From f7c2bbc356f8eb75b8e173700a3c690f02d6b546 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:08:49 +0000 Subject: [PATCH 1/4] Initial plan From 27d9eb36eb95eccc4a7cad33fcf4a171a15c5e90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:22:15 +0000 Subject: [PATCH 2/4] Remove automatic campaign_id generation for ProjectOps - Make campaign_id completely optional in update_project - Only add campaign label when campaign_id is explicitly provided - Update tests to verify new behavior - Add test for explicit campaign_id usage Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- .github/workflows/campaign-manager.lock.yml | 12 ++++--- ...ze-reduction-project64.campaign.g.lock.yml | 12 ++++--- pkg/workflow/js/update_project.cjs | 12 ++++--- pkg/workflow/js/update_project.test.cjs | 32 +++++++++++-------- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/.github/workflows/campaign-manager.lock.yml b/.github/workflows/campaign-manager.lock.yml index e6feb5e713c..24f4af18d5c 100644 --- a/.github/workflows/campaign-manager.lock.yml +++ b/.github/workflows/campaign-manager.lock.yml @@ -9621,7 +9621,7 @@ jobs: const { owner, repo } = context.repo, projectInfo = parseProjectUrl(output.project), projectNumberFromUrl = projectInfo.projectNumber, - campaignId = output.campaign_id || generateCampaignId(output.project, projectNumberFromUrl); + campaignId = output.campaign_id; try { let repoResult; (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information...")); @@ -9805,10 +9805,12 @@ jobs: { projectId, contentId } ) ).addProjectV2ItemById.item.id; - try { - await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); - } catch (labelError) { - core.warning(`Failed to add campaign label: ${labelError.message}`); + if (campaignId) { + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); + } catch (labelError) { + core.warning(`Failed to add campaign label: ${labelError.message}`); + } } } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml index 3847c532936..c3a2defec60 100644 --- a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml +++ b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml @@ -7767,7 +7767,7 @@ jobs: const { owner, repo } = context.repo, projectInfo = parseProjectUrl(output.project), projectNumberFromUrl = projectInfo.projectNumber, - campaignId = output.campaign_id || generateCampaignId(output.project, projectNumberFromUrl); + campaignId = output.campaign_id; try { let repoResult; (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information...")); @@ -7951,10 +7951,12 @@ jobs: { projectId, contentId } ) ).addProjectV2ItemById.item.id; - try { - await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); - } catch (labelError) { - core.warning(`Failed to add campaign label: ${labelError.message}`); + if (campaignId) { + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); + } catch (labelError) { + core.warning(`Failed to add campaign label: ${labelError.message}`); + } } } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; diff --git a/pkg/workflow/js/update_project.cjs b/pkg/workflow/js/update_project.cjs index b317b879624..78cbbbec341 100644 --- a/pkg/workflow/js/update_project.cjs +++ b/pkg/workflow/js/update_project.cjs @@ -125,7 +125,7 @@ async function updateProject(output) { const { owner, repo } = context.repo, projectInfo = parseProjectUrl(output.project), projectNumberFromUrl = projectInfo.projectNumber, - campaignId = output.campaign_id || generateCampaignId(output.project, projectNumberFromUrl); + campaignId = output.campaign_id; try { let repoResult; (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information...")); @@ -312,10 +312,12 @@ async function updateProject(output) { { projectId, contentId } ) ).addProjectV2ItemById.item.id; - try { - await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); - } catch (labelError) { - core.warning(`Failed to add campaign label: ${labelError.message}`); + if (campaignId) { + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); + } catch (labelError) { + core.warning(`Failed to add campaign label: ${labelError.message}`); + } } } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; diff --git a/pkg/workflow/js/update_project.test.cjs b/pkg/workflow/js/update_project.test.cjs index 1c6e338ecf5..514dc17c69e 100644 --- a/pkg/workflow/js/update_project.test.cjs +++ b/pkg/workflow/js/update_project.test.cjs @@ -245,6 +245,19 @@ describe("updateProject", () => { await updateProject(output); + // No campaign label should be added when campaign_id is not provided + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + expect(getOutput("item-id")).toBe("item123"); + }); + + it("adds an issue to a project board with campaign label when campaign_id provided", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { type: "update_project", project: projectUrl, content_type: "issue", content_number: 42, campaign_id: "my-campaign" }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), linkResponse, issueResponse("issue-id-42"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item123" } } }]); + + await updateProject(output); + const labelCall = mockGithub.rest.issues.addLabels.mock.calls[0][0]; expect(labelCall).toEqual( expect.objectContaining({ @@ -253,7 +266,7 @@ describe("updateProject", () => { issue_number: 42, }) ); - expect(labelCall.labels).toEqual([expect.stringMatching(/^campaign:testowner-project-60-[a-z0-9]{8}$/)]); + expect(labelCall.labels).toEqual(["campaign:my-campaign"]); expect(getOutput("item-id")).toBe("item123"); }); @@ -311,15 +324,8 @@ describe("updateProject", () => { await updateProject(output); - const labelCall = mockGithub.rest.issues.addLabels.mock.calls[0][0]; - expect(labelCall).toEqual( - expect.objectContaining({ - owner: "testowner", - repo: "testrepo", - issue_number: 17, - }) - ); - expect(labelCall.labels).toEqual([expect.stringMatching(/^campaign:testowner-project-60-[a-z0-9]{8}$/)]); + // No campaign label should be added when campaign_id is not provided + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); }); it("falls back to legacy issue field when content_number missing", async () => { @@ -332,8 +338,8 @@ describe("updateProject", () => { expect(mockCore.warning).toHaveBeenCalledWith('Field "issue" deprecated; use "content_number" instead.'); - const labelCall = mockGithub.rest.issues.addLabels.mock.calls[0][0]; - expect(labelCall.issue_number).toBe(101); + // No campaign label should be added when campaign_id is not provided + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); expect(getOutput("item-id")).toBe("legacy-item"); }); @@ -526,7 +532,7 @@ describe("updateProject", () => { it("warns when adding the campaign label fails", async () => { const projectUrl = "https://github.com/orgs/testowner/projects/60"; - const output = { type: "update_project", project: projectUrl, content_type: "issue", content_number: 50 }; + const output = { type: "update_project", project: projectUrl, content_type: "issue", content_number: 50, campaign_id: "test-campaign" }; queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-label"), linkResponse, issueResponse("issue-id-50"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-label" } } }]); From d42128ab2d557afbdb1fcd4eb1457462c31484bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:33:54 +0000 Subject: [PATCH 3/4] Update campaign templates to require explicit campaign_id - Add campaign_id instructions to campaign orchestrator templates - Update template test to verify campaign_id is included - Update documentation to clarify campaign_id is optional and campaign-specific Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- .github/workflows/issue-classifier.lock.yml | 2 +- .github/workflows/release.lock.yml | 6 +++--- .../workflows/stale-repo-identifier.lock.yml | 2 +- .github/workflows/super-linter.lock.yml | 2 +- docs/src/content/docs/reference/safe-outputs.md | 4 +++- .../prompts/project_update_instructions.md | 9 +++++++-- pkg/campaign/template_test.go | 17 +++++++++++++++++ 7 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index ddfc3f26710..733eb8392fa 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -2999,7 +2999,7 @@ jobs: path: /tmp/gh-aw/aw_info.json if-no-files-found: warn - name: Run AI Inference - uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4 + uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v1 env: GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index b6f5612fe73..e854eb4eeac 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -6502,13 +6502,13 @@ jobs: - name: Download Go modules run: go mod download - name: Generate SBOM (SPDX format) - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.10 with: artifact-name: sbom.spdx.json format: spdx-json output-file: sbom.spdx.json - name: Generate SBOM (CycloneDX format) - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.10 with: artifact-name: sbom.cdx.json format: cyclonedx-json @@ -6698,7 +6698,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Release with gh-extension-precompile - uses: cli/gh-extension-precompile@9e2237c30f869ad3bcaed6a4be2cd43564dd421b # v2.1.0 + uses: cli/gh-extension-precompile@9e2237c30f869ad3bcaed6a4be2cd43564dd421b # v2 with: build_script_override: scripts/build-release.sh go_version_file: go.mod diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index d3b0dba4e86..62826fd0598 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -231,7 +231,7 @@ jobs: ORGANIZATION: ${{ env.ORGANIZATION }} id: stale-repos name: Run stale_repos tool - uses: github/stale-repos@a21e55567b83cf3c3f3f9085d3038dc6cee02598 # v3.0.2 + uses: github/stale-repos@a21e55567b83cf3c3f3f9085d3038dc6cee02598 # v3 - env: INACTIVE_REPOS: ${{ steps.stale-repos.outputs.inactiveRepos }} name: Save stale repos output diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index 3f60c9a5651..b8ad8e7f5f3 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -7546,7 +7546,7 @@ jobs: persist-credentials: false - name: Super-linter id: super-linter - uses: super-linter/super-linter@47984f49b4e87383eed97890fe2dca6063bbd9c3 # v8.3.1 + uses: super-linter/super-linter@47984f49b4e87383eed97890fe2dca6063bbd9c3 # v8.2.1 env: CREATE_LOG_FILE: "true" DEFAULT_BRANCH: main diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index e153c9578c6..e603b57f0f9 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -358,7 +358,9 @@ safe-outputs: github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} # required: PAT with Projects access ``` -Agent output **must include a full GitHub project URL** in the `project` field (e.g., `https://github.com/orgs/myorg/projects/42` or `https://github.com/users/username/projects/5`). Project names or numbers alone are not accepted. Can also supply `content_number`, `content_type`, `fields`, and `campaign_id`. When `campaign_id` is provided, `update-project` treats it as the campaign tracker identifier and applies the `campaign:` label (for example, `campaign_id: security-sprint` results in `campaign:security-sprint`). See [Agentic Campaign Workflows](/gh-aw/guides/campaigns/) for the end-to-end campaign model. +Agent output **must include a full GitHub project URL** in the `project` field (e.g., `https://github.com/orgs/myorg/projects/42` or `https://github.com/users/username/projects/5`). Project names or numbers alone are not accepted. Can also supply `content_number`, `content_type`, and `fields` to add items and update their fields. + +**Campaign tracking (optional)**: When `campaign_id` is provided, `update-project` applies a `campaign:` label to newly added issues/PRs for tracking purposes (for example, `campaign_id: security-sprint` results in `campaign:security-sprint`). This is used for [Agentic Campaign Workflows](/gh-aw/guides/campaigns/) but is not required for general project board management. ProjectOps workflows typically do not need `campaign_id`. The job adds the issue or PR to the board, updates custom fields, and exposes `project-id`, `project-number`, `project-url`, `campaign-id`, and `item-id` outputs. Cross-repository targeting not supported. diff --git a/pkg/campaign/prompts/project_update_instructions.md b/pkg/campaign/prompts/project_update_instructions.md index c0f61b330b9..1ecc1cffca4 100644 --- a/pkg/campaign/prompts/project_update_instructions.md +++ b/pkg/campaign/prompts/project_update_instructions.md @@ -4,6 +4,9 @@ Execute state writes using the `update-project` safe-output. All writes must target this exact project URL: **Project URL**: {{.ProjectURL}} +{{if .TrackerLabel}} +**Campaign ID**: Extract from tracker label `{{.TrackerLabel}}` (format: `campaign:CAMPAIGN_ID`) +{{end}} #### Adding New Issues @@ -13,7 +16,8 @@ update-project: project: "{{.ProjectURL}}" item_url: "ISSUE_URL" status: "Todo" # or "Done" if issue is already closed -``` +{{if .TrackerLabel}} campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label {{.TrackerLabel}} +{{end}}``` **Note**: If your project board has `Start Date` and `End Date` fields, these will be **automatically populated** from the issue/PR timestamps: - `Start Date` is set from the issue's `createdAt` timestamp @@ -29,7 +33,8 @@ update-project: project: "{{.ProjectURL}}" item_url: "ISSUE_URL" status: "Done" # or "In Progress", "Todo" -``` +{{if .TrackerLabel}} campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label {{.TrackerLabel}} +{{end}}``` #### Idempotency diff --git a/pkg/campaign/template_test.go b/pkg/campaign/template_test.go index 28419245b20..509b5536f0c 100644 --- a/pkg/campaign/template_test.go +++ b/pkg/campaign/template_test.go @@ -93,6 +93,23 @@ func TestRenderProjectUpdateInstructions(t *testing.T) { }, shouldBeEmpty: false, }, + { + name: "with project URL and tracker label", + data: CampaignPromptData{ + ProjectURL: "https://github.com/orgs/test/projects/1", + TrackerLabel: "campaign:my-campaign", + }, + shouldContain: []string{ + "Project Board Integration", + "update-project", + "https://github.com/orgs/test/projects/1", + "Campaign ID", + "campaign:my-campaign", + "campaign_id:", + "CAMPAIGN_ID", + }, + shouldBeEmpty: false, + }, { name: "without project URL", data: CampaignPromptData{ From de33d1a681bb88dd5ce6102339d0b61a54a20530 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:40:37 +0000 Subject: [PATCH 4/4] Remove automatic campaign_id generation from ProjectOps Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- .github/workflows/issue-classifier.lock.yml | 2 +- .github/workflows/release.lock.yml | 6 +++--- .github/workflows/stale-repo-identifier.lock.yml | 2 +- .github/workflows/super-linter.lock.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 733eb8392fa..ddfc3f26710 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -2999,7 +2999,7 @@ jobs: path: /tmp/gh-aw/aw_info.json if-no-files-found: warn - name: Run AI Inference - uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v1 + uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4 env: GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index e854eb4eeac..b6f5612fe73 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -6502,13 +6502,13 @@ jobs: - name: Download Go modules run: go mod download - name: Generate SBOM (SPDX format) - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.10 + uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 with: artifact-name: sbom.spdx.json format: spdx-json output-file: sbom.spdx.json - name: Generate SBOM (CycloneDX format) - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.10 + uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 with: artifact-name: sbom.cdx.json format: cyclonedx-json @@ -6698,7 +6698,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Release with gh-extension-precompile - uses: cli/gh-extension-precompile@9e2237c30f869ad3bcaed6a4be2cd43564dd421b # v2 + uses: cli/gh-extension-precompile@9e2237c30f869ad3bcaed6a4be2cd43564dd421b # v2.1.0 with: build_script_override: scripts/build-release.sh go_version_file: go.mod diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 62826fd0598..d3b0dba4e86 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -231,7 +231,7 @@ jobs: ORGANIZATION: ${{ env.ORGANIZATION }} id: stale-repos name: Run stale_repos tool - uses: github/stale-repos@a21e55567b83cf3c3f3f9085d3038dc6cee02598 # v3 + uses: github/stale-repos@a21e55567b83cf3c3f3f9085d3038dc6cee02598 # v3.0.2 - env: INACTIVE_REPOS: ${{ steps.stale-repos.outputs.inactiveRepos }} name: Save stale repos output diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index b8ad8e7f5f3..3f60c9a5651 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -7546,7 +7546,7 @@ jobs: persist-credentials: false - name: Super-linter id: super-linter - uses: super-linter/super-linter@47984f49b4e87383eed97890fe2dca6063bbd9c3 # v8.2.1 + uses: super-linter/super-linter@47984f49b4e87383eed97890fe2dca6063bbd9c3 # v8.3.1 env: CREATE_LOG_FILE: "true" DEFAULT_BRANCH: main