diff --git a/.github/workflows/file-size-reduction-project71.campaign.lock.yml b/.github/workflows/file-size-reduction-project71.campaign.lock.yml index 2e5b65de03..1e44d25802 100644 --- a/.github/workflows/file-size-reduction-project71.campaign.lock.yml +++ b/.github/workflows/file-size-reduction-project71.campaign.lock.yml @@ -203,7 +203,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /tmp/gh-aw/safeoutputs/config.json << 'EOF' - {"add_comment":{"max":10},"create_issue":{"max":1},"create_project_status_update":{"max":1},"missing_tool":{},"noop":{"max":1},"update_project":{"max":10}} + {"add_comment":{"max":10},"copy_project":{"max":1},"create_issue":{"max":1},"create_project_status_update":{"max":1},"missing_tool":{"max":0},"noop":{"max":1},"update_project":{"max":10}} EOF cat > /tmp/gh-aw/safeoutputs/tools.json << 'EOF' [ @@ -407,6 +407,36 @@ jobs: "type": "object" }, "name": "update_project" + }, + { + "description": "Copy a GitHub Projects v2 board to create a new project with the same structure, fields, and views. Useful for duplicating project templates or migrating projects between organizations. By default, draft issues are not copied unless includeDraftIssues is set to true. If the workflow has configured default values for source-project or target-owner, those fields become optional in the tool call.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "includeDraftIssues": { + "description": "Whether to copy draft issues from the source project. Default: false.", + "type": "boolean" + }, + "owner": { + "description": "Login name of the organization or user that will own the new project (e.g., 'myorg' or 'username'). The token must have access to this owner. Optional if target-owner is configured in the workflow frontmatter.", + "type": "string" + }, + "sourceProject": { + "description": "Full GitHub project URL of the source project to copy (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Optional if source-project is configured in the workflow frontmatter.", + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", + "type": "string" + }, + "title": { + "description": "Title for the new project. Should be descriptive and unique within the owner's projects.", + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + }, + "name": "copy_project" } ] EOF @@ -1277,7 +1307,7 @@ jobs: To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - **Available tools**: add_comment, create_issue, create_project_status_update, missing_tool, noop, update_project + **Available tools**: add_comment, copy_project, create_issue, create_project_status_update, missing_tool, noop, update_project **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. @@ -1882,4 +1912,18 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/tmp/gh-aw/actions/update_project.cjs'); await main(); + - name: Copy Project + id: copy_project + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'copy_project')) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_COPY_PROJECT_SOURCE: "https://github.com/orgs/githubnext/projects/74" + with: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/tmp/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/tmp/gh-aw/actions/copy_project.cjs'); + await main(); diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index 0b234d7225..e83c1fdd9b 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/githubnext/gh-aw/pkg/constants" "github.com/githubnext/gh-aw/pkg/logger" "github.com/githubnext/gh-aw/pkg/workflow" "github.com/goccy/go-yaml" @@ -355,6 +356,19 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W } safeOutputs.CreateProjectStatusUpdates = statusUpdateConfig + // Allow copying GitHub Projects for campaign setup (max: 1, typically used once). + // Default source project is the githubnext "[TEMPLATE: Agentic Campaign]" project. + copyProjectConfig := &workflow.CopyProjectsConfig{ + BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 1}, + SourceProject: string(constants.DefaultCampaignTemplateProjectURL), + } + // Use the same custom GitHub token for copy-project as for other project operations. + if strings.TrimSpace(spec.ProjectGitHubToken) != "" { + copyProjectConfig.GitHubToken = strings.TrimSpace(spec.ProjectGitHubToken) + orchestratorLog.Printf("Campaign orchestrator '%s' configured with custom GitHub token for copy-project", spec.ID) + } + safeOutputs.CopyProjects = copyProjectConfig + orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with safe outputs enabled", spec.ID) // Extract file-glob patterns from memory-paths or metrics-glob to support diff --git a/pkg/campaign/orchestrator_test.go b/pkg/campaign/orchestrator_test.go index 69a1a524a0..dd979bfd0d 100644 --- a/pkg/campaign/orchestrator_test.go +++ b/pkg/campaign/orchestrator_test.go @@ -322,6 +322,82 @@ func TestBuildOrchestrator_GovernanceOverridesSafeOutputMaxima(t *testing.T) { } } +func TestBuildOrchestrator_CopyProjectSafeOutput(t *testing.T) { + t.Run("copy-project enabled by default with template source", func(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Description: "A test campaign", + ProjectURL: "https://github.com/orgs/test/projects/1", + Workflows: []string{"test-workflow"}, + } + + mdPath := ".github/workflows/test-campaign.campaign.md" + data, _ := BuildOrchestrator(spec, mdPath) + + if data == nil { + t.Fatalf("expected non-nil WorkflowData") + } + + // Verify that SafeOutputs is configured + if data.SafeOutputs == nil { + t.Fatalf("expected SafeOutputs to be configured") + } + + // Verify that CopyProjects is configured + if data.SafeOutputs.CopyProjects == nil { + t.Fatalf("expected CopyProjects to be configured") + } + + // Verify that the max is 1 + if data.SafeOutputs.CopyProjects.Max != 1 { + t.Errorf("expected CopyProjects max to be 1, got %d", data.SafeOutputs.CopyProjects.Max) + } + + // Verify that the default source project is set to the template project + expectedSourceProject := "https://github.com/orgs/githubnext/projects/74" + if data.SafeOutputs.CopyProjects.SourceProject != expectedSourceProject { + t.Errorf("expected CopyProjects source-project to be %q, got %q", + expectedSourceProject, data.SafeOutputs.CopyProjects.SourceProject) + } + }) + + t.Run("copy-project with custom github token", func(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign-with-token", + Name: "Test Campaign", + Description: "A test campaign with custom GitHub token", + ProjectURL: "https://github.com/orgs/test/projects/1", + Workflows: []string{"test-workflow"}, + ProjectGitHubToken: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}", + } + + mdPath := ".github/workflows/test-campaign.campaign.md" + data, _ := BuildOrchestrator(spec, mdPath) + + if data == nil { + t.Fatalf("expected non-nil WorkflowData") + } + + // Verify that SafeOutputs is configured + if data.SafeOutputs == nil { + t.Fatalf("expected SafeOutputs to be configured") + } + + // Verify that CopyProjects is configured + if data.SafeOutputs.CopyProjects == nil { + t.Fatalf("expected CopyProjects to be configured") + } + + // Verify that the GitHubToken is set + if data.SafeOutputs.CopyProjects.GitHubToken != "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}" { + t.Errorf("expected CopyProjects GitHubToken to be %q, got %q", + "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}", + data.SafeOutputs.CopyProjects.GitHubToken) + } + }) +} + func TestExtractFileGlobPatterns(t *testing.T) { tests := []struct { name string diff --git a/pkg/cli/compile_orchestrator.go b/pkg/cli/compile_orchestrator.go index 4594ab4416..1fc54b2206 100644 --- a/pkg/cli/compile_orchestrator.go +++ b/pkg/cli/compile_orchestrator.go @@ -111,6 +111,24 @@ func renderGeneratedCampaignOrchestratorMarkdown(data *workflow.WorkflowData, so } outputs["create-project-status-update"] = statusUpdateConfig } + if data.SafeOutputs.CopyProjects != nil { + copyProjectConfig := map[string]any{ + "max": data.SafeOutputs.CopyProjects.Max, + } + // Include source-project if specified + if strings.TrimSpace(data.SafeOutputs.CopyProjects.SourceProject) != "" { + copyProjectConfig["source-project"] = data.SafeOutputs.CopyProjects.SourceProject + } + // Include target-owner if specified + if strings.TrimSpace(data.SafeOutputs.CopyProjects.TargetOwner) != "" { + copyProjectConfig["target-owner"] = data.SafeOutputs.CopyProjects.TargetOwner + } + // Include github-token if specified + if strings.TrimSpace(data.SafeOutputs.CopyProjects.GitHubToken) != "" { + copyProjectConfig["github-token"] = data.SafeOutputs.CopyProjects.GitHubToken + } + outputs["copy-project"] = copyProjectConfig + } if len(outputs) > 0 { payload := map[string]any{"safe-outputs": outputs} if out, err := yaml.Marshal(payload); err == nil { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 9e4b769969..2f0cb5cb41 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -194,6 +194,10 @@ const ExpressionBreakThreshold LineLength = 100 // DefaultMCPRegistryURL is the default MCP registry URL. const DefaultMCPRegistryURL URL = "https://api.mcp.github.com/v0" +// DefaultCampaignTemplateProjectURL is the default source project URL for copying campaign templates. +// This points to the githubnext "[TEMPLATE: Agentic Campaign]" project (Project 74). +const DefaultCampaignTemplateProjectURL URL = "https://github.com/orgs/githubnext/projects/74" + // DefaultClaudeCodeVersion is the default version of the Claude Code CLI. const DefaultClaudeCodeVersion Version = "2.0.76" diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index ffceaf0c56..ba20c59aa9 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -197,6 +197,12 @@ func generateSafeOutputsConfig(data *WorkflowData) string { 10, // default max ) } + if data.SafeOutputs.CopyProjects != nil { + safeOutputsConfig["copy_project"] = generateMaxConfig( + data.SafeOutputs.CopyProjects.Max, + 1, // default max + ) + } if data.SafeOutputs.UpdateRelease != nil { safeOutputsConfig["update_release"] = generateMaxConfig( data.SafeOutputs.UpdateRelease.Max, @@ -525,6 +531,9 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, if data.SafeOutputs.CreateProjectStatusUpdates != nil { enabledTools["create_project_status_update"] = true } + if data.SafeOutputs.CopyProjects != nil { + enabledTools["copy_project"] = true + } // Note: dispatch_workflow tools are generated dynamically below, not from the static tools list // Filter tools to only include enabled ones and enhance descriptions