diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index c38c820fefb..d39f6cdd7fe 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -617,21 +617,71 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; - const result = await github.graphql( - `mutation($projectId: ID!, $title: String!, $body: String) { - addProjectV2DraftIssue(input: { - projectId: $projectId, - title: $title, - body: $body - }) { - projectItem { - id + + // Check for existing draft issue with the same title + const existingDraftItem = await (async function findExistingDraftByTitle(projectId, targetTitle) { + let hasNextPage = true; + let endCursor = null; + + while (hasNextPage) { + const result = await github.graphql( + `query($projectId: ID!, $after: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $after) { + nodes { + id + content { + __typename + ... on DraftIssue { + id + title + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + }`, + { projectId, after: endCursor } + ); + + const found = result.node.items.nodes.find(item => item.content?.__typename === "DraftIssue" && item.content.title === targetTitle); + if (found) return found; + + hasNextPage = result.node.items.pageInfo.hasNextPage; + endCursor = result.node.items.pageInfo.endCursor; + } + + return null; + })(projectId, draftTitle); + + let itemId; + if (existingDraftItem) { + itemId = existingDraftItem.id; + core.info(`✓ Found existing draft issue "${draftTitle}" - updating fields instead of creating duplicate`); + } else { + const result = await github.graphql( + `mutation($projectId: ID!, $title: String!, $body: String) { + addProjectV2DraftIssue(input: { + projectId: $projectId, + title: $title, + body: $body + }) { + projectItem { + id + } } - } - }`, - { projectId, title: draftTitle, body: draftBody } - ); - const itemId = result.addProjectV2DraftIssue.projectItem.id; + }`, + { projectId, title: draftTitle, body: draftBody } + ); + itemId = result.addProjectV2DraftIssue.projectItem.id; + core.info(`✓ Created new draft issue "${draftTitle}"`); + } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; if (Object.keys(fieldsToUpdate).length > 0) { diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 305b7366e46..cc3e2708add 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -267,6 +267,24 @@ const addDraftIssueResponse = (itemId = "draft-item") => ({ }, }); +const existingDraftItemResponse = (title, itemId = "existing-draft-item") => ({ + node: { + items: { + nodes: [ + { + id: itemId, + content: { + __typename: "DraftIssue", + id: `draft-content-${itemId}`, + title: title, + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, +}); + function queueResponses(responses) { responses.forEach(response => { mockGithub.graphql.mockResolvedValueOnce(response); @@ -427,13 +445,20 @@ describe("updateProject", () => { draft_body: "Draft body", }; - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), addDraftIssueResponse("draft-item-1")]); + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-draft"), + emptyItemsResponse(), // No existing drafts with this title + addDraftIssueResponse("draft-item-1"), + ]); await updateProject(output); expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(true); expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); expect(getOutput("item-id")).toBe("draft-item-1"); + expect(mockCore.info).toHaveBeenCalledWith('✓ Created new draft issue "Draft title"'); }); it("rejects draft issues without a title", async () => { @@ -450,6 +475,36 @@ describe("updateProject", () => { await expect(updateProject(output)).rejects.toThrow(/draft_title/); }); + it("reuses existing draft issue instead of creating duplicate", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_title: "Existing Draft", + fields: { Status: "In Progress" }, + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-draft"), + existingDraftItemResponse("Existing Draft", "existing-draft-123"), // Draft with same title exists + fieldsResponse([{ id: "field-status", name: "Status" }]), + updateFieldValueResponse(), + ]); + + await updateProject(output); + + // Should NOT call addProjectV2DraftIssue since draft already exists + expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(false); + // Should call updateProjectV2ItemFieldValue to update the existing draft + expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("updateProjectV2ItemFieldValue"))).toBe(true); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + expect(getOutput("item-id")).toBe("existing-draft-123"); + expect(mockCore.info).toHaveBeenCalledWith('✓ Found existing draft issue "Existing Draft" - updating fields instead of creating duplicate'); + }); + it("skips adding an issue that already exists on the board", async () => { const projectUrl = "https://github.com/orgs/testowner/projects/60"; const output = { type: "update_project", project: projectUrl, content_type: "issue", content_number: 99 }; @@ -583,6 +638,7 @@ describe("updateProject", () => { repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft-fields"), + emptyItemsResponse(), // No existing drafts with this title addDraftIssueResponse("draft-item-fields"), fieldsResponse([{ id: "field-status", name: "Status" }]), updateFieldValueResponse(), @@ -1263,7 +1319,13 @@ describe("updateProject", () => { draft_body: "This is a test", }; - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(messageProjectUrl, 60, "project-message"), addDraftIssueResponse("draft-item-message")]); + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(messageProjectUrl, 60, "project-message"), + emptyItemsResponse(), // No existing drafts with this title + addDraftIssueResponse("draft-item-message"), + ]); const result = await messageHandler(messageWithProject, new Map());