From d2132e4d3d919c98726695cb5227fa1cfce9bf12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:00:16 +0000 Subject: [PATCH 1/4] Initial plan From 5d46ff2a789ab54289c3df30c011e212db2bb175 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:06:42 +0000 Subject: [PATCH 2/4] Add temporary_id and draft_issue_id support to update_project tool Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 12 +- actions/setup/js/update_project.cjs | 195 ++++++++++++++++------- 2 files changed, 150 insertions(+), 57 deletions(-) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index fd8538af7d8..840a5cc5cca 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -626,12 +626,22 @@ }, "draft_title": { "type": "string", - "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'." + "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue' and creating a new draft (when draft_issue_id is not provided)." }, "draft_body": { "type": "string", "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'." }, + "draft_issue_id": { + "type": "string", + "pattern": "^#?aw_[0-9a-f]{12}$", + "description": "Temporary ID of an existing draft issue to update (e.g., 'aw_smokedraft01' or '#aw_smokedraft01'). Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates." + }, + "temporary_id": { + "type": "string", + "pattern": "^aw_[0-9a-f]{12}$", + "description": "Unique temporary identifier for this draft issue (e.g., 'aw_smokedraft01'). Provide this when creating a new draft to enable future updates via draft_issue_id. Format: 'aw_' followed by 12 hex characters." + }, "fields": { "type": "object", "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project." diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index d39f6cdd7fe..f10d787df06 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -611,76 +611,156 @@ 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.'); - } + // Extract and normalize temporary_id and draft_issue_id (strip # prefix if present) + const rawTemporaryId = typeof output.temporary_id === "string" ? output.temporary_id.trim() : ""; + const temporaryId = rawTemporaryId.startsWith("#") ? rawTemporaryId.slice(1) : rawTemporaryId; + + const rawDraftIssueId = typeof output.draft_issue_id === "string" ? output.draft_issue_id.trim() : ""; + const draftIssueId = rawDraftIssueId.startsWith("#") ? rawDraftIssueId.slice(1) : rawDraftIssueId; + const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; - // 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 + let itemId; + let resolvedTemporaryId = temporaryId; + + // Mode 1: Update existing draft via draft_issue_id + if (draftIssueId) { + // Try to resolve draft_issue_id from temporaryIdMap + const resolved = temporaryIdMap.get(draftIssueId.toLowerCase()); + if (resolved && resolved.draftItemId) { + itemId = resolved.draftItemId; + core.info(`✓ Resolved draft_issue_id "${draftIssueId}" to item ${itemId}`); + } else { + // Fall back to title-based lookup if title is provided + if (draftTitle) { + 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 + } } } } - 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); + + if (existingDraftItem) { + itemId = existingDraftItem.id; + core.info(`✓ Found draft issue "${draftTitle}" by title fallback`); + } else { + throw new Error(`draft_issue_id "${draftIssueId}" not found in temporary ID map and no draft with title "${draftTitle}" found`); + } + } else { + throw new Error(`draft_issue_id "${draftIssueId}" not found in temporary ID map and no draft_title provided for fallback lookup`); + } + } + } + // Mode 2: Create new draft or find by title + else { + if (!draftTitle) { + throw new Error('Invalid draft_title. When content_type is "draft_issue" and draft_issue_id is not provided, draft_title is required and must be a non-empty string.'); + } + + // 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 } - ); + }`, + { projectId, after: endCursor } + ); - const found = result.node.items.nodes.find(item => item.content?.__typename === "DraftIssue" && item.content.title === targetTitle); - if (found) return found; + 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; - } + hasNextPage = result.node.items.pageInfo.hasNextPage; + endCursor = result.node.items.pageInfo.endCursor; + } - return null; - })(projectId, draftTitle); + 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 + 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 } - ); - itemId = result.addProjectV2DraftIssue.projectItem.id; - core.info(`✓ Created new draft issue "${draftTitle}"`); + }`, + { projectId, title: draftTitle, body: draftBody } + ); + itemId = result.addProjectV2DraftIssue.projectItem.id; + core.info(`✓ Created new draft issue "${draftTitle}"`); + + // Store temporary_id mapping if provided + if (temporaryId) { + temporaryIdMap.set(temporaryId.toLowerCase(), { draftItemId: itemId }); + core.info(`✓ Stored temporary_id mapping: ${temporaryId} -> ${itemId}`); + } + } } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; @@ -811,6 +891,9 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } core.setOutput("item-id", itemId); + if (resolvedTemporaryId) { + core.setOutput("temporary-id", resolvedTemporaryId); + } return; } let contentNumber = null; From 9ba2f3d2ec5b2e4e370fdfa80eb401e95133c021 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:08:16 +0000 Subject: [PATCH 3/4] Add comprehensive tests for temporary_id and draft_issue_id functionality Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 4 +- actions/setup/js/update_project.test.cjs | 176 +++++++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index f10d787df06..f1eaf00224b 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -614,7 +614,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = // Extract and normalize temporary_id and draft_issue_id (strip # prefix if present) const rawTemporaryId = typeof output.temporary_id === "string" ? output.temporary_id.trim() : ""; const temporaryId = rawTemporaryId.startsWith("#") ? rawTemporaryId.slice(1) : rawTemporaryId; - + const rawDraftIssueId = typeof output.draft_issue_id === "string" ? output.draft_issue_id.trim() : ""; const draftIssueId = rawDraftIssueId.startsWith("#") ? rawDraftIssueId.slice(1) : rawDraftIssueId; @@ -685,7 +685,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = throw new Error(`draft_issue_id "${draftIssueId}" not found in temporary ID map and no draft_title provided for fallback lookup`); } } - } + } // Mode 2: Create new draft or find by title else { if (!draftTitle) { diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index cc3e2708add..c16b8a3ca85 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -505,6 +505,182 @@ describe("updateProject", () => { expect(mockCore.info).toHaveBeenCalledWith('✓ Found existing draft issue "Existing Draft" - updating fields instead of creating duplicate'); }); + it("creates draft issue with temporary_id and stores mapping", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const temporaryIdMap = new Map(); + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_title: "Draft with temp ID", + draft_body: "Body content", + temporary_id: "aw_smokedraft01", + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-draft"), + emptyItemsResponse(), // No existing drafts + addDraftIssueResponse("draft-item-temp"), + ]); + + await updateProject(output, temporaryIdMap); + + expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(true); + expect(getOutput("item-id")).toBe("draft-item-temp"); + expect(getOutput("temporary-id")).toBe("aw_smokedraft01"); + expect(temporaryIdMap.get("aw_smokedraft01")).toEqual({ draftItemId: "draft-item-temp" }); + expect(mockCore.info).toHaveBeenCalledWith('✓ Created new draft issue "Draft with temp ID"'); + expect(mockCore.info).toHaveBeenCalledWith("✓ Stored temporary_id mapping: aw_smokedraft01 -> draft-item-temp"); + }); + + it("creates draft issue with temporary_id (with # prefix) and strips prefix", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const temporaryIdMap = new Map(); + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_title: "Draft with hash prefix", + temporary_id: "#aw_abc123def456", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), emptyItemsResponse(), addDraftIssueResponse("draft-item-hash")]); + + await updateProject(output, temporaryIdMap); + + expect(getOutput("temporary-id")).toBe("aw_abc123def456"); + expect(temporaryIdMap.get("aw_abc123def456")).toEqual({ draftItemId: "draft-item-hash" }); + expect(mockCore.info).toHaveBeenCalledWith("✓ Stored temporary_id mapping: aw_abc123def456 -> draft-item-hash"); + }); + + it("updates draft issue via draft_issue_id using temporary ID map", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const temporaryIdMap = new Map(); + temporaryIdMap.set("aw_smokedraft01", { draftItemId: "draft-item-existing" }); + + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "aw_smokedraft01", + fields: { Priority: "High" }, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), fieldsResponse([{ id: "field-priority", name: "Priority" }]), updateFieldValueResponse()]); + + await updateProject(output, temporaryIdMap); + + // Should NOT create new draft + expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(false); + // Should update fields on existing draft + expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("updateProjectV2ItemFieldValue"))).toBe(true); + expect(getOutput("item-id")).toBe("draft-item-existing"); + expect(mockCore.info).toHaveBeenCalledWith('✓ Resolved draft_issue_id "aw_smokedraft01" to item draft-item-existing'); + }); + + it("updates draft issue via draft_issue_id with # prefix", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const temporaryIdMap = new Map(); + temporaryIdMap.set("aw_abc123def456", { draftItemId: "draft-item-ref" }); + + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "#aw_abc123def456", + fields: { Status: "Done" }, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), fieldsResponse([{ id: "field-status", name: "Status" }]), updateFieldValueResponse()]); + + await updateProject(output, temporaryIdMap); + + expect(getOutput("item-id")).toBe("draft-item-ref"); + expect(mockCore.info).toHaveBeenCalledWith('✓ Resolved draft_issue_id "aw_abc123def456" to item draft-item-ref'); + }); + + it("falls back to title lookup when draft_issue_id not in map but title provided", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const temporaryIdMap = new Map(); // Empty map + + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "aw_notfound123", + draft_title: "Fallback Draft", + fields: { Status: "In Progress" }, + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-draft"), + existingDraftItemResponse("Fallback Draft", "draft-item-fallback"), + fieldsResponse([{ id: "field-status", name: "Status" }]), + updateFieldValueResponse(), + ]); + + await updateProject(output, temporaryIdMap); + + expect(getOutput("item-id")).toBe("draft-item-fallback"); + expect(mockCore.info).toHaveBeenCalledWith('✓ Found draft issue "Fallback Draft" by title fallback'); + }); + + it("throws error when draft_issue_id not found and no title for fallback", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const temporaryIdMap = new Map(); + + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "aw_missing1234", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft")]); + + await expect(updateProject(output, temporaryIdMap)).rejects.toThrow(/draft_issue_id.*not found.*no draft_title/); + }); + + it("throws error when draft_issue_id not in map and title not found", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const temporaryIdMap = new Map(); + + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "aw_notfound456", + draft_title: "Non-existent Draft", + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-draft"), + emptyItemsResponse(), // No drafts found + ]); + + await expect(updateProject(output, temporaryIdMap)).rejects.toThrow(/draft_issue_id.*not found.*no draft with title/); + }); + + it("rejects draft_issue without title when creating (no draft_issue_id)", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + // No draft_title, no draft_issue_id + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft")]); + + await expect(updateProject(output)).rejects.toThrow(/draft_title.*required/); + }); + 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 c009f212a596d45698a665e5549c30d598790768 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:29:38 +0000 Subject: [PATCH 4/4] Address PR review feedback: schema patterns, validation helpers, extract duplicate function, fix test IDs Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 6 +- actions/setup/js/update_project.cjs | 153 ++++++++++------------- actions/setup/js/update_project.test.cjs | 20 +-- 3 files changed, 81 insertions(+), 98 deletions(-) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 840a5cc5cca..42fe8b80b2d 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -635,12 +635,12 @@ "draft_issue_id": { "type": "string", "pattern": "^#?aw_[0-9a-f]{12}$", - "description": "Temporary ID of an existing draft issue to update (e.g., 'aw_smokedraft01' or '#aw_smokedraft01'). Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates." + "description": "Temporary ID of an existing draft issue to update (e.g., 'aw_abc123def456' or '#aw_abc123def456'). Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates." }, "temporary_id": { "type": "string", - "pattern": "^aw_[0-9a-f]{12}$", - "description": "Unique temporary identifier for this draft issue (e.g., 'aw_smokedraft01'). Provide this when creating a new draft to enable future updates via draft_issue_id. Format: 'aw_' followed by 12 hex characters." + "pattern": "^#?aw_[0-9a-f]{12}$", + "description": "Unique temporary identifier for this draft issue (e.g., 'aw_abc123def456' or '#aw_abc123def456'). Provide this when creating a new draft to enable future updates via draft_issue_id. Format: optional leading '#', then 'aw_' followed by 12 hex characters." }, "fields": { "type": "object", diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index f1eaf00224b..66658fa8695 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, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); /** * Log detailed GraphQL error information @@ -308,6 +308,55 @@ function checkFieldTypeMismatch(fieldName, field, expectedDataType) { ); return false; // Continue with existing field type } + +/** + * Find an existing draft issue by title in a project + * @param {Object} github - GitHub client (Octokit instance) + * @param {string} projectId - Project ID + * @param {string} targetTitle - Title to search for + * @returns {Promise<{id: string} | null>} Draft item or null if not found + */ +async function findExistingDraftByTitle(github, 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; +} + /** * Update a GitHub Project v2 * @param {any} output - Safe output configuration @@ -611,13 +660,23 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".'); } - // Extract and normalize temporary_id and draft_issue_id (strip # prefix if present) + // Extract and normalize temporary_id and draft_issue_id using shared helpers const rawTemporaryId = typeof output.temporary_id === "string" ? output.temporary_id.trim() : ""; const temporaryId = rawTemporaryId.startsWith("#") ? rawTemporaryId.slice(1) : rawTemporaryId; const rawDraftIssueId = typeof output.draft_issue_id === "string" ? output.draft_issue_id.trim() : ""; const draftIssueId = rawDraftIssueId.startsWith("#") ? rawDraftIssueId.slice(1) : rawDraftIssueId; + // Validate temporary_id format if provided + if (temporaryId && !isTemporaryId(temporaryId)) { + throw new Error(`Invalid temporary_id format: "${temporaryId}". Expected format: aw_ followed by 12 hex characters (e.g., "aw_abc123def456").`); + } + + // Validate draft_issue_id format if provided + if (draftIssueId && !isTemporaryId(draftIssueId)) { + throw new Error(`Invalid draft_issue_id format: "${draftIssueId}". Expected format: aw_ followed by 12 hex characters (e.g., "aw_abc123def456").`); + } + const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; @@ -626,54 +685,16 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = // Mode 1: Update existing draft via draft_issue_id if (draftIssueId) { - // Try to resolve draft_issue_id from temporaryIdMap - const resolved = temporaryIdMap.get(draftIssueId.toLowerCase()); + // Try to resolve draft_issue_id from temporaryIdMap using normalized ID + const normalized = normalizeTemporaryId(draftIssueId); + const resolved = temporaryIdMap.get(normalized); if (resolved && resolved.draftItemId) { itemId = resolved.draftItemId; core.info(`✓ Resolved draft_issue_id "${draftIssueId}" to item ${itemId}`); } else { // Fall back to title-based lookup if title is provided if (draftTitle) { - 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); + const existingDraftItem = await findExistingDraftByTitle(github, projectId, draftTitle); if (existingDraftItem) { itemId = existingDraftItem.id; @@ -693,46 +714,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } // 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); + const existingDraftItem = await findExistingDraftByTitle(github, projectId, draftTitle); if (existingDraftItem) { itemId = existingDraftItem.id; @@ -757,7 +739,8 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = // Store temporary_id mapping if provided if (temporaryId) { - temporaryIdMap.set(temporaryId.toLowerCase(), { draftItemId: itemId }); + const normalized = normalizeTemporaryId(temporaryId); + temporaryIdMap.set(normalized, { draftItemId: itemId }); core.info(`✓ Stored temporary_id mapping: ${temporaryId} -> ${itemId}`); } } diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index c16b8a3ca85..eadb025fb1e 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -514,7 +514,7 @@ describe("updateProject", () => { content_type: "draft_issue", draft_title: "Draft with temp ID", draft_body: "Body content", - temporary_id: "aw_smokedraft01", + temporary_id: "aw_9f11121ed7df", }; queueResponses([ @@ -529,10 +529,10 @@ describe("updateProject", () => { expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(true); expect(getOutput("item-id")).toBe("draft-item-temp"); - expect(getOutput("temporary-id")).toBe("aw_smokedraft01"); - expect(temporaryIdMap.get("aw_smokedraft01")).toEqual({ draftItemId: "draft-item-temp" }); + expect(getOutput("temporary-id")).toBe("aw_9f11121ed7df"); + expect(temporaryIdMap.get("aw_9f11121ed7df")).toEqual({ draftItemId: "draft-item-temp" }); expect(mockCore.info).toHaveBeenCalledWith('✓ Created new draft issue "Draft with temp ID"'); - expect(mockCore.info).toHaveBeenCalledWith("✓ Stored temporary_id mapping: aw_smokedraft01 -> draft-item-temp"); + expect(mockCore.info).toHaveBeenCalledWith("✓ Stored temporary_id mapping: aw_9f11121ed7df -> draft-item-temp"); }); it("creates draft issue with temporary_id (with # prefix) and strips prefix", async () => { @@ -558,13 +558,13 @@ describe("updateProject", () => { it("updates draft issue via draft_issue_id using temporary ID map", async () => { const projectUrl = "https://github.com/orgs/testowner/projects/60"; const temporaryIdMap = new Map(); - temporaryIdMap.set("aw_smokedraft01", { draftItemId: "draft-item-existing" }); + temporaryIdMap.set("aw_9f11121ed7df", { draftItemId: "draft-item-existing" }); const output = { type: "update_project", project: projectUrl, content_type: "draft_issue", - draft_issue_id: "aw_smokedraft01", + draft_issue_id: "aw_9f11121ed7df", fields: { Priority: "High" }, }; @@ -577,7 +577,7 @@ describe("updateProject", () => { // Should update fields on existing draft expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("updateProjectV2ItemFieldValue"))).toBe(true); expect(getOutput("item-id")).toBe("draft-item-existing"); - expect(mockCore.info).toHaveBeenCalledWith('✓ Resolved draft_issue_id "aw_smokedraft01" to item draft-item-existing'); + expect(mockCore.info).toHaveBeenCalledWith('✓ Resolved draft_issue_id "aw_9f11121ed7df" to item draft-item-existing'); }); it("updates draft issue via draft_issue_id with # prefix", async () => { @@ -609,7 +609,7 @@ describe("updateProject", () => { type: "update_project", project: projectUrl, content_type: "draft_issue", - draft_issue_id: "aw_notfound123", + draft_issue_id: "aw_aefe5b4b9585", draft_title: "Fallback Draft", fields: { Status: "In Progress" }, }; @@ -637,7 +637,7 @@ describe("updateProject", () => { type: "update_project", project: projectUrl, content_type: "draft_issue", - draft_issue_id: "aw_missing1234", + draft_issue_id: "aw_1a2b3c4d5e6f", }; queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft")]); @@ -653,7 +653,7 @@ describe("updateProject", () => { type: "update_project", project: projectUrl, content_type: "draft_issue", - draft_issue_id: "aw_notfound456", + draft_issue_id: "aw_27a9a9bfcc4e", draft_title: "Non-existent Draft", };