From 7d726efff66a19aac0623a26ad11c660a21007c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:21:37 +0000 Subject: [PATCH 1/4] Initial plan From 84891bcf3a1d06e0fa9e9bec68501350e5466da0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:30:20 +0000 Subject: [PATCH 2/4] Add draft_issue_id and temporary_id support for draft items in update_project Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 13 +- actions/setup/js/update_project.cjs | 140 +++++++++++++++++++--- actions/setup/js/update_project.test.cjs | 145 +++++++++++++++++++++++ 3 files changed, 277 insertions(+), 21 deletions(-) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index fd8538af7d8..3d56997c7be 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -600,7 +600,7 @@ }, { "name": "update_project", - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title.\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", + "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title (to create) or draft_issue_id (to update existing).\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", "inputSchema": { "type": "object", "required": ["project"], @@ -626,12 +626,21 @@ }, "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 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", + "description": "Project item ID or temporary ID of an existing draft issue to update. Use this to reference a specific draft issue instead of creating a new one. Format: either a GitHub project item ID or a temporary ID (aw_XXXXXXXXXXXX). If provided with content_type='draft_issue', draft_title is optional." + }, + "temporary_id": { + "type": "string", + "pattern": "^aw_[0-9a-f]{12}$", + "description": "Optional temporary identifier for a draft issue being created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use this to reference the draft issue in subsequent update_project calls via draft_issue_id before it's converted to a real issue." + }, "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 c38c820fefb..70f8ae9b0d6 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 @@ -246,6 +246,56 @@ async function resolveProjectV2(projectInfo, projectNumberInt, github) { throw new Error(`Project #${projectNumberInt} not found or not accessible for ${who}.${total} Accessible Projects v2: ${summary}`); } + +/** + * Find an existing draft issue in the project by project item ID + * @param {string} projectId - Project ID + * @param {string} draftItemId - Draft project item ID to find + * @param {Object} github - GitHub client (Octokit instance) + * @returns {Promise<{id: string, title?: string}|null>} Draft item if found, null otherwise + */ +async function findExistingDraftByItemId(projectId, draftItemId, github) { + 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.id === draftItemId); + if (found) { + return { id: found.id, title: found.content?.title }; + } + + hasNextPage = result.node.items.pageInfo.hasNextPage; + endCursor = result.node.items.pageInfo.endCursor; + } + + return null; +} + /** * Check if a field name conflicts with unsupported GitHub built-in field types * @param {string} fieldName - Original field name @@ -611,27 +661,79 @@ 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.'); + // Check for draft_issue_id to reference an existing draft + let itemId = null; + const hasDraftIssueId = output.draft_issue_id !== undefined && output.draft_issue_id !== null; + + if (hasDraftIssueId) { + const rawDraftIssueId = output.draft_issue_id; + const sanitizedDraftIssueId = typeof rawDraftIssueId === "string" ? rawDraftIssueId.trim() : String(rawDraftIssueId); + + // Check if it's a temporary ID + if (isTemporaryId(sanitizedDraftIssueId)) { + const normalizedTempId = normalizeTemporaryId(sanitizedDraftIssueId); + const resolvedItemId = temporaryIdMap.get(normalizedTempId); + + if (resolvedItemId && typeof resolvedItemId === "string") { + // Temporary ID resolved to a project item ID + core.info(`✓ Resolved temporary ID ${sanitizedDraftIssueId} to draft item ID`); + + // Verify the item still exists in the project + const existingItem = await findExistingDraftByItemId(projectId, resolvedItemId, github); + if (existingItem) { + itemId = existingItem.id; + core.info(`✓ Found existing draft item: "${existingItem.title || "Untitled"}"`); + } else { + throw new Error(`Draft item with temporary ID ${sanitizedDraftIssueId} no longer exists in project. It may have been deleted or converted to an issue.`); + } + } else { + throw new Error(`Temporary ID '${sanitizedDraftIssueId}' not found in map. Ensure the draft issue was created in a previous step with a temporary_id field.`); + } + } else { + // Direct project item ID provided + const existingItem = await findExistingDraftByItemId(projectId, sanitizedDraftIssueId, github); + if (existingItem) { + itemId = existingItem.id; + core.info(`✓ Found existing draft item: "${existingItem.title || "Untitled"}"`); + } else { + throw new Error(`Draft item with ID "${sanitizedDraftIssueId}" not found in project. Verify the draft_issue_id is correct.`); + } + } } - 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 + // If no itemId found yet, create a new draft issue + if (!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.'); + } + + 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 + } } - } - }`, - { 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}"`); + + // If temporary_id was provided, store the mapping + if (output.temporary_id && isTemporaryId(String(output.temporary_id))) { + const normalizedTempId = normalizeTemporaryId(String(output.temporary_id)); + temporaryIdMap.set(normalizedTempId, itemId); + core.setOutput("temporary-id", normalizedTempId); + core.info(`✓ Stored temporary ID mapping: ${normalizedTempId} -> ${itemId}`); + } + } 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..9729d97a05d 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -596,6 +596,151 @@ describe("updateProject", () => { expect(getOutput("item-id")).toBe("draft-item-fields"); }); + it("creates a draft issue with temporary_id and outputs the mapping", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_title: "Draft with temp ID", + temporary_id: "aw_abc123def456", + }; + + const temporaryIdMap = new Map(); + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-temp-id"), addDraftIssueResponse("draft-item-temp-123")]); + + await updateProject(output, temporaryIdMap); + + expect(getOutput("item-id")).toBe("draft-item-temp-123"); + expect(getOutput("temporary-id")).toBe("aw_abc123def456"); + expect(temporaryIdMap.get("aw_abc123def456")).toBe("draft-item-temp-123"); + }); + + it("updates an existing draft issue by draft_issue_id using temporary ID", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "aw_abc123def456", + fields: { Status: "In Progress" }, + }; + + // Temporary ID map with existing mapping + const temporaryIdMap = new Map([["aw_abc123def456", "draft-item-existing-123"]]); + + // Mock response for finding the draft by item ID + const draftItemResponse = { + node: { + items: { + nodes: [ + { + id: "draft-item-existing-123", + content: { + id: "draft-content-123", + title: "Existing Draft", + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-update-draft"), draftItemResponse, fieldsResponse([{ id: "field-status", name: "Status" }]), updateFieldValueResponse()]); + + await updateProject(output, temporaryIdMap); + + // Verify no new draft was created (no addProjectV2DraftIssue mutation) + const addDraftCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("addProjectV2DraftIssue")); + expect(addDraftCall).toBeUndefined(); + + // Verify the field was updated + const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue")); + expect(updateCall).toBeDefined(); + expect(getOutput("item-id")).toBe("draft-item-existing-123"); + }); + + it("updates an existing draft issue by direct draft_issue_id (not temporary ID)", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "PVTI_lADOABCD1234567890", + fields: { Status: "Done" }, + }; + + // Mock response for finding the draft by item ID + const draftItemResponse = { + node: { + items: { + nodes: [ + { + id: "PVTI_lADOABCD1234567890", + content: { + id: "draft-content-456", + title: "Direct ID Draft", + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-direct-id"), draftItemResponse, fieldsResponse([{ id: "field-status", name: "Status" }]), updateFieldValueResponse()]); + + await updateProject(output); + + // Verify no new draft was created + const addDraftCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("addProjectV2DraftIssue")); + expect(addDraftCall).toBeUndefined(); + + expect(getOutput("item-id")).toBe("PVTI_lADOABCD1234567890"); + }); + + it("throws error when draft_issue_id temporary ID is not found in map", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "aw_abc123def789", // Valid format but not in map + }; + + const temporaryIdMap = new Map(); // Empty map + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-not-found")]); + + await expect(updateProject(output, temporaryIdMap)).rejects.toThrow(/Temporary ID 'aw_abc123def789' not found in map/); + }); + + it("throws error when draft_issue_id points to non-existent item", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "PVTI_nonexistent", + }; + + // Mock response with no matching items + const emptyDraftResponse = { + node: { + items: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-missing-item"), emptyDraftResponse]); + + await expect(updateProject(output)).rejects.toThrow(/Draft item with ID "PVTI_nonexistent" not found in project/); + }); + it("updates a single select field when the option exists", async () => { const projectUrl = "https://github.com/orgs/testowner/projects/60"; const output = { From 2025ddb9fdb07625e911b441f1f6dd1316249751 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:01:47 +0000 Subject: [PATCH 3/4] Require temporary_id for draft issue creation (breaking change) Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 6 ++--- actions/setup/js/update_project.cjs | 30 +++++++++++++++++------- actions/setup/js/update_project.test.cjs | 25 ++++++++++++++++++-- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 3d56997c7be..261c2df7923 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -600,7 +600,7 @@ }, { "name": "update_project", - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title (to create) or draft_issue_id (to update existing).\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", + "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title + temporary_id (to create) or draft_issue_id (to update existing).\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", "inputSchema": { "type": "object", "required": ["project"], @@ -626,7 +626,7 @@ }, "draft_title": { "type": "string", - "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue' and draft_issue_id is not provided." + "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue' and creating a new draft (along with temporary_id). Not required when updating via draft_issue_id." }, "draft_body": { "type": "string", @@ -639,7 +639,7 @@ "temporary_id": { "type": "string", "pattern": "^aw_[0-9a-f]{12}$", - "description": "Optional temporary identifier for a draft issue being created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use this to reference the draft issue in subsequent update_project calls via draft_issue_id before it's converted to a real issue." + "description": "Temporary identifier for a draft issue being created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). REQUIRED when creating a new draft issue (content_type='draft_issue' without draft_issue_id). Use this to reference the draft issue in subsequent update_project calls via draft_issue_id." }, "fields": { "type": "object", diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 70f8ae9b0d6..a2867af2442 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -703,6 +703,15 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = // If no itemId found yet, create a new draft issue if (!itemId) { + // Require temporary_id for new draft creation (similar to content_number for issues/PRs) + if (!output.temporary_id || !isTemporaryId(String(output.temporary_id))) { + throw new Error( + 'When content_type is "draft_issue" and creating a new draft, temporary_id is required. ' + + 'Provide a valid temporary ID (format: aw_XXXXXXXXXXXX, e.g., "aw_abc123def456"). ' + + "To update an existing draft, use draft_issue_id instead." + ); + } + 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.'); @@ -726,13 +735,11 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = itemId = result.addProjectV2DraftIssue.projectItem.id; core.info(`✓ Created new draft issue: "${draftTitle}"`); - // If temporary_id was provided, store the mapping - if (output.temporary_id && isTemporaryId(String(output.temporary_id))) { - const normalizedTempId = normalizeTemporaryId(String(output.temporary_id)); - temporaryIdMap.set(normalizedTempId, itemId); - core.setOutput("temporary-id", normalizedTempId); - core.info(`✓ Stored temporary ID mapping: ${normalizedTempId} -> ${itemId}`); - } + // Store the temporary_id mapping + const normalizedTempId = normalizeTemporaryId(String(output.temporary_id)); + temporaryIdMap.set(normalizedTempId, itemId); + core.setOutput("temporary-id", normalizedTempId); + core.info(`✓ Stored temporary ID mapping: ${normalizedTempId} -> ${itemId}`); } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; @@ -1144,13 +1151,18 @@ async function main(config = {}, githubClient = null) { // Provide helpful context based on content_type if (message.content_type === "draft_issue") { - core.error('For draft_issue content_type, you must include: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "...", "fields": {...}}'); + core.error( + 'For draft_issue content_type, you must include: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "...", "temporary_id": "aw_abc123def456", "fields": {...}}' + ); + core.error('Or to update an existing draft: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_issue_id": "aw_abc123def456", "fields": {...}}'); } else if (message.content_type === "issue" || message.content_type === "pull_request") { core.error( `For ${message.content_type} content_type, you must include: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "${message.content_type}", "content_number": 123, "fields": {...}}` ); } else { - core.error('Example: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "Task Title", "fields": {"Status": "Todo"}}'); + core.error( + 'Example: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "Task Title", "temporary_id": "aw_abc123def456", "fields": {"Status": "Todo"}}' + ); } return { diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 9729d97a05d..7a7d6d7df24 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -417,7 +417,7 @@ describe("updateProject", () => { expect(getOutput("item-id")).toBe("item123"); }); - it("adds a draft issue to a project board", async () => { + it("adds a draft issue to a project board with temporary_id", async () => { const projectUrl = "https://github.com/orgs/testowner/projects/60"; const output = { type: "update_project", @@ -425,6 +425,7 @@ describe("updateProject", () => { content_type: "draft_issue", draft_title: "Draft title", draft_body: "Draft body", + temporary_id: "aw_abc123def456", }; queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), addDraftIssueResponse("draft-item-1")]); @@ -434,6 +435,22 @@ describe("updateProject", () => { 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(getOutput("temporary-id")).toBe("aw_abc123def456"); + }); + + it("rejects draft issues without temporary_id", 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", + draft_body: "Draft body", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft")]); + + await expect(updateProject(output)).rejects.toThrow(/temporary_id is required/); }); it("rejects draft issues without a title", async () => { @@ -443,6 +460,7 @@ describe("updateProject", () => { project: projectUrl, content_type: "draft_issue", draft_title: " ", + temporary_id: "aw_abc123def456", }; queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft")]); @@ -569,13 +587,14 @@ describe("updateProject", () => { expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); }); - it("updates fields on a draft issue item", async () => { + it("updates fields on a draft issue item with temporary_id", 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", + temporary_id: "aw_def456abc123", fields: { Status: "In Progress" }, }; @@ -594,6 +613,7 @@ describe("updateProject", () => { expect(updateCall).toBeDefined(); expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); expect(getOutput("item-id")).toBe("draft-item-fields"); + expect(getOutput("temporary-id")).toBe("aw_def456abc123"); }); it("creates a draft issue with temporary_id and outputs the mapping", async () => { @@ -1406,6 +1426,7 @@ describe("updateProject", () => { content_type: "draft_issue", draft_title: "Test Draft Issue", draft_body: "This is a test", + temporary_id: "aw_fedcba987654", }; queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(messageProjectUrl, 60, "project-message"), addDraftIssueResponse("draft-item-message")]); From 860b4845759c98ed95eb351f971416760097c2b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:27:24 +0000 Subject: [PATCH 4/4] Refactor: optimize draft lookup, add # prefix support, use structured mapping Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 2 +- actions/setup/js/update_project.cjs | 88 ++++++++++------- actions/setup/js/update_project.test.cjs | 116 ++++++++++++++++------- 3 files changed, 137 insertions(+), 69 deletions(-) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 261c2df7923..dbc87284b6b 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -639,7 +639,7 @@ "temporary_id": { "type": "string", "pattern": "^aw_[0-9a-f]{12}$", - "description": "Temporary identifier for a draft issue being created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). REQUIRED when creating a new draft issue (content_type='draft_issue' without draft_issue_id). Use this to reference the draft issue in subsequent update_project calls via draft_issue_id." + "description": "Temporary identifier for a draft issue being created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Must be provided when creating a new draft issue (content_type='draft_issue' without draft_issue_id); this constraint is enforced by the tool at runtime, not by the static schema validator. Use this to reference the draft issue in subsequent update_project calls via draft_issue_id." }, "fields": { "type": "object", diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index a2867af2442..fedfa4bab45 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, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); /** * Log detailed GraphQL error information @@ -249,51 +249,48 @@ async function resolveProjectV2(projectInfo, projectNumberInt, github) { /** * Find an existing draft issue in the project by project item ID + * Queries the item directly for efficiency instead of paginating through all items * @param {string} projectId - Project ID * @param {string} draftItemId - Draft project item ID to find * @param {Object} github - GitHub client (Octokit instance) * @returns {Promise<{id: string, title?: string}|null>} Draft item if found, null otherwise */ async function findExistingDraftByItemId(projectId, draftItemId, github) { - let hasNextPage = true; - let endCursor = null; - - while (hasNextPage) { + try { const result = await github.graphql( - `query($projectId: ID!, $after: String) { - node(id: $projectId) { - ... on ProjectV2 { - items(first: 100, after: $after) { - nodes { + `query($draftItemId: ID!, $projectId: ID!) { + node(id: $draftItemId) { + ... on ProjectV2Item { + id + content { + ... on DraftIssue { id - content { - ... on DraftIssue { - id - title - } - } - } - pageInfo { - hasNextPage - endCursor + title } } + project { + id + } } } }`, - { projectId, after: endCursor } + { draftItemId, projectId } ); - const found = result.node.items.nodes.find(item => item.id === draftItemId); - if (found) { - return { id: found.id, title: found.content?.title }; + const node = result?.node; + if (!node || !node.content || node.project?.id !== projectId) { + return null; } - hasNextPage = result.node.items.pageInfo.hasNextPage; - endCursor = result.node.items.pageInfo.endCursor; + return { + id: node.id, + title: node.content.title, + }; + } catch (error) { + // If the item doesn't exist or isn't accessible, return null + core.debug(`Draft item ${draftItemId} not found: ${getErrorMessage(error)}`); + return null; } - - return null; } /** @@ -667,14 +664,27 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = if (hasDraftIssueId) { const rawDraftIssueId = output.draft_issue_id; - const sanitizedDraftIssueId = typeof rawDraftIssueId === "string" ? rawDraftIssueId.trim() : String(rawDraftIssueId); + let sanitizedDraftIssueId = typeof rawDraftIssueId === "string" ? rawDraftIssueId.trim() : String(rawDraftIssueId); + + // Strip optional leading # prefix (consistent with content_number and project handling) + if (sanitizedDraftIssueId.startsWith("#")) { + sanitizedDraftIssueId = sanitizedDraftIssueId.substring(1); + } // Check if it's a temporary ID if (isTemporaryId(sanitizedDraftIssueId)) { const normalizedTempId = normalizeTemporaryId(sanitizedDraftIssueId); - const resolvedItemId = temporaryIdMap.get(normalizedTempId); + const resolved = temporaryIdMap.get(normalizedTempId); + + // Handle both string (direct item ID) and structured object formats + let resolvedItemId = null; + if (typeof resolved === "string") { + resolvedItemId = resolved; + } else if (resolved && typeof resolved === "object" && resolved.draftItemId) { + resolvedItemId = resolved.draftItemId; + } - if (resolvedItemId && typeof resolvedItemId === "string") { + if (resolvedItemId) { // Temporary ID resolved to a project item ID core.info(`✓ Resolved temporary ID ${sanitizedDraftIssueId} to draft item ID`); @@ -704,7 +714,14 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = // If no itemId found yet, create a new draft issue if (!itemId) { // Require temporary_id for new draft creation (similar to content_number for issues/PRs) - if (!output.temporary_id || !isTemporaryId(String(output.temporary_id))) { + let tempIdValue = output.temporary_id; + + // Strip optional leading # prefix + if (typeof tempIdValue === "string" && tempIdValue.startsWith("#")) { + tempIdValue = tempIdValue.substring(1); + } + + if (!tempIdValue || !isTemporaryId(String(tempIdValue))) { throw new Error( 'When content_type is "draft_issue" and creating a new draft, temporary_id is required. ' + 'Provide a valid temporary ID (format: aw_XXXXXXXXXXXX, e.g., "aw_abc123def456"). ' + @@ -735,9 +752,10 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = itemId = result.addProjectV2DraftIssue.projectItem.id; core.info(`✓ Created new draft issue: "${draftTitle}"`); - // Store the temporary_id mapping - const normalizedTempId = normalizeTemporaryId(String(output.temporary_id)); - temporaryIdMap.set(normalizedTempId, itemId); + // Store the temporary_id mapping as a structured object to distinguish from issue mappings + const normalizedTempId = normalizeTemporaryId(String(tempIdValue)); + const draftMapping = { draftItemId: itemId }; + temporaryIdMap.set(normalizedTempId, draftMapping); core.setOutput("temporary-id", normalizedTempId); core.info(`✓ Stored temporary ID mapping: ${normalizedTempId} -> ${itemId}`); } diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 7a7d6d7df24..7eef7e316c4 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -634,7 +634,11 @@ describe("updateProject", () => { expect(getOutput("item-id")).toBe("draft-item-temp-123"); expect(getOutput("temporary-id")).toBe("aw_abc123def456"); - expect(temporaryIdMap.get("aw_abc123def456")).toBe("draft-item-temp-123"); + + // Verify the mapping is stored as a structured object + const mapping = temporaryIdMap.get("aw_abc123def456"); + expect(mapping).toBeDefined(); + expect(mapping.draftItemId).toBe("draft-item-temp-123"); }); it("updates an existing draft issue by draft_issue_id using temporary ID", async () => { @@ -647,23 +651,19 @@ describe("updateProject", () => { fields: { Status: "In Progress" }, }; - // Temporary ID map with existing mapping - const temporaryIdMap = new Map([["aw_abc123def456", "draft-item-existing-123"]]); + // Temporary ID map with existing mapping (structured format) + const temporaryIdMap = new Map([["aw_abc123def456", { draftItemId: "draft-item-existing-123" }]]); - // Mock response for finding the draft by item ID + // Mock response for direct node query const draftItemResponse = { node: { - items: { - nodes: [ - { - id: "draft-item-existing-123", - content: { - id: "draft-content-123", - title: "Existing Draft", - }, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, + id: "draft-item-existing-123", + content: { + id: "draft-content-123", + title: "Existing Draft", + }, + project: { + id: "project-update-draft", }, }, }; @@ -692,20 +692,16 @@ describe("updateProject", () => { fields: { Status: "Done" }, }; - // Mock response for finding the draft by item ID + // Mock response for direct node query const draftItemResponse = { node: { - items: { - nodes: [ - { - id: "PVTI_lADOABCD1234567890", - content: { - id: "draft-content-456", - title: "Direct ID Draft", - }, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, + id: "PVTI_lADOABCD1234567890", + content: { + id: "draft-content-456", + title: "Direct ID Draft", + }, + project: { + id: "project-direct-id", }, }, }; @@ -746,19 +742,73 @@ describe("updateProject", () => { draft_issue_id: "PVTI_nonexistent", }; - // Mock response with no matching items + // Mock response - node not found const emptyDraftResponse = { + node: null, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-missing-item"), emptyDraftResponse]); + + await expect(updateProject(output)).rejects.toThrow(/Draft item with ID "PVTI_nonexistent" not found in project/); + }); + + it("handles draft_issue_id with leading # prefix", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "#aw_abc123def456", + fields: { Status: "In Progress" }, + }; + + // Temporary ID map with existing mapping (structured format) + const temporaryIdMap = new Map([["aw_abc123def456", { draftItemId: "draft-item-hash-prefix" }]]); + + // Mock response for direct node query + const draftItemResponse = { node: { - items: { - nodes: [], - pageInfo: { hasNextPage: false, endCursor: null }, + id: "draft-item-hash-prefix", + content: { + id: "draft-content-hash", + title: "Draft with Hash Prefix", + }, + project: { + id: "project-hash-test", }, }, }; - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-missing-item"), emptyDraftResponse]); + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-hash-test"), draftItemResponse, fieldsResponse([{ id: "field-status", name: "Status" }]), updateFieldValueResponse()]); - await expect(updateProject(output)).rejects.toThrow(/Draft item with ID "PVTI_nonexistent" not found in project/); + await updateProject(output, temporaryIdMap); + + expect(getOutput("item-id")).toBe("draft-item-hash-prefix"); + }); + + it("handles temporary_id with leading # prefix", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_title: "Draft with hash prefix temp ID", + temporary_id: "#aw_fedcba987654", + }; + + const temporaryIdMap = new Map(); + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-hash-temp"), addDraftIssueResponse("draft-item-hash-temp")]); + + await updateProject(output, temporaryIdMap); + + expect(getOutput("item-id")).toBe("draft-item-hash-temp"); + expect(getOutput("temporary-id")).toBe("aw_fedcba987654"); + + // Verify the mapping is stored without the # prefix + const mapping = temporaryIdMap.get("aw_fedcba987654"); + expect(mapping).toBeDefined(); + expect(mapping.draftItemId).toBe("draft-item-hash-temp"); }); it("updates a single select field when the option exists", async () => {