diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index c38c820fefb..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,27 +611,104 @@ 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; - const result = await github.graphql( - `mutation($projectId: ID!, $title: String!, $body: String) { - addProjectV2DraftIssue(input: { - projectId: $projectId, - title: $title, - body: $body - }) { - projectItem { - id - } + // 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; + + 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 + } + } + } + } + }`, + { 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; } - }`, - { projectId, title: draftTitle, body: draftBody } - ); - const itemId = result.addProjectV2DraftIssue.projectItem.id; + 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 } + ); + 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 fieldsToUpdate = output.fields ? { ...output.fields } : {}; if (Object.keys(fieldsToUpdate).length > 0) { @@ -761,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 305b7366e46..c7bc28b7179 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,77 @@ 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 (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 }; @@ -583,6 +672,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 +1353,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());