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/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{ 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" } } }]);