From 83e7dc6d8b13e2ea820df8e9df228d2526e6d57e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:39:55 +0000 Subject: [PATCH 1/3] Initial plan From be001c2e247044f46942c18818e5c5205e486c69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:48:07 +0000 Subject: [PATCH 2/3] Fix duplicate draft issue creation in update-project Add deduplication logic to check for existing draft issues by title before creating new ones. When updating fields on a draft issue, the code now reuses the existing item ID instead of creating a duplicate. Changes: - Added findExistingDraftByTitle helper function to query project items - Modified draft issue creation to check for existing drafts first - Only creates new draft if no matching title found - Added existingDraftItemResponse test helper - Updated all tests to include emptyItemsResponse for deduplication check - Added new test case for reusing existing draft issues Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 77 +++++++++++++++++++----- actions/setup/js/update_project.test.cjs | 66 +++++++++++++++++++- 2 files changed, 127 insertions(+), 16 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index c38c820fefb..e58c27c9b8f 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -617,21 +617,70 @@ 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 { + ... 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()); From 38d5373d5d55e1007e0fd9067fb3110814b7f44b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:09:56 +0000 Subject: [PATCH 3/3] Fix __typename missing from GraphQL query The deduplication wasn't working because __typename was being checked but not requested in the GraphQL query. This caused the check to always fail since __typename was undefined. Changes: - Added __typename to the GraphQL query for draft issue content - Now the __typename === "DraftIssue" check works correctly - Deduplication now properly identifies existing draft issues Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index e58c27c9b8f..d39f6cdd7fe 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -632,6 +632,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = nodes { id content { + __typename ... on DraftIssue { id title