From b0e4c9f52261e8b2c96cb55276263f647944d7a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 05:49:34 +0000 Subject: [PATCH 1/6] Initial plan From 4cad5abd0058d958b4ae3c43ffe306e09883c736 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 05:58:08 +0000 Subject: [PATCH 2/6] Fix update-project to reuse existing draft issues instead of creating duplicates Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 81 ++++++++++++++++++++---- actions/setup/js/update_project.test.cjs | 42 +++++++++++- 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index c38c820fefb..0c0fef2ddf8 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -617,21 +617,74 @@ 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 if a draft issue with this title already exists on the board + const existingDraftItem = await (async function (projectId, title) { + 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 Issue { + id + } + ... on PullRequest { + id + } + ... on DraftIssue { + id + title + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + }`, + { projectId, after: endCursor } + ); + + const found = result.node.items.nodes.find(item => item.content && item.content.title === title); + 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("✓ Draft issue already on board"); + } 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; + } 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..d90a7597b96 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -249,6 +249,24 @@ const existingItemResponse = (contentId, itemId = "existing-item") => ({ }, }); +const existingDraftItemResponse = (title, itemId = "existing-draft-item") => ({ + node: { + items: { + nodes: [{ id: itemId, content: { id: "draft-content-id", title } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, +}); + +const emptyDraftItemsResponse = () => ({ + node: { + items: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, +}); + const fieldsResponse = nodes => ({ node: { fields: { nodes } } }); const updateFieldValueResponse = () => ({ @@ -427,7 +445,7 @@ 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"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-item-1")]); await updateProject(output); @@ -450,6 +468,25 @@ describe("updateProject", () => { await expect(updateProject(output)).rejects.toThrow(/draft_title/); }); + it("skips adding a draft 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: "draft_issue", + draft_title: "Draft title", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), existingDraftItemResponse("Draft title", "existing-draft-item")]); + + await updateProject(output); + + expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board"); + expect(getOutput("item-id")).toBe("existing-draft-item"); + // Should not call addProjectV2DraftIssue mutation + expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(false); + }); + 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 +620,7 @@ describe("updateProject", () => { repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft-fields"), + emptyDraftItemsResponse(), addDraftIssueResponse("draft-item-fields"), fieldsResponse([{ id: "field-status", name: "Status" }]), updateFieldValueResponse(), @@ -1263,7 +1301,7 @@ 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"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-item-message")]); const result = await messageHandler(messageWithProject, new Map()); From 4aca2c9d3600c8d455120d914b171efef5ca1df1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 08:27:24 +0000 Subject: [PATCH 3/6] Add temporary ID support for draft issues in update-project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accept optional temporary_id field in draft issue messages - Store temporary_id → draftItemId mapping when created - Support referencing draft issues by temporary ID alone (no title needed) - Fall back to title matching when no temporary ID in map - Add test for temporary ID-based draft issue reference - All 3358 JavaScript tests passing Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 146 +++++++++++++---------- actions/setup/js/update_project.test.cjs | 54 ++++++++- 2 files changed, 138 insertions(+), 62 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 0c0fef2ddf8..18b5b357e3f 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -3,7 +3,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { loadTemporaryIdMap, resolveIssueNumber } = require("./temporary_id.cjs"); +const { loadTemporaryIdMap, resolveIssueNumber, generateTemporaryId, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); /** * Log detailed GraphQL error information @@ -611,79 +611,103 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".'); } - const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; - if (!draftTitle) { - throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.'); - } + // Get or generate temporary ID for this draft issue + const temporaryId = output.temporary_id ?? generateTemporaryId(); + const normalizedTempId = normalizeTemporaryId(temporaryId); - const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; + // Check if we're referencing an existing draft by temporary ID only + const existingMapping = temporaryIdMap.get(normalizedTempId); + const referencingById = existingMapping && existingMapping.draftItemId && !output.draft_title; - // Check if a draft issue with this title already exists on the board - const existingDraftItem = await (async function (projectId, title) { - let hasNextPage = true; - let endCursor = null; + let itemId; - 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 Issue { - id - } - ... on PullRequest { - id - } - ... on DraftIssue { - id - title + if (referencingById) { + // Referencing existing draft issue by temporary ID (no title needed) + itemId = existingMapping.draftItemId; + core.info(`✓ Draft issue found via temporary ID ${temporaryId}`); + } else { + // Creating or finding draft issue (title required) + const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; + if (!draftTitle) { + throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required unless referencing an existing draft by temporary_id.'); + } + + const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; + // Check if a draft issue with this title already exists on the board + const existingDraftItem = await (async function (projectId, title) { + 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 Issue { + id + } + ... on PullRequest { + id + } + ... on DraftIssue { + id + title + } } } - } - pageInfo { - hasNextPage - endCursor + pageInfo { + hasNextPage + endCursor + } } } } + }`, + { projectId, after: endCursor } + ); + + const found = result.node.items.nodes.find(item => item.content && item.content.title === title); + if (found) return found; + + hasNextPage = result.node.items.pageInfo.hasNextPage; + endCursor = result.node.items.pageInfo.endCursor; + } + return null; + })(projectId, draftTitle); + + if (existingDraftItem) { + itemId = existingDraftItem.id; + core.info("✓ Draft issue already on board (matched by title)"); + + // Store the mapping for future references + temporaryIdMap.set(normalizedTempId, { draftItemId: itemId }); + core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemId}`); + } else { + // Create new draft issue + const result = await github.graphql( + `mutation($projectId: ID!, $title: String!, $body: String) { + addProjectV2DraftIssue(input: { + projectId: $projectId, + title: $title, + body: $body + }) { + projectItem { + id + } } }`, - { projectId, after: endCursor } + { projectId, title: draftTitle, body: draftBody } ); + itemId = result.addProjectV2DraftIssue.projectItem.id; - const found = result.node.items.nodes.find(item => item.content && item.content.title === title); - if (found) return found; - - hasNextPage = result.node.items.pageInfo.hasNextPage; - endCursor = result.node.items.pageInfo.endCursor; + // Store the mapping for future references + temporaryIdMap.set(normalizedTempId, { draftItemId: itemId }); + core.info(`Created draft issue and stored temporary ID mapping: ${temporaryId} -> ${itemId}`); } - return null; - })(projectId, draftTitle); - - let itemId; - if (existingDraftItem) { - itemId = existingDraftItem.id; - core.info("✓ Draft issue already on board"); - } 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 } - ); - itemId = result.addProjectV2DraftIssue.projectItem.id; } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index d90a7597b96..c7bc28b7179 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -481,12 +481,64 @@ describe("updateProject", () => { await updateProject(output); - expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board"); + expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board (matched by title)"); expect(getOutput("item-id")).toBe("existing-draft-item"); // Should not call addProjectV2DraftIssue mutation expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(false); }); + it("creates draft issue with temporary_id and allows reference by ID", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + + // Create a shared temporary ID map to simulate persistence + const temporaryIdMap = new Map(); + + // First call: create with temporary_id + const createOutput = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_title: "Task 1", + temporary_id: "aw_abc123def456", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-temp-id"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-temp-1")]); + + // Get the handler factory and call it with the message + const handler = await updateProjectHandlerFactory({}); + await handler(createOutput, temporaryIdMap); + + expect(mockCore.info).toHaveBeenCalledWith("Created draft issue and stored temporary ID mapping: aw_abc123def456 -> draft-temp-1"); + expect(getOutput("item-id")).toBe("draft-temp-1"); + + // Verify the temporary ID was stored + expect(temporaryIdMap.has("aw_abc123def456")).toBe(true); + expect(temporaryIdMap.get("aw_abc123def456")).toEqual({ draftItemId: "draft-temp-1" }); + + // Second call: reference by temporary_id only (no title needed) + const updateOutput = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + temporary_id: "aw_abc123def456", + fields: { Status: "Done" }, + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-temp-id"), + fieldsResponse([{ id: "field-status", name: "Status", dataType: "SINGLE_SELECT", options: [{ id: "opt-done", name: "Done" }] }]), + updateFieldValueResponse(), + ]); + + await handler(updateOutput, temporaryIdMap); + + // Check that the message was logged (may be among other info messages) + const infoCalls = mockCore.info.mock.calls.map(call => call[0]); + expect(infoCalls).toContain("✓ Draft issue found via temporary ID aw_abc123def456"); + }); + 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 }; From eb4f4d6ce731c525d2be70b237fcc743f2ae6408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:15:38 +0000 Subject: [PATCH 4/6] Output temporary-id for draft issues to enable agent references - Add core.setOutput("temporary-id", temporaryId) for draft issues - Enables agents to reference created drafts using returned temporary_id - Addresses issue where agents couldn't identify "the draft issue you created" - Tests passing (45 update_project tests) 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 18b5b357e3f..e955971c9e1 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -838,6 +838,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } core.setOutput("item-id", itemId); + core.setOutput("temporary-id", temporaryId); return; } let contentNumber = null; From 77fd9f7d9be45a2a23e0adbb7915883377ae5041 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:26:53 +0000 Subject: [PATCH 5/6] Simplify to title-based deduplication only (remove temporary ID complexity) - Removed temporary ID imports and complex mapping logic - Keep only simple title-based deduplication - Query existing drafts by title, reuse if found - All 44 update_project tests passing - Cleaner, more maintainable solution Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 147 ++++++++++------------- actions/setup/js/update_project.test.cjs | 54 +-------- 2 files changed, 62 insertions(+), 139 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index e955971c9e1..0c0fef2ddf8 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -3,7 +3,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { loadTemporaryIdMap, resolveIssueNumber, generateTemporaryId, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); +const { loadTemporaryIdMap, resolveIssueNumber } = require("./temporary_id.cjs"); /** * Log detailed GraphQL error information @@ -611,103 +611,79 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".'); } - // Get or generate temporary ID for this draft issue - const temporaryId = output.temporary_id ?? generateTemporaryId(); - const normalizedTempId = normalizeTemporaryId(temporaryId); - - // Check if we're referencing an existing draft by temporary ID only - const existingMapping = temporaryIdMap.get(normalizedTempId); - const referencingById = existingMapping && existingMapping.draftItemId && !output.draft_title; - - let itemId; + const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; + if (!draftTitle) { + throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.'); + } - if (referencingById) { - // Referencing existing draft issue by temporary ID (no title needed) - itemId = existingMapping.draftItemId; - core.info(`✓ Draft issue found via temporary ID ${temporaryId}`); - } else { - // Creating or finding draft issue (title required) - const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; - if (!draftTitle) { - throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required unless referencing an existing draft by temporary_id.'); - } + const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; - const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; - // Check if a draft issue with this title already exists on the board - const existingDraftItem = await (async function (projectId, title) { - let hasNextPage = true; - let endCursor = null; + // Check if a draft issue with this title already exists on the board + const existingDraftItem = await (async function (projectId, title) { + 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 Issue { - id - } - ... on PullRequest { - id - } - ... on DraftIssue { - id - title - } + 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 Issue { + id + } + ... on PullRequest { + id + } + ... on DraftIssue { + id + title } - } - pageInfo { - hasNextPage - endCursor } } + pageInfo { + hasNextPage + endCursor + } } } - }`, - { projectId, after: endCursor } - ); - - const found = result.node.items.nodes.find(item => item.content && item.content.title === title); - if (found) return found; - - hasNextPage = result.node.items.pageInfo.hasNextPage; - endCursor = result.node.items.pageInfo.endCursor; - } - return null; - })(projectId, draftTitle); - - if (existingDraftItem) { - itemId = existingDraftItem.id; - core.info("✓ Draft issue already on board (matched by title)"); - - // Store the mapping for future references - temporaryIdMap.set(normalizedTempId, { draftItemId: itemId }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemId}`); - } else { - // Create new draft issue - 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 } + { projectId, after: endCursor } ); - itemId = result.addProjectV2DraftIssue.projectItem.id; - // Store the mapping for future references - temporaryIdMap.set(normalizedTempId, { draftItemId: itemId }); - core.info(`Created draft issue and stored temporary ID mapping: ${temporaryId} -> ${itemId}`); + const found = result.node.items.nodes.find(item => item.content && item.content.title === title); + 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("✓ Draft issue already on board"); + } 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 } + ); + itemId = result.addProjectV2DraftIssue.projectItem.id; } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; @@ -838,7 +814,6 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } core.setOutput("item-id", itemId); - core.setOutput("temporary-id", temporaryId); return; } let contentNumber = null; diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index c7bc28b7179..d90a7597b96 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -481,64 +481,12 @@ describe("updateProject", () => { await updateProject(output); - expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board (matched by title)"); + expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board"); expect(getOutput("item-id")).toBe("existing-draft-item"); // Should not call addProjectV2DraftIssue mutation expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(false); }); - it("creates draft issue with temporary_id and allows reference by ID", async () => { - const projectUrl = "https://github.com/orgs/testowner/projects/60"; - - // Create a shared temporary ID map to simulate persistence - const temporaryIdMap = new Map(); - - // First call: create with temporary_id - const createOutput = { - type: "update_project", - project: projectUrl, - content_type: "draft_issue", - draft_title: "Task 1", - temporary_id: "aw_abc123def456", - }; - - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-temp-id"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-temp-1")]); - - // Get the handler factory and call it with the message - const handler = await updateProjectHandlerFactory({}); - await handler(createOutput, temporaryIdMap); - - expect(mockCore.info).toHaveBeenCalledWith("Created draft issue and stored temporary ID mapping: aw_abc123def456 -> draft-temp-1"); - expect(getOutput("item-id")).toBe("draft-temp-1"); - - // Verify the temporary ID was stored - expect(temporaryIdMap.has("aw_abc123def456")).toBe(true); - expect(temporaryIdMap.get("aw_abc123def456")).toEqual({ draftItemId: "draft-temp-1" }); - - // Second call: reference by temporary_id only (no title needed) - const updateOutput = { - type: "update_project", - project: projectUrl, - content_type: "draft_issue", - temporary_id: "aw_abc123def456", - fields: { Status: "Done" }, - }; - - queueResponses([ - repoResponse(), - viewerResponse(), - orgProjectV2Response(projectUrl, 60, "project-temp-id"), - fieldsResponse([{ id: "field-status", name: "Status", dataType: "SINGLE_SELECT", options: [{ id: "opt-done", name: "Done" }] }]), - updateFieldValueResponse(), - ]); - - await handler(updateOutput, temporaryIdMap); - - // Check that the message was logged (may be among other info messages) - const infoCalls = mockCore.info.mock.calls.map(call => call[0]); - expect(infoCalls).toContain("✓ Draft issue found via temporary ID aw_abc123def456"); - }); - 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 }; From baead6cc97f87e137db9319dac9d8ade44f2f7f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:36:13 +0000 Subject: [PATCH 6/6] Restore temporary ID support as default mechanism for draft issues - Restore temp ID imports and mapping logic from commit eb4f4d6 - Output temporary-id for agent consumption - Temporary ID is the recommended approach (no need for exact title) - Title matching remains as fallback - All 45 update_project tests passing Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 147 +++++++++++++---------- actions/setup/js/update_project.test.cjs | 54 ++++++++- 2 files changed, 139 insertions(+), 62 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 0c0fef2ddf8..e955971c9e1 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -3,7 +3,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { loadTemporaryIdMap, resolveIssueNumber } = require("./temporary_id.cjs"); +const { loadTemporaryIdMap, resolveIssueNumber, generateTemporaryId, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); /** * Log detailed GraphQL error information @@ -611,79 +611,103 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".'); } - const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; - if (!draftTitle) { - throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.'); - } + // Get or generate temporary ID for this draft issue + const temporaryId = output.temporary_id ?? generateTemporaryId(); + const normalizedTempId = normalizeTemporaryId(temporaryId); - const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; + // Check if we're referencing an existing draft by temporary ID only + const existingMapping = temporaryIdMap.get(normalizedTempId); + const referencingById = existingMapping && existingMapping.draftItemId && !output.draft_title; - // Check if a draft issue with this title already exists on the board - const existingDraftItem = await (async function (projectId, title) { - let hasNextPage = true; - let endCursor = null; + let itemId; - 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 Issue { - id - } - ... on PullRequest { - id - } - ... on DraftIssue { - id - title + if (referencingById) { + // Referencing existing draft issue by temporary ID (no title needed) + itemId = existingMapping.draftItemId; + core.info(`✓ Draft issue found via temporary ID ${temporaryId}`); + } else { + // Creating or finding draft issue (title required) + const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; + if (!draftTitle) { + throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required unless referencing an existing draft by temporary_id.'); + } + + const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; + // Check if a draft issue with this title already exists on the board + const existingDraftItem = await (async function (projectId, title) { + 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 Issue { + id + } + ... on PullRequest { + id + } + ... on DraftIssue { + id + title + } } } - } - pageInfo { - hasNextPage - endCursor + pageInfo { + hasNextPage + endCursor + } } } } + }`, + { projectId, after: endCursor } + ); + + const found = result.node.items.nodes.find(item => item.content && item.content.title === title); + if (found) return found; + + hasNextPage = result.node.items.pageInfo.hasNextPage; + endCursor = result.node.items.pageInfo.endCursor; + } + return null; + })(projectId, draftTitle); + + if (existingDraftItem) { + itemId = existingDraftItem.id; + core.info("✓ Draft issue already on board (matched by title)"); + + // Store the mapping for future references + temporaryIdMap.set(normalizedTempId, { draftItemId: itemId }); + core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemId}`); + } else { + // Create new draft issue + const result = await github.graphql( + `mutation($projectId: ID!, $title: String!, $body: String) { + addProjectV2DraftIssue(input: { + projectId: $projectId, + title: $title, + body: $body + }) { + projectItem { + id + } } }`, - { projectId, after: endCursor } + { projectId, title: draftTitle, body: draftBody } ); + itemId = result.addProjectV2DraftIssue.projectItem.id; - const found = result.node.items.nodes.find(item => item.content && item.content.title === title); - if (found) return found; - - hasNextPage = result.node.items.pageInfo.hasNextPage; - endCursor = result.node.items.pageInfo.endCursor; + // Store the mapping for future references + temporaryIdMap.set(normalizedTempId, { draftItemId: itemId }); + core.info(`Created draft issue and stored temporary ID mapping: ${temporaryId} -> ${itemId}`); } - return null; - })(projectId, draftTitle); - - let itemId; - if (existingDraftItem) { - itemId = existingDraftItem.id; - core.info("✓ Draft issue already on board"); - } 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 } - ); - itemId = result.addProjectV2DraftIssue.projectItem.id; } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; @@ -814,6 +838,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } core.setOutput("item-id", itemId); + core.setOutput("temporary-id", temporaryId); return; } let contentNumber = null; diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index d90a7597b96..c7bc28b7179 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -481,12 +481,64 @@ describe("updateProject", () => { await updateProject(output); - expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board"); + expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board (matched by title)"); expect(getOutput("item-id")).toBe("existing-draft-item"); // Should not call addProjectV2DraftIssue mutation expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(false); }); + it("creates draft issue with temporary_id and allows reference by ID", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + + // Create a shared temporary ID map to simulate persistence + const temporaryIdMap = new Map(); + + // First call: create with temporary_id + const createOutput = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_title: "Task 1", + temporary_id: "aw_abc123def456", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-temp-id"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-temp-1")]); + + // Get the handler factory and call it with the message + const handler = await updateProjectHandlerFactory({}); + await handler(createOutput, temporaryIdMap); + + expect(mockCore.info).toHaveBeenCalledWith("Created draft issue and stored temporary ID mapping: aw_abc123def456 -> draft-temp-1"); + expect(getOutput("item-id")).toBe("draft-temp-1"); + + // Verify the temporary ID was stored + expect(temporaryIdMap.has("aw_abc123def456")).toBe(true); + expect(temporaryIdMap.get("aw_abc123def456")).toEqual({ draftItemId: "draft-temp-1" }); + + // Second call: reference by temporary_id only (no title needed) + const updateOutput = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + temporary_id: "aw_abc123def456", + fields: { Status: "Done" }, + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-temp-id"), + fieldsResponse([{ id: "field-status", name: "Status", dataType: "SINGLE_SELECT", options: [{ id: "opt-done", name: "Done" }] }]), + updateFieldValueResponse(), + ]); + + await handler(updateOutput, temporaryIdMap); + + // Check that the message was logged (may be among other info messages) + const infoCalls = mockCore.info.mock.calls.map(call => call[0]); + expect(infoCalls).toContain("✓ Draft issue found via temporary ID aw_abc123def456"); + }); + 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 };