diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index fd8538af7d8..42fe8b80b2d 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_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_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", "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..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,76 +660,90 @@ 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 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; - // Check for existing draft issue with the same title - const existingDraftItem = await (async function findExistingDraftByTitle(projectId, targetTitle) { - let hasNextPage = true; - let endCursor = null; + let itemId; + let resolvedTemporaryId = temporaryId; + + // Mode 1: Update existing draft via draft_issue_id + if (draftIssueId) { + // 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 findExistingDraftByTitle(github, 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 findExistingDraftByTitle(github, projectId, draftTitle); - while (hasNextPage) { + if (existingDraftItem) { + itemId = existingDraftItem.id; + core.info(`✓ Found existing draft issue "${draftTitle}" - updating fields instead of creating duplicate`); + } else { 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 - } - } + `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 } ); - - 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; + itemId = result.addProjectV2DraftIssue.projectItem.id; + core.info(`✓ Created new draft issue "${draftTitle}"`); + + // Store temporary_id mapping if provided + if (temporaryId) { + const normalized = normalizeTemporaryId(temporaryId); + temporaryIdMap.set(normalized, { draftItemId: itemId }); + core.info(`✓ Stored temporary_id mapping: ${temporaryId} -> ${itemId}`); + } } - - return null; - })(projectId, draftTitle); - - let itemId; - if (existingDraftItem) { - itemId = existingDraftItem.id; - core.info(`✓ Found existing draft issue "${draftTitle}" - updating fields instead of creating duplicate`); - } else { - const result = await github.graphql( - `mutation($projectId: ID!, $title: String!, $body: String) { - addProjectV2DraftIssue(input: { - projectId: $projectId, - title: $title, - body: $body - }) { - projectItem { - id - } - } - }`, - { projectId, title: draftTitle, body: draftBody } - ); - itemId = result.addProjectV2DraftIssue.projectItem.id; - core.info(`✓ Created new draft issue "${draftTitle}"`); } const fieldsToUpdate = output.fields ? { ...output.fields } : {}; @@ -811,6 +874,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; diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index cc3e2708add..eadb025fb1e 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_9f11121ed7df", + }; + + 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_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_9f11121ed7df -> 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_9f11121ed7df", { draftItemId: "draft-item-existing" }); + + const output = { + type: "update_project", + project: projectUrl, + content_type: "draft_issue", + draft_issue_id: "aw_9f11121ed7df", + 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_9f11121ed7df" 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_aefe5b4b9585", + 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_1a2b3c4d5e6f", + }; + + 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_27a9a9bfcc4e", + 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 };