diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 7c6ad87d590..78e5b2a0be2 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -193,11 +193,15 @@ async function main(config = {}) { } // Defer if issue_number is a temporary ID that hasn't been resolved yet - if (message.issue_number != null && isTemporaryId(message.issue_number)) { - const normalized = normalizeTemporaryId(String(message.issue_number)); - if (!temporaryIdMap.has(normalized)) { - core.info(`Deferring assign_to_agent — temporary ID ${message.issue_number} not yet resolved`); - return { success: false, deferred: true }; + // Strip leading '#' so both 'aw_abc1' and '#aw_abc1' (canonical validator form) are handled + if (message.issue_number != null) { + const issueNumStr = String(message.issue_number).trim(); + if (isTemporaryId(issueNumStr)) { + const normalized = normalizeTemporaryId(issueNumStr); + if (!temporaryIdMap.has(normalized)) { + core.info(`Deferring assign_to_agent — temporary ID ${message.issue_number} not yet resolved`); + return { success: false, deferred: true }; + } } } diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 95de8eb3977..f531b577bbd 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -321,6 +321,82 @@ describe("assign_to_agent", () => { expect(variables.issueNumber).toBe(99); }); + it("should resolve temporary issue IDs with '#' prefix (#aw_...) using GH_AW_TEMPORARY_ID_MAP", async () => { + process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ + aw_abc123: { repo: "test-owner/test-repo", number: 99 }, + }); + + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: "#aw_abc123", + agent: "copilot", + }, + ], + errors: [], + }); + + mockGithub.graphql + .mockResolvedValueOnce({ + repository: { + suggestedActors: { + nodes: [{ login: "copilot-swe-agent", id: "MDQ6VXNlcjE=" }], + }, + }, + }) + .mockResolvedValueOnce({ + repository: { + issue: { + id: "issue-id-99", + assignees: { nodes: [] }, + }, + }, + }) + .mockResolvedValueOnce({ + addAssigneesToAssignable: { + assignable: { assignees: { nodes: [{ login: "copilot-swe-agent" }] } }, + }, + }); + + await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary issue id")); + + const secondCallArgs = mockGithub.graphql.mock.calls[1]; + expect(secondCallArgs).toBeDefined(); + const variables = secondCallArgs[1]; + expect(variables.issueNumber).toBe(99); + }); + + it("should defer when issue_number is a '#aw_' temporary ID not yet in map", async () => { + // No temporary ID map set — the ID is unresolved + delete process.env.GH_AW_TEMPORARY_ID_MAP; + + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: "#aw_abc123", + agent: "copilot", + }, + ], + errors: [], + }); + + // Call main() factory then invoke the handler directly so we can inspect the deferred result + const deferred = await eval(`(async () => { + ${assignToAgentScript}; + const _handler = await main({}); + const { loadTemporaryIdMap } = require("./temporary_id.cjs"); + const _map = loadTemporaryIdMap(); + return _handler({ type: "assign_to_agent", issue_number: "#aw_abc123", agent: "copilot" }, {}, _map); + })()`); + + expect(deferred).toMatchObject({ success: false, deferred: true }); + expect(mockGithub.graphql).not.toHaveBeenCalled(); + }); + it("should reject unsupported agents", async () => { setAgentOutput({ items: [ diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index a378f1ae0d5..245c886d8ba 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -264,12 +264,7 @@ describe("claude_harness.cjs", () => { }); it("extracts command from line with box-drawing pipe marker (│) before permission denied", () => { - const output = [ - "\u2713 Some successful step", - "\u2717 Check if go command works (shell)", - " \u2502 go version 2>&1", - " \u2514 Permission denied and could not request permission from user", - ].join("\n"); + const output = ["\u2713 Some successful step", "\u2717 Check if go command works (shell)", " \u2502 go version 2>&1", " \u2514 Permission denied and could not request permission from user"].join("\n"); expect(extractDeniedCommands(output)).toEqual(["go version 2>&1"]); }); @@ -279,26 +274,12 @@ describe("claude_harness.cjs", () => { }); it("deduplicates repeated denied commands", () => { - const output = [ - " \u2502 go version", - " Permission denied", - " \u2502 go version", - " Permission denied", - " \u2502 go version", - " Permission denied", - ].join("\n"); + const output = [" \u2502 go version", " Permission denied", " \u2502 go version", " Permission denied", " \u2502 go version", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual(["go version"]); }); it("extracts multiple distinct denied commands", () => { - const output = [ - " \u2502 go version 2>&1", - " Permission denied", - " \u2502 ls /usr/local/go/bin/go", - " Permission denied", - " \u2502 which go", - " Permission denied", - ].join("\n"); + const output = [" \u2502 go version 2>&1", " Permission denied", " \u2502 ls /usr/local/go/bin/go", " Permission denied", " \u2502 which go", " Permission denied"].join("\n"); const result = extractDeniedCommands(output); expect(result).toContain("go version 2>&1"); expect(result).toContain("ls /usr/local/go/bin/go"); @@ -312,10 +293,7 @@ describe("claude_harness.cjs", () => { it("does not capture suffix of a command containing an internal pipe", () => { // "find . -name '*.go' | sort" should not match by splitting on the internal | - const output = [ - " find . -name '*.go' | sort", - " Permission denied", - ].join("\n"); + const output = [" find . -name '*.go' | sort", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual([]); }); }); diff --git a/actions/setup/js/codex_harness.test.cjs b/actions/setup/js/codex_harness.test.cjs index 9f04e9b3734..3b484daef4a 100644 --- a/actions/setup/js/codex_harness.test.cjs +++ b/actions/setup/js/codex_harness.test.cjs @@ -145,12 +145,7 @@ describe("codex_harness.cjs", () => { }); it("extracts command from line with box-drawing pipe marker (│) before permission denied", () => { - const output = [ - "\u2713 Some successful step", - "\u2717 Check if go command works (shell)", - " \u2502 go version 2>&1", - " \u2514 Permission denied and could not request permission from user", - ].join("\n"); + const output = ["\u2713 Some successful step", "\u2717 Check if go command works (shell)", " \u2502 go version 2>&1", " \u2514 Permission denied and could not request permission from user"].join("\n"); expect(extractDeniedCommands(output)).toEqual(["go version 2>&1"]); }); @@ -160,27 +155,13 @@ describe("codex_harness.cjs", () => { }); it("deduplicates repeated denied commands", () => { - const output = [ - " \u2502 go version", - " Permission denied", - " \u2502 go version", - " Permission denied", - " \u2502 go version", - " Permission denied", - ].join("\n"); + const output = [" \u2502 go version", " Permission denied", " \u2502 go version", " Permission denied", " \u2502 go version", " Permission denied"].join("\n"); const result = extractDeniedCommands(output); expect(result).toEqual(["go version"]); }); it("extracts multiple distinct denied commands", () => { - const output = [ - " \u2502 go version 2>&1", - " Permission denied", - " \u2502 ls /usr/local/go/bin/go", - " Permission denied", - " \u2502 which go", - " Permission denied", - ].join("\n"); + const output = [" \u2502 go version 2>&1", " Permission denied", " \u2502 ls /usr/local/go/bin/go", " Permission denied", " \u2502 which go", " Permission denied"].join("\n"); const result = extractDeniedCommands(output); expect(result).toContain("go version 2>&1"); expect(result).toContain("ls /usr/local/go/bin/go"); @@ -193,32 +174,18 @@ describe("codex_harness.cjs", () => { }); it("looks back up to 3 lines for command context", () => { - const output = [ - " \u2502 make test", - "Running...", - "Still running...", - " Permission denied", - ].join("\n"); + const output = [" \u2502 make test", "Running...", "Still running...", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual(["make test"]); }); it("does not look back more than 3 lines", () => { - const output = [ - " \u2502 make test", - "line2", - "line3", - "line4", - " Permission denied", - ].join("\n"); + const output = [" \u2502 make test", "line2", "line3", "line4", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual([]); }); it("does not capture suffix of a command containing an internal pipe", () => { // "find . -name '*.go' | sort" should not match by splitting on the internal | - const output = [ - " find . -name '*.go' | sort", - " Permission denied", - ].join("\n"); + const output = [" find . -name '*.go' | sort", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual([]); }); }); diff --git a/actions/setup/js/collect_ndjson_output.test.cjs b/actions/setup/js/collect_ndjson_output.test.cjs index faa04cb2787..c1f58d921e3 100644 --- a/actions/setup/js/collect_ndjson_output.test.cjs +++ b/actions/setup/js/collect_ndjson_output.test.cjs @@ -1548,7 +1548,7 @@ describe("collect_ndjson_output.cjs", () => { outputCall = setOutputCalls.find(call => "output" === call[0]); expect(outputCall).toBeDefined(); const parsedOutput = JSON.parse(outputCall[1]); - (expect(parsedOutput.items).toHaveLength(1), expect(parsedOutput.items[0].issue_number).toBe("aw_abc123"), expect(parsedOutput.errors).toHaveLength(0)); + (expect(parsedOutput.items).toHaveLength(1), expect(parsedOutput.items[0].issue_number).toBe("#aw_abc123"), expect(parsedOutput.errors).toHaveLength(0)); }), it("should validate assign_to_agent with optional fields", async () => { const testFile = "/tmp/gh-aw/test-ndjson-output.txt", diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 083338ba8a2..155fa1697b7 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -246,12 +246,7 @@ describe("copilot_harness.cjs", () => { }); it("extracts command from line with box-drawing pipe marker (│) before permission denied", () => { - const output = [ - "\u2713 Some successful step", - "\u2717 Check if go command works (shell)", - " \u2502 go version 2>&1", - " \u2514 Permission denied and could not request permission from user", - ].join("\n"); + const output = ["\u2713 Some successful step", "\u2717 Check if go command works (shell)", " \u2502 go version 2>&1", " \u2514 Permission denied and could not request permission from user"].join("\n"); expect(extractDeniedCommands(output)).toEqual(["go version 2>&1"]); }); @@ -261,27 +256,13 @@ describe("copilot_harness.cjs", () => { }); it("deduplicates repeated denied commands", () => { - const output = [ - " \u2502 go version", - " Permission denied", - " \u2502 go version", - " Permission denied", - " \u2502 go version", - " Permission denied", - ].join("\n"); + const output = [" \u2502 go version", " Permission denied", " \u2502 go version", " Permission denied", " \u2502 go version", " Permission denied"].join("\n"); const result = extractDeniedCommands(output); expect(result).toEqual(["go version"]); }); it("extracts multiple distinct denied commands", () => { - const output = [ - " \u2502 go version 2>&1", - " Permission denied", - " \u2502 ls /usr/local/go/bin/go", - " Permission denied", - " \u2502 which go", - " Permission denied", - ].join("\n"); + const output = [" \u2502 go version 2>&1", " Permission denied", " \u2502 ls /usr/local/go/bin/go", " Permission denied", " \u2502 which go", " Permission denied"].join("\n"); const result = extractDeniedCommands(output); expect(result).toContain("go version 2>&1"); expect(result).toContain("ls /usr/local/go/bin/go"); @@ -294,32 +275,18 @@ describe("copilot_harness.cjs", () => { }); it("looks back up to 3 lines for command context", () => { - const output = [ - " \u2502 make test", - "Running...", - "Still running...", - " Permission denied", - ].join("\n"); + const output = [" \u2502 make test", "Running...", "Still running...", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual(["make test"]); }); it("does not look back more than 3 lines", () => { - const output = [ - " \u2502 make test", - "line2", - "line3", - "line4", - " Permission denied", - ].join("\n"); + const output = [" \u2502 make test", "line2", "line3", "line4", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual([]); }); it("does not capture suffix of a command containing an internal pipe", () => { // "find . -name '*.go' | sort" should not match by splitting on the internal | - const output = [ - " find . -name '*.go' | sort", - " Permission denied", - ].join("\n"); + const output = [" find . -name '*.go' | sort", " Permission denied"].join("\n"); expect(extractDeniedCommands(output)).toEqual([]); }); }); diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 37fe517ee20..6da5c11f4cc 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -677,13 +677,11 @@ async function main(config = {}) { let effectiveParentIssueNumber; let effectiveParentRepo = qualifiedItemRepo; // Default to same repo if (message.parent !== undefined) { - // Strip # prefix if present to allow flexible temporary ID format const parentStr = String(message.parent).trim(); - const parentWithoutHash = parentStr.startsWith("#") ? parentStr.substring(1) : parentStr; - if (isTemporaryId(parentWithoutHash)) { + if (isTemporaryId(parentStr)) { // It's a temporary ID, look it up in the map - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(parentWithoutHash)); + const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(parentStr)); if (resolvedParent) { effectiveParentIssueNumber = resolvedParent.number; effectiveParentRepo = resolvedParent.repo; @@ -693,11 +691,12 @@ async function main(config = {}) { } } else { // Check if it looks like a malformed temporary ID - if (parentWithoutHash.startsWith("aw_")) { - core.warning(`Invalid temporary ID format for parent: '${message.parent}'. Temporary IDs must be in format 'aw_' followed by 3 to 12 alphanumeric characters (A-Za-z0-9). Example: 'aw_abc' or 'aw_Test123'`); + const withoutHash = parentStr.startsWith("#") ? parentStr.substring(1) : parentStr; + if (withoutHash.startsWith("aw_")) { + core.warning(`Invalid temporary ID format for parent: '${message.parent}'. Temporary IDs must be in format 'aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_). Example: 'aw_abc' or 'aw_pr_fix'`); } else { // It's a real issue number - const parsed = parseInt(parentWithoutHash, 10); + const parsed = parseInt(withoutHash, 10); if (!isNaN(parsed)) { effectiveParentIssueNumber = parsed; } else { diff --git a/actions/setup/js/create_project.cjs b/actions/setup/js/create_project.cjs index ced7fe6b6f8..a3a7e1448ea 100644 --- a/actions/setup/js/create_project.cjs +++ b/actions/setup/js/create_project.cjs @@ -335,18 +335,17 @@ async function main(config = {}, githubClient = null) { // Resolve temporary ID in item_url if present if (item_url && typeof item_url === "string") { // Check if item_url contains a temporary ID (either as URL or plain ID) - // Format: https://github.com/owner/repo/issues/#aw_XXXXXXXXXXXX or #aw_XXXXXXXXXXXX - const urlMatch = item_url.match(/issues\/(#?aw_[0-9a-f]{12})\s*$/i); - const plainMatch = item_url.match(/^(#?aw_[0-9a-f]{12})\s*$/i); + // Format: https://github.com/owner/repo/issues/#aw_XXX or #aw_XXX + const urlMatch = item_url.match(/issues\/(#?aw_[A-Za-z0-9_]{3,12})\s*$/i); + const plainMatch = item_url.match(/^(#?aw_[A-Za-z0-9_]{3,12})\s*$/i); if (urlMatch || plainMatch) { const tempIdStr = (urlMatch && urlMatch[1]) || (plainMatch && plainMatch[1]) || ""; - const tempIdWithoutHash = tempIdStr.startsWith("#") ? tempIdStr.substring(1) : tempIdStr; // Check if it's a valid temporary ID - if (isTemporaryId(tempIdWithoutHash)) { + if (isTemporaryId(tempIdStr)) { // Look up in the unified temporaryIdMap (Map) or resolvedTemporaryIds (plain object) - const normalizedKey = normalizeTemporaryId(tempIdWithoutHash); + const normalizedKey = normalizeTemporaryId(tempIdStr); const resolved = temporaryIdMap instanceof Map ? temporaryIdMap.get(normalizedKey) : resolvedTemporaryIds[normalizedKey]; if (resolved && resolved.repo && resolved.number) { diff --git a/actions/setup/js/create_project.test.cjs b/actions/setup/js/create_project.test.cjs new file mode 100644 index 00000000000..a89f497c6af --- /dev/null +++ b/actions/setup/js/create_project.test.cjs @@ -0,0 +1,460 @@ +// @ts-check +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; + +let main; + +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setOutput: vi.fn(), + debug: vi.fn(), +}; + +const mockGithub = { + graphql: vi.fn(), +}; + +const mockContext = { + repo: { + owner: "test-owner", + repo: "test-repo", + }, + payload: {}, +}; + +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +/** Standard mock responses for a successful project creation */ +const ORG_OWNER_RESPONSE = { organization: { id: "ORG_abc123" } }; +const CREATED_PROJECT_RESPONSE = { + createProjectV2: { + projectV2: { + id: "PVT_proj1", + number: 1, + title: "Test Project", + url: "https://github.com/orgs/test-org/projects/1", + }, + }, +}; + +/** + * Helper: create a handler with a mock github client wired to the module + */ +async function makeHandler(config = {}) { + return main({ target_owner: "test-org", max: 5, ...config }, mockGithub); +} + +beforeAll(async () => { + const mod = await import("./create_project.cjs"); + main = mod.main; +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockContext.payload = {}; +}); + +// ─── temporary_id field ─────────────────────────────────────────────────────── + +describe("create_project temporary_id field", () => { + it("uses declared temporary_id (bare aw_xxx) and returns it in result", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "aw_proj1" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.temporaryId).toBe("aw_proj1"); + expect(temporaryIdMap.get("aw_proj1")).toEqual({ projectUrl: "https://github.com/orgs/test-org/projects/1" }); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Stored temporary ID mapping: aw_proj1")); + }); + + it("normalises declared temporary_id with '#' prefix to bare form", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "#aw_proj1" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.temporaryId).toBe("aw_proj1"); + expect(temporaryIdMap.has("aw_proj1")).toBe(true); + expect(temporaryIdMap.has("#aw_proj1")).toBe(false); + }); + + it("normalises declared temporary_id to lowercase", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "aw_MYPROJ" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.temporaryId).toBe("aw_myproj"); + expect(temporaryIdMap.has("aw_myproj")).toBe(true); + }); + + it("normalises '#aw_' prefix and uppercase together", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "#aw_MyProj1" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.temporaryId).toBe("aw_myproj1"); + expect(temporaryIdMap.has("aw_myproj1")).toBe(true); + }); + + it("auto-generates temporary_id when omitted", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.temporaryId).toMatch(/^aw_[A-Za-z0-9]{8}$/); + expect(temporaryIdMap.size).toBe(1); + }); + + it("auto-generates temporary_id and warns when format is invalid", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "bad-format" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.temporaryId).toMatch(/^aw_[A-Za-z0-9]{8}$/); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Invalid temporary_id format")); + }); + + it("supports underscore-containing temporary_id (e.g. aw_pr_fix)", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "aw_pr_fix" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.temporaryId).toBe("aw_pr_fix"); + expect(temporaryIdMap.has("aw_pr_fix")).toBe(true); + }); +}); + +// ─── item_url temporary ID resolution ──────────────────────────────────────── + +const ISSUE_NODE_RESPONSE = { repository: { issue: { id: "ISSUE_node123" } } }; +const ADD_ITEM_RESPONSE = { addProjectV2ItemById: { item: { id: "PVTI_item1" } } }; + +/** Mock sequence for: getOwnerId + createProject + getIssueNodeId + addItemToProject */ +function mockSuccessWithItem() { + mockGithub.graphql + .mockResolvedValueOnce(ORG_OWNER_RESPONSE) // getOwnerId + .mockResolvedValueOnce(CREATED_PROJECT_RESPONSE) // createProjectV2 + .mockResolvedValueOnce(ISSUE_NODE_RESPONSE) // getIssueNodeId + .mockResolvedValueOnce(ADD_ITEM_RESPONSE); // addItemToProject +} + +describe("create_project item_url temporary ID resolution", () => { + it("resolves plain temporary ID in item_url (aw_xxx form)", async () => { + mockSuccessWithItem(); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + temporaryIdMap.set("aw_issue1", { repo: "test-owner/test-repo", number: 42 }); + + const result = await handler( + { + title: "My Project", + item_url: "aw_issue1", + temporary_id: "aw_proj1", + }, + Object.fromEntries(temporaryIdMap), + temporaryIdMap + ); + + expect(result.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary ID aw_issue1 in item_url")); + }); + + it("resolves temporary ID with '#' prefix in item_url (#aw_xxx form)", async () => { + mockSuccessWithItem(); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + temporaryIdMap.set("aw_issue1", { repo: "test-owner/test-repo", number: 42 }); + + const result = await handler( + { + title: "My Project", + item_url: "#aw_issue1", + temporary_id: "aw_proj1", + }, + Object.fromEntries(temporaryIdMap), + temporaryIdMap + ); + + expect(result.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary ID #aw_issue1 in item_url")); + }); + + it("resolves temporary ID embedded in full GitHub URL (aw_xxx in URL path)", async () => { + mockSuccessWithItem(); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + temporaryIdMap.set("aw_issue1", { repo: "test-owner/test-repo", number: 42 }); + + const result = await handler( + { + title: "My Project", + item_url: "https://github.com/test-owner/test-repo/issues/aw_issue1", + temporary_id: "aw_proj1", + }, + Object.fromEntries(temporaryIdMap), + temporaryIdMap + ); + + expect(result.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary ID aw_issue1 in item_url")); + }); + + it("resolves '#aw_xxx' embedded in full GitHub URL path", async () => { + mockSuccessWithItem(); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + temporaryIdMap.set("aw_issue1", { repo: "test-owner/test-repo", number: 42 }); + + const result = await handler( + { + title: "My Project", + item_url: "https://github.com/test-owner/test-repo/issues/#aw_issue1", + temporary_id: "aw_proj1", + }, + Object.fromEntries(temporaryIdMap), + temporaryIdMap + ); + + expect(result.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary ID #aw_issue1 in item_url")); + }); + + it("resolves via resolvedTemporaryIds (plain object) when temporaryIdMap is null", async () => { + mockSuccessWithItem(); + + const handler = await makeHandler(); + const resolvedTemporaryIds = { aw_issue1: { repo: "test-owner/test-repo", number: 42 } }; + + const result = await handler( + { + title: "My Project", + item_url: "aw_issue1", + temporary_id: "aw_proj1", + }, + resolvedTemporaryIds, + null + ); + + expect(result.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary ID aw_issue1 in item_url")); + }); + + it("fails with error when item_url temporary ID is not in temporaryIdMap", async () => { + // No graphql calls expected — fails before API calls + const handler = await makeHandler(); + const temporaryIdMap = new Map(); // empty — ID not yet resolved + + const result = await handler( + { + title: "My Project", + item_url: "aw_missing", + temporary_id: "aw_proj1", + }, + {}, + temporaryIdMap + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("aw_missing"); + expect(result.error).toContain("item_url not found"); + expect(mockGithub.graphql).not.toHaveBeenCalled(); + }); + + it("passes through a real item_url without treating it as a temporary ID", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE).mockResolvedValueOnce(ISSUE_NODE_RESPONSE).mockResolvedValueOnce(ADD_ITEM_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + const result = await handler( + { + title: "My Project", + item_url: "https://github.com/test-owner/test-repo/issues/42", + temporary_id: "aw_proj1", + }, + {}, + temporaryIdMap + ); + + expect(result.success).toBe(true); + // Should not log a "Resolved temporary ID" message for a real URL + expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Resolved temporary ID")); + }); +}); + +// ─── temporaryIdMap storage ─────────────────────────────────────────────────── + +describe("create_project temporaryIdMap storage", () => { + it("stores project URL in temporaryIdMap under the normalised key", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + await handler({ title: "My Project", temporary_id: "aw_proj1" }, {}, temporaryIdMap); + + expect(temporaryIdMap.get("aw_proj1")).toEqual({ + projectUrl: "https://github.com/orgs/test-org/projects/1", + }); + }); + + it("stores project URL under lowercase key when temporary_id is uppercase", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + await handler({ title: "My Project", temporary_id: "aw_PROJ1" }, {}, temporaryIdMap); + + expect(temporaryIdMap.has("aw_proj1")).toBe(true); + expect(temporaryIdMap.has("aw_PROJ1")).toBe(false); + }); + + it("stores project URL under lowercase key when temporary_id has '#' prefix", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + const temporaryIdMap = new Map(); + + await handler({ title: "My Project", temporary_id: "#aw_proj1" }, {}, temporaryIdMap); + + expect(temporaryIdMap.has("aw_proj1")).toBe(true); + expect(temporaryIdMap.has("#aw_proj1")).toBe(false); + }); + + it("does not store in map when temporaryIdMap is null", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler(); + + // Pass null as temporaryIdMap (backward compat) — should not throw + const result = await handler({ title: "My Project", temporary_id: "aw_proj1" }, {}, null); + + expect(result.success).toBe(true); + expect(result.temporaryId).toBe("aw_proj1"); + }); +}); + +// ─── staged mode ───────────────────────────────────────────────────────────── + +describe("create_project staged mode", () => { + it("returns previewInfo with temporaryId without calling API", async () => { + const handler = await makeHandler({ staged: true }); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "aw_proj1" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.staged).toBe(true); + expect(result.previewInfo.temporaryId).toBe("aw_proj1"); + expect(mockGithub.graphql).not.toHaveBeenCalled(); + }); + + it("normalises '#' prefix in temporary_id during staged mode", async () => { + const handler = await makeHandler({ staged: true }); + const temporaryIdMap = new Map(); + + const result = await handler({ title: "My Project", temporary_id: "#aw_proj1" }, {}, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.staged).toBe(true); + expect(result.previewInfo.temporaryId).toBe("aw_proj1"); + }); +}); + +// ─── max count ──────────────────────────────────────────────────────────────── + +describe("create_project max count", () => { + it("succeeds on first call and rejects on second when max=1", async () => { + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce(CREATED_PROJECT_RESPONSE); + + const handler = await makeHandler({ max: 1 }); + const temporaryIdMap = new Map(); + + const first = await handler({ title: "Project A", temporary_id: "aw_proja" }, {}, temporaryIdMap); + expect(first.success).toBe(true); + + const second = await handler({ title: "Project B", temporary_id: "aw_projb" }, {}, temporaryIdMap); + expect(second.success).toBe(false); + expect(second.error).toContain("Max count"); + }); +}); + +// ─── title auto-generation ──────────────────────────────────────────────────── + +describe("create_project title auto-generation", () => { + it("auto-generates title from issue context when title is missing", async () => { + mockContext.payload = { issue: { number: 7, title: "Fix the bug" } }; + mockGithub.graphql.mockResolvedValueOnce(ORG_OWNER_RESPONSE).mockResolvedValueOnce({ + createProjectV2: { + projectV2: { + id: "PVT_auto", + number: 2, + title: "Project: Fix the bug", + url: "https://github.com/orgs/test-org/projects/2", + }, + }, + }); + + const handler = await makeHandler({ title_prefix: "Project" }); + + const result = await handler({ temporary_id: "aw_proj1" }, {}, new Map()); + + expect(result.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Generated title from issue")); + }); + + it("fails when title is missing and context has no issue", async () => { + mockContext.payload = {}; + const handler = await makeHandler(); + + const result = await handler({ temporary_id: "aw_proj1" }, {}, new Map()); + + expect(result.success).toBe(false); + expect(result.error).toContain("Missing required field 'title'"); + }); + + it("fails when target_owner is missing", async () => { + // Create handler without target_owner + const handler = await main({ max: 1 }, mockGithub); + + const result = await handler({ title: "My Project", temporary_id: "aw_proj1" }, {}, new Map()); + + expect(result.success).toBe(false); + expect(result.error).toContain("No owner specified"); + }); +}); diff --git a/actions/setup/js/create_project_status_update.cjs b/actions/setup/js/create_project_status_update.cjs index 2debe3a5b76..18767cf47f6 100644 --- a/actions/setup/js/create_project_status_update.cjs +++ b/actions/setup/js/create_project_status_update.cjs @@ -316,9 +316,8 @@ async function main(config = {}, githubClient = null) { // Resolve temporary project ID if present const projectStr = effectiveProjectUrl.trim(); - const projectWithoutHash = projectStr.startsWith("#") ? projectStr.substring(1) : projectStr; - if (isTemporaryId(projectWithoutHash)) { - const normalizedId = normalizeTemporaryId(projectWithoutHash); + if (isTemporaryId(projectStr)) { + const normalizedId = normalizeTemporaryId(projectStr); const resolved = temporaryIdMap instanceof Map ? temporaryIdMap.get(normalizedId) : resolvedTemporaryIds[normalizedId]; if (resolved && typeof resolved === "object" && "projectUrl" in resolved && resolved.projectUrl) { core.info(`Resolved temporary project ID ${projectStr} to ${resolved.projectUrl}`); diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index 2a60cd5c298..f91addc865c 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -706,8 +706,7 @@ function buildMissingToolContext(items) { function buildPermissionDeniedContext(items, workflowId) { const missingToolMessages = loadMissingToolMessages(items); - const isPermissionDeniedItem = m => - m.tool === "tool/permission" && Array.isArray(m.denied_commands) && m.denied_commands.length > 0; + const isPermissionDeniedItem = m => m.tool === "tool/permission" && Array.isArray(m.denied_commands) && m.denied_commands.length > 0; const permissionItems = missingToolMessages.filter(isPermissionDeniedItem); if (permissionItems.length === 0) { @@ -1811,7 +1810,6 @@ async function main() { // Build missing_tool context (only when report-as-failure is enabled for this signal type) const missingToolContext = missingToolReportAsFailure ? buildMissingToolContext(agentOutputResult.items) : ""; - // Build permission denied context (denied commands list + fix prompt) const permissionDeniedContext = buildPermissionDeniedContext(agentOutputResult.items, workflowID); // Build report_incomplete context diff --git a/actions/setup/js/handle_agent_failure.test.cjs b/actions/setup/js/handle_agent_failure.test.cjs index 26ecfccea4d..b5bd6fd2522 100644 --- a/actions/setup/js/handle_agent_failure.test.cjs +++ b/actions/setup/js/handle_agent_failure.test.cjs @@ -1337,10 +1337,7 @@ describe("handle_agent_failure", () => { }); it("returns empty string when there are no tool/permission items", () => { - fs.writeFileSync( - path.join(tmpDir, "agent_output.json"), - JSON.stringify({ items: [{ type: "noop", reason: "done" }] }) - ); + fs.writeFileSync(path.join(tmpDir, "agent_output.json"), JSON.stringify({ items: [{ type: "noop", reason: "done" }] })); expect(buildPermissionDeniedContext()).toBe(""); }); @@ -1348,9 +1345,7 @@ describe("handle_agent_failure", () => { fs.writeFileSync( path.join(tmpDir, "agent_output.json"), JSON.stringify({ - items: [ - { type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: [] }, - ], + items: [{ type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: [] }], }) ); expect(buildPermissionDeniedContext()).toBe(""); @@ -1358,9 +1353,7 @@ describe("handle_agent_failure", () => { it("returns inline fallback when template is not available (RUNNER_TEMP not set)", () => { delete process.env.RUNNER_TEMP; - const items = [ - { type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: ["go version 2>&1"] }, - ]; + const items = [{ type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: ["go version 2>&1"] }]; const result = buildPermissionDeniedContext(items); expect(result).toContain("go version 2>&1"); expect(result).toContain("Repeated Permission Denied"); @@ -1368,9 +1361,7 @@ describe("handle_agent_failure", () => { it("renders fallback with denied commands listed", () => { delete process.env.RUNNER_TEMP; - const items = [ - { type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: ["go version 2>&1", "ls /usr/local/go/bin/go"] }, - ]; + const items = [{ type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: ["go version 2>&1", "ls /usr/local/go/bin/go"] }]; const result = buildPermissionDeniedContext(items); expect(result).toContain("`go version 2>&1`"); expect(result).toContain("`ls /usr/local/go/bin/go`"); @@ -1393,13 +1384,8 @@ describe("handle_agent_failure", () => { it("renders template when permission_denied_context.md is available", () => { const promptsDir = path.join(tmpDir, "gh-aw", "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); - fs.copyFileSync( - path.join(__dirname, "../md/permission_denied_context.md"), - path.join(promptsDir, "permission_denied_context.md") - ); - const items = [ - { type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: ["go version 2>&1"] }, - ]; + fs.copyFileSync(path.join(__dirname, "../md/permission_denied_context.md"), path.join(promptsDir, "permission_denied_context.md")); + const items = [{ type: "missing_tool", tool: "tool/permission", reason: "permission denied", denied_commands: ["go version 2>&1"] }]; const result = buildPermissionDeniedContext(items, "my-workflow"); expect(result).toContain("go version 2>&1"); expect(result).toContain("my-workflow"); diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs index cbb24fdf463..30c144ef85e 100644 --- a/actions/setup/js/safe_output_type_validator.cjs +++ b/actions/setup/js/safe_output_type_validator.cjs @@ -10,7 +10,7 @@ */ const { sanitizeContent } = require("./sanitize_content.cjs"); -const { isTemporaryId } = require("./temporary_id.cjs"); +const { isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { unfenceMarkdown } = require("./markdown_unfencing.cjs"); @@ -223,9 +223,10 @@ function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { error: `Line ${lineNum}: ${fieldName} must be a number or string`, }; } - // Check if it's a temporary ID + // Check if it's a temporary ID. Both 'aw_abc1' and '#aw_abc1' are accepted; + // isTemporaryId handles both forms, and normalizeTemporaryId strips '#' for map keys. if (isTemporaryId(value)) { - return { isValid: true, normalizedValue: String(value).toLowerCase(), isTemporary: true }; + return { isValid: true, normalizedValue: `#${normalizeTemporaryId(String(value))}`, isTemporary: true }; } // Try to parse as positive integer const parsed = typeof value === "string" ? parseInt(value, 10) : value; diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 0f6fbdbfd08..f9dcb7409aa 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -306,14 +306,34 @@ describe("safe_output_type_validator", () => { expect(result.isTemporary).toBe(false); }); - it("should accept temporary ID", async () => { + it("should accept temporary ID and normalize to # prefix form", async () => { const { validateIssueNumberOrTemporaryId } = await import("./safe_output_type_validator.cjs"); const result = validateIssueNumberOrTemporaryId("aw_abc123", "issue_number", 1); expect(result.isValid).toBe(true); expect(result.isTemporary).toBe(true); - expect(result.normalizedValue).toBe("aw_abc123"); + expect(result.normalizedValue).toBe("#aw_abc123"); + }); + + it("should accept temporary ID with leading '#' and normalize to # prefix form", async () => { + const { validateIssueNumberOrTemporaryId } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueNumberOrTemporaryId("#aw_abc123", "issue_number", 1); + + expect(result.isValid).toBe(true); + expect(result.isTemporary).toBe(true); + expect(result.normalizedValue).toBe("#aw_abc123"); + }); + + it("should accept temporary ID with underscore in the suffix", async () => { + const { validateIssueNumberOrTemporaryId } = await import("./safe_output_type_validator.cjs"); + + const result = validateIssueNumberOrTemporaryId("aw_pr_fix", "item_number", 1); + + expect(result.isValid).toBe(true); + expect(result.isTemporary).toBe(true); + expect(result.normalizedValue).toBe("#aw_pr_fix"); }); it("should reject invalid values", async () => { diff --git a/actions/setup/js/safe_outputs_graph_temporary_id.integration.test.cjs b/actions/setup/js/safe_outputs_graph_temporary_id.integration.test.cjs new file mode 100644 index 00000000000..50dbb531340 --- /dev/null +++ b/actions/setup/js/safe_outputs_graph_temporary_id.integration.test.cjs @@ -0,0 +1,332 @@ +// @ts-check +/** + * Integration tests for graph-based safe-output handlers with temporary IDs. + * + * These tests verify the end-to-end cross-handler flows where a temporary ID + * produced by one handler is consumed by a subsequent handler via the shared + * `temporaryIdMap`. The covered chains are: + * + * create_project → create_project_status_update + * create_project → update_project (project field) + * issue (pre-seeded map entry) → create_project (item_url) → update_project (content_number) + */ + +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; + +// ─── shared mocks ───────────────────────────────────────────────────────────── + +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + notice: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + exportVariable: vi.fn(), + getInput: vi.fn(), + summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue(undefined) }, +}; + +const mockGithub = { + graphql: vi.fn(), + rest: { issues: { addLabels: vi.fn().mockResolvedValue({}) } }, + request: vi.fn(), +}; + +const mockContext = { + runId: 1, + repo: { owner: "test-org", repo: "test-repo" }, + payload: { repository: { html_url: "https://github.com/test-org/test-repo" } }, +}; + +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +// ─── handler modules ────────────────────────────────────────────────────────── + +let createProjectMain; +let createProjectStatusUpdateMain; +let updateProjectMain; + +beforeAll(async () => { + const cpMod = await import("./create_project.cjs"); + createProjectMain = cpMod.main; + + const cpsudMod = await import("./create_project_status_update.cjs"); + createProjectStatusUpdateMain = cpsudMod.main; + + const upMod = await import("./update_project.cjs"); + updateProjectMain = (upMod.default || upMod).main; +}); + +beforeEach(() => { + // resetAllMocks clears the mockResolvedValueOnce queue in addition to call history, + // preventing leftover queued responses from leaking between tests. + vi.resetAllMocks(); +}); + +// ─── GraphQL mock helpers ────────────────────────────────────────────────────── + +const ORG_OWNER_RESPONSE = { organization: { id: "ORG_abc123" } }; + +function createProjectResponse(projectUrl, number = 1) { + return { + createProjectV2: { + projectV2: { + id: "PVT_proj1", + number, + title: "Test Project", + url: projectUrl, + }, + }, + }; +} + +function orgProjectV2Response(url, number = 1, id = "PVT_proj1") { + return { + organization: { + projectV2: { + id, + number, + title: "Test Project", + url, + owner: { __typename: "Organization", login: "test-org" }, + }, + }, + }; +} + +function statusUpdateResponse(id = "PVTSU_1") { + return { + createProjectV2StatusUpdate: { + statusUpdate: { + id, + body: "Integration test status", + bodyHTML: "
Integration test status
", + startDate: null, + targetDate: null, + status: "ON_TRACK", + createdAt: "2025-01-01T00:00:00Z", + }, + }, + }; +} + +function repoResponse() { + return { + repository: { id: "repo123", owner: { id: "owner123", __typename: "Organization" } }, + }; +} + +function viewerResponse() { + return { viewer: { login: "test-bot" } }; +} + +function issueResponse(id) { + return { repository: { issue: { id, body: null } } }; +} + +function emptyItemsResponse() { + return { node: { items: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }; +} + +function addItemResponse(itemId = "PVTI_item1") { + return { addProjectV2ItemById: { item: { id: itemId } } }; +} + +function fieldsResponse(nodes = []) { + return { node: { fields: { nodes, pageInfo: { hasNextPage: false, endCursor: null } } } }; +} + +// ─── Integration: create_project → create_project_status_update ─────────────── + +describe("integration: create_project → create_project_status_update via temporary ID", () => { + it("project URL stored by create_project is resolved by create_project_status_update", async () => { + const projectUrl = "https://github.com/orgs/test-org/projects/42"; + + // create_project: getOwnerId + createProjectV2 + mockGithub.graphql + .mockResolvedValueOnce(ORG_OWNER_RESPONSE) + .mockResolvedValueOnce(createProjectResponse(projectUrl, 42)) + // create_project_status_update: resolve project + create status update + .mockResolvedValueOnce(orgProjectV2Response(projectUrl, 42)) + .mockResolvedValueOnce(statusUpdateResponse()); + + const temporaryIdMap = new Map(); + + const cpHandler = await createProjectMain({ target_owner: "test-org", max: 2 }, mockGithub); + const cpResult = await cpHandler({ title: "Integration Project", temporary_id: "aw_iproj1" }, {}, temporaryIdMap); + + expect(cpResult.success).toBe(true); + expect(cpResult.temporaryId).toBe("aw_iproj1"); + expect(temporaryIdMap.get("aw_iproj1")).toEqual({ projectUrl }); + + // Now create_project_status_update references the project via temporary ID + const cpsuHandler = await createProjectStatusUpdateMain({ max: 2 }, mockGithub); + const cpsuResult = await cpsuHandler({ project: "#aw_iproj1", body: "Integration test status", status: "ON_TRACK" }, Object.fromEntries(temporaryIdMap), temporaryIdMap); + + expect(cpsuResult.success).toBe(true); + expect(cpsuResult.status_update_id).toBe("PVTSU_1"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID #aw_iproj1")); + }); + + it("bare aw_xxx form also resolves in create_project_status_update", async () => { + const projectUrl = "https://github.com/orgs/test-org/projects/43"; + + mockGithub.graphql + .mockResolvedValueOnce(ORG_OWNER_RESPONSE) + .mockResolvedValueOnce(createProjectResponse(projectUrl, 43)) + .mockResolvedValueOnce(orgProjectV2Response(projectUrl, 43)) + .mockResolvedValueOnce(statusUpdateResponse("PVTSU_2")); + + const temporaryIdMap = new Map(); + + const cpHandler = await createProjectMain({ target_owner: "test-org", max: 2 }, mockGithub); + await cpHandler({ title: "Another Project", temporary_id: "aw_iproj2" }, {}, temporaryIdMap); + + const cpsuHandler = await createProjectStatusUpdateMain({ max: 2 }, mockGithub); + const cpsuResult = await cpsuHandler({ project: "aw_iproj2", body: "Status update", status: "AT_RISK" }, Object.fromEntries(temporaryIdMap), temporaryIdMap); + + expect(cpsuResult.success).toBe(true); + expect(cpsuResult.status_update_id).toBe("PVTSU_2"); + }); + + it("create_project_status_update returns error when project temporary ID is not yet in map", async () => { + const temporaryIdMap = new Map(); // empty — create_project not yet called + + const cpsuHandler = await createProjectStatusUpdateMain({ max: 2 }, mockGithub); + const result = await cpsuHandler({ project: "#aw_unresolved", body: "Status", status: "ON_TRACK" }, Object.fromEntries(temporaryIdMap), temporaryIdMap); + + expect(result.success).toBe(false); + expect(result.error).toContain("aw_unresolved"); + expect(mockGithub.graphql).not.toHaveBeenCalled(); + }); +}); + +// ─── Integration: create_project → update_project ──────────────────────────── + +describe("integration: create_project → update_project via temporary ID", () => { + it("project URL stored by create_project is resolved by update_project (project field)", async () => { + const projectUrl = "https://github.com/orgs/test-org/projects/50"; + + // create_project calls + mockGithub.graphql + .mockResolvedValueOnce(ORG_OWNER_RESPONSE) + .mockResolvedValueOnce(createProjectResponse(projectUrl, 50)) + // update_project calls: repo, viewer, orgProject, issue, items, addItem + .mockResolvedValueOnce(repoResponse()) + .mockResolvedValueOnce(viewerResponse()) + .mockResolvedValueOnce(orgProjectV2Response(projectUrl, 50, "PVT_proj50")) + .mockResolvedValueOnce(issueResponse("ISSUE_node1")) + .mockResolvedValueOnce(emptyItemsResponse()) + .mockResolvedValueOnce(addItemResponse("PVTI_item50")); + + const temporaryIdMap = new Map(); + + const cpHandler = await createProjectMain({ target_owner: "test-org", max: 2 }, mockGithub); + const cpResult = await cpHandler({ title: "Project 50", temporary_id: "#aw_proj50" }, {}, temporaryIdMap); + + expect(cpResult.success).toBe(true); + expect(cpResult.temporaryId).toBe("aw_proj50"); + expect(temporaryIdMap.has("aw_proj50")).toBe(true); + + // update_project uses "#aw_proj50" as the project reference + const upHandler = await updateProjectMain({ max: 5 }, mockGithub); + const upResult = await upHandler({ project: "#aw_proj50", content_type: "issue", content_number: 99 }, Object.fromEntries(temporaryIdMap), temporaryIdMap); + + expect(upResult.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID")); + }); +}); + +// ─── Integration: issue → create_project (item_url) → update_project ───────── + +describe("integration: pre-seeded issue → create_project (item_url) → update_project (content_number)", () => { + it("create_project resolves issue temporary ID in item_url; update_project resolves issue and project IDs", async () => { + const projectUrl = "https://github.com/orgs/test-org/projects/60"; + + // The "issue" was created by an earlier create_issue handler and stored in the map + const temporaryIdMap = new Map(); + temporaryIdMap.set("aw_issue1", { repo: "test-org/test-repo", number: 100 }); + + // create_project: getOwnerId + createProjectV2 + getIssueNodeId + addItemToProject + mockGithub.graphql + .mockResolvedValueOnce(ORG_OWNER_RESPONSE) + .mockResolvedValueOnce(createProjectResponse(projectUrl, 60)) + .mockResolvedValueOnce({ repository: { issue: { id: "ISSUE_node100" } } }) // getIssueNodeId + .mockResolvedValueOnce(addItemResponse("PVTI_init_item")) + // update_project calls: repo, viewer, orgProject, issue, items, addItem + .mockResolvedValueOnce(repoResponse()) + .mockResolvedValueOnce(viewerResponse()) + .mockResolvedValueOnce(orgProjectV2Response(projectUrl, 60, "PVT_proj60")) + .mockResolvedValueOnce(issueResponse("ISSUE_node100")) + .mockResolvedValueOnce(emptyItemsResponse()) + .mockResolvedValueOnce(addItemResponse("PVTI_item60")); + + const cpHandler = await createProjectMain({ target_owner: "test-org", max: 2 }, mockGithub); + const cpResult = await cpHandler( + { + title: "Project 60", + temporary_id: "aw_proj60", + item_url: "#aw_issue1", // resolved from temporaryIdMap + }, + Object.fromEntries(temporaryIdMap), + temporaryIdMap + ); + + expect(cpResult.success).toBe(true); + expect(cpResult.temporaryId).toBe("aw_proj60"); + // project URL now in map + expect(temporaryIdMap.get("aw_proj60")).toEqual({ projectUrl }); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary ID #aw_issue1 in item_url")); + + // update_project references both project and issue via temporary IDs + const upHandler = await updateProjectMain({ max: 5 }, mockGithub); + const upResult = await upHandler( + { + project: "#aw_proj60", + content_type: "issue", + content_number: "#aw_issue1", + }, + Object.fromEntries(temporaryIdMap), + temporaryIdMap + ); + + expect(upResult.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID")); + }); +}); + +// ─── Integration: normalisation across the pipeline ────────────────────────── + +describe("integration: temporary ID normalisation across the pipeline", () => { + it("project created with uppercase temporary_id is found by subsequent handlers using lowercase", async () => { + const projectUrl = "https://github.com/orgs/test-org/projects/70"; + + // create_project: getOwnerId + createProjectV2 + mockGithub.graphql + .mockResolvedValueOnce(ORG_OWNER_RESPONSE) + .mockResolvedValueOnce(createProjectResponse(projectUrl, 70)) + // create_project_status_update: resolve project + create status update + .mockResolvedValueOnce(orgProjectV2Response(projectUrl, 70)) + .mockResolvedValueOnce(statusUpdateResponse("PVTSU_norm")); + + const temporaryIdMap = new Map(); + + const cpHandler = await createProjectMain({ target_owner: "test-org", max: 2 }, mockGithub); + const cpResult = await cpHandler({ title: "Project 70", temporary_id: "#aw_PROJ70" }, {}, temporaryIdMap); + + expect(cpResult.success).toBe(true); + expect(cpResult.temporaryId).toBe("aw_proj70"); // normalised + expect(temporaryIdMap.has("aw_proj70")).toBe(true); // stored under lowercase + + // create_project_status_update uses a different casing — still resolves + const cpsuHandler = await createProjectStatusUpdateMain({ max: 2 }, mockGithub); + const cpsuResult = await cpsuHandler({ project: "#aw_proj70", body: "Normalisation test", status: "ON_TRACK" }, Object.fromEntries(temporaryIdMap), temporaryIdMap); + + expect(cpsuResult.success).toBe(true); + expect(cpsuResult.status_update_id).toBe("PVTSU_norm"); + }); +}); diff --git a/actions/setup/js/temporary_id.cjs b/actions/setup/js/temporary_id.cjs index 7984d4dc46e..04a517093a7 100644 --- a/actions/setup/js/temporary_id.cjs +++ b/actions/setup/js/temporary_id.cjs @@ -59,24 +59,28 @@ function generateTemporaryId() { } /** - * Check if a value is a valid temporary ID (aw_ prefix + 3 to 12 alphanumeric or underscore characters) + * Check if a value is a valid temporary ID. + * Accepts both canonical form ('#aw_xxx') and bare form ('aw_xxx'). + * Format: optional '#', then 'aw_' followed by 3–12 alphanumeric or underscore characters. * @param {any} value - The value to check * @returns {boolean} True if the value is a valid temporary ID */ function isTemporaryId(value) { if (typeof value === "string") { - return /^aw_[A-Za-z0-9_]{3,12}$/i.test(value); + return /^#?aw_[A-Za-z0-9_]{3,12}$/i.test(value); } return false; } /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID + * Normalize a temporary ID to a bare lowercase map key for consistent lookups. + * Strips the leading '#' if present, then lowercases. + * @param {string} tempId - The temporary ID to normalize (with or without leading '#') + * @returns {string} Bare lowercase temporary ID (e.g. 'aw_abc123') */ function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); + const s = String(tempId); + return (s.startsWith("#") ? s.substring(1) : s).toLowerCase(); } /** @@ -129,8 +133,7 @@ function replaceTemporaryIdReferencesInPatch(text, tempIdMap, currentRepo) { // This must run before the standard replacement to avoid leaving a '#' in URLs const urlContextPattern = /\/(#aw_[A-Za-z0-9_]{3,12})\b/gi; let result = text.replace(urlContextPattern, (match, tempIdWithHash) => { - const tempId = tempIdWithHash.substring(1); // strip leading '#' - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + const resolved = tempIdMap.get(normalizeTemporaryId(tempIdWithHash)); if (resolved !== undefined) { return `/${resolved.number}`; } @@ -190,9 +193,8 @@ function getOrGenerateTemporaryId(message, entityType = "item") { // Normalize and validate format const rawTemporaryId = message.temporary_id.trim(); - const normalized = rawTemporaryId.startsWith("#") ? rawTemporaryId.substring(1).trim() : rawTemporaryId; - if (!isTemporaryId(normalized)) { + if (!isTemporaryId(rawTemporaryId)) { // Warn and auto-generate rather than failing - an invalid temporary_id is a minor issue const autoGenerated = generateTemporaryId(); if (typeof core !== "undefined") { @@ -207,7 +209,7 @@ function getOrGenerateTemporaryId(message, entityType = "item") { } return { - temporaryId: normalized.toLowerCase(), + temporaryId: normalizeTemporaryId(rawTemporaryId), error: null, }; } @@ -300,15 +302,13 @@ function resolveIssueNumber(value, temporaryIdMap) { return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; } - // Strip # prefix if present to allow flexible temporary ID format const valueStr = String(value).trim(); // Strip surrounding quotes (agent sometimes double-quotes string values, e.g. `"aw_foo"`) const unquoted = /^(["'])(.+)\1$/.test(valueStr) ? valueStr.slice(1, -1) : valueStr; - const valueWithoutHash = unquoted.startsWith("#") ? unquoted.substring(1) : unquoted; - // Check if it's a temporary ID - if (isTemporaryId(valueWithoutHash)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueWithoutHash)); + // Check if it's a temporary ID (accepts both '#aw_xxx' and 'aw_xxx' forms) + if (isTemporaryId(unquoted)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(unquoted)); if (resolvedPair !== undefined) { // Support legacy format where the map value is the issue number. const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; @@ -336,7 +336,8 @@ function resolveIssueNumber(value, temporaryIdMap) { } // Check if it looks like a malformed temporary ID - if (valueWithoutHash.startsWith("aw_")) { + const withoutHash = unquoted.startsWith("#") ? unquoted.substring(1) : unquoted; + if (withoutHash.startsWith("aw_")) { return { resolved: null, wasTemporaryId: false, @@ -345,7 +346,7 @@ function resolveIssueNumber(value, temporaryIdMap) { } // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueWithoutHash, 10); + const issueNumber = typeof value === "number" ? value : parseInt(withoutHash, 10); if (isNaN(issueNumber) || issueNumber <= 0) { return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}. Expected either a valid temporary ID (format: aw_ followed by 3-12 alphanumeric or underscore characters) or a numeric issue number.` }; } @@ -552,12 +553,9 @@ function extractTemporaryIdReferences(message) { for (const field of idFields) { const value = message[field]; if (value !== undefined && value !== null) { - // Strip # prefix if present const valueStr = String(value).trim(); - const valueWithoutHash = valueStr.startsWith("#") ? valueStr.substring(1) : valueStr; - - if (isTemporaryId(valueWithoutHash)) { - tempIds.add(normalizeTemporaryId(valueWithoutHash)); + if (isTemporaryId(valueStr)) { + tempIds.add(normalizeTemporaryId(valueStr)); } } } @@ -571,18 +569,16 @@ function extractTemporaryIdReferences(message) { if (value !== undefined && value !== null && typeof value === "string") { // Extract potential temporary ID from URL or plain ID // Match: https://github.com/owner/repo/issues/#aw_XXX or #aw_XXXXXXXX - const urlMatch = value.match(/issues\/(#?aw_[A-Za-z0-9]{3,8})\s*$/i); + const urlMatch = value.match(/issues\/(#?aw_[A-Za-z0-9_]{3,12})\s*$/i); if (urlMatch) { - const valueWithoutHash = urlMatch[1].startsWith("#") ? urlMatch[1].substring(1) : urlMatch[1]; - if (isTemporaryId(valueWithoutHash)) { - tempIds.add(normalizeTemporaryId(valueWithoutHash)); + if (isTemporaryId(urlMatch[1])) { + tempIds.add(normalizeTemporaryId(urlMatch[1])); } } else { // Also check if the entire value is a temporary ID (with or without #) const valueStr = String(value).trim(); - const valueWithoutHash = valueStr.startsWith("#") ? valueStr.substring(1) : valueStr; - if (isTemporaryId(valueWithoutHash)) { - tempIds.add(normalizeTemporaryId(valueWithoutHash)); + if (isTemporaryId(valueStr)) { + tempIds.add(normalizeTemporaryId(valueStr)); } } } @@ -638,10 +634,9 @@ function resolveNumberFromTemporaryId(value, resolvedTemporaryIds) { } const rawStr = String(value).trim(); - const withoutHash = rawStr.startsWith("#") ? rawStr.substring(1) : rawStr; - if (isTemporaryId(withoutHash)) { - const normalized = normalizeTemporaryId(withoutHash); + if (isTemporaryId(rawStr)) { + const normalized = normalizeTemporaryId(rawStr); const entry = resolvedTemporaryIds && resolvedTemporaryIds[normalized]; if (!entry || !entry.number) { return { resolved: null, wasTemporaryId: true, errorMessage: `Unresolved temporary ID: ${rawStr}` }; @@ -652,6 +647,7 @@ function resolveNumberFromTemporaryId(value, resolvedTemporaryIds) { // Strict integer check: only accept pure numeric strings or actual numbers. // parseInt("42abc") returns 42 which would pass NaN/isInteger checks, so we // validate the raw string contains only digits before converting. + const withoutHash = rawStr.startsWith("#") ? rawStr.substring(1) : rawStr; let num; if (typeof value === "number") { num = value; diff --git a/actions/setup/js/temporary_id.test.cjs b/actions/setup/js/temporary_id.test.cjs index 98f94ad3e57..aa26f0345f9 100644 --- a/actions/setup/js/temporary_id.test.cjs +++ b/actions/setup/js/temporary_id.test.cjs @@ -52,6 +52,14 @@ describe("temporary_id.cjs", () => { expect(isTemporaryId("aw_123456789abc")).toBe(true); // 12 chars - at the limit }); + it("should return true for valid #aw_ prefixed strings (canonical form)", async () => { + const { isTemporaryId } = await import("./temporary_id.cjs"); + expect(isTemporaryId("#aw_abc")).toBe(true); + expect(isTemporaryId("#aw_abc1")).toBe(true); + expect(isTemporaryId("#aw_pr_fix")).toBe(true); + expect(isTemporaryId("#aw_123456789abc")).toBe(true); // 12 chars - at the limit + }); + it("should return true for valid aw_ prefixed strings with underscores", async () => { const { isTemporaryId } = await import("./temporary_id.cjs"); expect(isTemporaryId("aw_id_123")).toBe(true); // Contains underscore - now valid @@ -84,6 +92,12 @@ describe("temporary_id.cjs", () => { expect(normalizeTemporaryId("aw_ABC123")).toBe("aw_abc123"); expect(normalizeTemporaryId("AW_Test123")).toBe("aw_test123"); }); + + it("should strip leading # before lowercasing", async () => { + const { normalizeTemporaryId } = await import("./temporary_id.cjs"); + expect(normalizeTemporaryId("#aw_ABC123")).toBe("aw_abc123"); + expect(normalizeTemporaryId("#aw_pr_fix")).toBe("aw_pr_fix"); + }); }); describe("replaceTemporaryIdReferences", () => { diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index f7d70362c17..0c08781763c 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -752,23 +752,24 @@ 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 using shared helpers + // Extract, validate, and normalize temporary_id and draft_issue_id using shared helpers. + // normalizeTemporaryId strips any leading '#' so the bare key form is used consistently. 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 IDs used for draft chaining. // Draft issue chaining must use strict temporary IDs to match the unified handler manager. - if (temporaryId && !isTemporaryId(temporaryId)) { - throw new Error(`${ERR_VALIDATION}: Invalid temporary_id format: "${temporaryId}". Expected format: aw_ followed by 3 to 12 alphanumeric or underscore characters (e.g., "aw_abc", "aw_pr_fix").`); + if (rawTemporaryId && !isTemporaryId(rawTemporaryId)) { + throw new Error(`${ERR_VALIDATION}: Invalid temporary_id format: "${rawTemporaryId}". Expected format: aw_ followed by 3 to 12 alphanumeric or underscore characters (e.g., "aw_abc", "aw_pr_fix").`); } - if (draftIssueId && !isTemporaryId(draftIssueId)) { - throw new Error(`${ERR_VALIDATION}: Invalid draft_issue_id format: "${draftIssueId}". Expected format: aw_ followed by 3 to 12 alphanumeric or underscore characters (e.g., "aw_abc", "aw_pr_fix").`); + if (rawDraftIssueId && !isTemporaryId(rawDraftIssueId)) { + throw new Error(`${ERR_VALIDATION}: Invalid draft_issue_id format: "${rawDraftIssueId}". Expected format: aw_ followed by 3 to 12 alphanumeric or underscore characters (e.g., "aw_abc", "aw_pr_fix").`); } + const temporaryId = rawTemporaryId ? normalizeTemporaryId(rawTemporaryId) : ""; + const draftIssueId = rawDraftIssueId ? normalizeTemporaryId(rawDraftIssueId) : ""; + const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : ""; const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined; @@ -1297,14 +1298,12 @@ async function main(config = {}, githubClient = null) { // Resolve temporary project ID if present if (effectiveProjectUrl && typeof effectiveProjectUrl === "string") { - // Strip # prefix if present const projectStr = effectiveProjectUrl.trim(); - const projectWithoutHash = projectStr.startsWith("#") ? projectStr.substring(1) : projectStr; // Check if it's a temporary ID using the canonical pattern (aw_XXX to aw_XXXXXXXX) - if (isTemporaryId(projectWithoutHash)) { + if (isTemporaryId(projectStr)) { // Look up in the unified temporaryIdMap using normalized (lowercase) ID - const normalizedId = normalizeTemporaryId(projectWithoutHash); + const normalizedId = normalizeTemporaryId(projectStr); const resolved = tempIdMap.get(normalizedId); if (resolved && typeof resolved === "object" && "projectUrl" in resolved && resolved.projectUrl) { core.info(`Resolved temporary project ID ${projectStr} to ${resolved.projectUrl}`); diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 10151ca3fb0..d6a2c5a7d47 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -876,7 +876,7 @@ describe("updateProject", () => { await updateProject(updateOutput, temporaryIdMap); expect(getOutput("item-id")).toBe("draft-item-chain"); - expect(mockCore.info).toHaveBeenCalledWith('✓ Resolved draft_issue_id "AW_DEADBE" to item draft-item-chain'); + expect(mockCore.info).toHaveBeenCalledWith('✓ Resolved draft_issue_id "aw_deadbe" to item draft-item-chain'); }); it("rejects malformed auto-generated temporary_id with aw_ prefix", async () => { diff --git a/actions/setup/js/upload_artifact.cjs b/actions/setup/js/upload_artifact.cjs index f387e226337..ddc42fa529c 100644 --- a/actions/setup/js/upload_artifact.cjs +++ b/actions/setup/js/upload_artifact.cjs @@ -83,9 +83,8 @@ function resolveTemporaryArtifactId(message) { const declared = message.temporary_id; if (declared && typeof declared === "string") { const trimmed = declared.trim(); - const normalized = trimmed.startsWith("#") ? trimmed.substring(1) : trimmed; - if (isTemporaryId(normalized)) { - return normalizeTemporaryId(normalized); + if (isTemporaryId(trimmed)) { + return normalizeTemporaryId(trimmed); } if (typeof core !== "undefined") { core.warning(`upload_artifact: invalid temporary_id format '${declared}'. ` + `Temporary IDs must be 'aw_' followed by 3–12 alphanumeric or underscore characters. ` + `A random ID will be generated instead.`); diff --git a/actions/setup/md/safe_outputs_prompt.md b/actions/setup/md/safe_outputs_prompt.md index 8113d7cc205..493c3d5bfc3 100644 --- a/actions/setup/md/safe_outputs_prompt.md +++ b/actions/setup/md/safe_outputs_prompt.md @@ -9,7 +9,7 @@ When no action is needed, call noop like this: {"noop": {"message": "No action needed: [brief explanation of what was analyzed and why no action was required]"}} ``` -temporary_id: optional cross-reference field (e.g. use #aw_abc1 in a body). Format: aw_ + 3–8 alphanumeric chars (/^aw_[A-Za-z0-9]{3,8}$/). Omit when not needed. +temporary_id: optional cross-reference field. Canonical form: '#aw_' followed by 3–12 alphanumeric or underscore characters — e.g., '#aw_abc1', '#aw_pr_fix'. Pattern: /^#?aw_[A-Za-z0-9_]{3,12}$/i (the '#' prefix is optional; bare 'aw_abc1' is accepted and normalised to '#aw_abc1' automatically). Use this form for all field values (temporary_id, item_number, issue_number, parent, etc.). In body/markdown text, '#aw_abc1' references are replaced with the real issue/PR number after creation. Omit entirely when not needed. **Note**: safeoutputs tools do NOT support `@filename` file name expansion. Always provide content inline — do not use `@