From cdf95f1a50ac6be42aa7a0f711b06df7b9f248fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:50:48 +0000 Subject: [PATCH 1/3] Initial plan From a37d8d40ecfc60bdf3d7fd4f6cb732160ca81f7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:06:52 +0000 Subject: [PATCH 2/3] feat: add owner/number format and temp ID support for project safe outputs Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- .../setup/js/create_project_status_update.cjs | 72 ++++++-- .../js/create_project_status_update.test.cjs | 168 ++++++++++++++++++ actions/setup/js/safe_outputs_tools.json | 8 +- actions/setup/js/update_project.cjs | 71 +++++--- actions/setup/js/update_project.test.cjs | 85 ++++++++- .../content/docs/reference/safe-outputs.md | 41 ++++- 6 files changed, 389 insertions(+), 56 deletions(-) diff --git a/actions/setup/js/create_project_status_update.cjs b/actions/setup/js/create_project_status_update.cjs index 65e6d70949c..3260009ce85 100644 --- a/actions/setup/js/create_project_status_update.cjs +++ b/actions/setup/js/create_project_status_update.cjs @@ -6,6 +6,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs"); +const { isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -52,25 +53,28 @@ function logGraphQLError(error, operation) { } /** - * Parse project URL into components - * @param {unknown} projectUrl - Project URL - * @returns {{ scope: string, ownerLogin: string, projectNumber: string }} Project info + * Parse project URL or owner/number format into components. + * When the owner/number format is used, `scope` is set to `null` and is resolved + * automatically during project lookup (org tried first, then user). + * @param {unknown} projectUrl - Project URL or owner/number (e.g., 'myorg/42') + * @returns {{ scope: string | null, ownerLogin: string, projectNumber: string }} Project info */ function parseProjectUrl(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); + throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL or owner/number format.`); } - const match = projectUrl.match(/^https:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); - if (!match) { - throw new Error(`${ERR_VALIDATION}: Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); + const urlMatch = projectUrl.match(/^https?:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); + if (urlMatch) { + return { scope: urlMatch[1], ownerLogin: urlMatch[2], projectNumber: urlMatch[3] }; } - return { - scope: match[1], - ownerLogin: match[2], - projectNumber: match[3], - }; + const ownerNumberMatch = projectUrl.match(/^([A-Za-z0-9][A-Za-z0-9\-]*)\/(\d+)$/); + if (ownerNumberMatch) { + return { scope: null, ownerLogin: ownerNumberMatch[1], projectNumber: ownerNumberMatch[2] }; + } + + throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123) or owner/number format (e.g., myorg/42).`); } /** @@ -158,12 +162,28 @@ function summarizeEmptyProjectsV2List(list) { } /** - * Resolve a project by number - * @param {{ scope: string, ownerLogin: string, projectNumber: string }} projectInfo - Project info + * Resolve a project by number. + * When `projectInfo.scope` is `null` (owner/number format), org is tried first then user. + * On success the resolved scope is written back to `projectInfo.scope`. + * @param {{ scope: string | null, ownerLogin: string, projectNumber: string }} projectInfo - Project info (mutated to set scope when null) * @param {number} projectNumberInt - Project number * @returns {Promise<{ id: string, number: number, title: string, url: string }>} Project details */ async function resolveProjectV2(projectInfo, projectNumberInt) { + // When scope is unknown (owner/number format), try org then user. + if (!projectInfo.scope) { + for (const tryScope of ["orgs", "users"]) { + try { + const project = await resolveProjectV2({ ...projectInfo, scope: tryScope }, projectNumberInt); + projectInfo.scope = tryScope; // propagate resolved scope to the caller's object + return project; + } catch (e) { + core.warning(`Project #${projectNumberInt} not found as ${tryScope === "orgs" ? "org" : "user"} "${projectInfo.ownerLogin}"; trying ${tryScope === "orgs" ? "user" : "org"}.`); + } + } + throw new Error(`${ERR_NOT_FOUND}: Project #${projectNumberInt} not found for owner "${projectInfo.ownerLogin}" (tried as org and user). Verify the owner login and project number are correct.`); + } + try { const query = projectInfo.scope === "orgs" @@ -327,17 +347,35 @@ async function main(config = {}, githubClient = null) { const output = message; // Validate that project field is explicitly provided in the message - // The project field is required in agent output messages and must be a full GitHub project URL - const effectiveProjectUrl = output.project; + // The project field can be a full URL, owner/number format, or a temporary project ID + let effectiveProjectUrl = output.project; if (!effectiveProjectUrl || typeof effectiveProjectUrl !== "string" || effectiveProjectUrl.trim() === "") { - core.error('Missing required "project" field. The agent must explicitly include the project URL in the output message: {"type": "create_project_status_update", "project": "https://github.com/orgs/myorg/projects/42", "body": "..."}'); + core.error('Missing required "project" field. The agent must explicitly include the project in the output message: {"type": "create_project_status_update", "project": "myorg/42", "body": "..."}'); return { success: false, error: "Missing required field: project", }; } + // 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); + const resolved = temporaryIdMap instanceof Map ? temporaryIdMap.get(normalizedId) : undefined; + if (resolved && typeof resolved === "object" && "projectUrl" in resolved && resolved.projectUrl) { + core.info(`Resolved temporary project ID ${projectStr} to ${resolved.projectUrl}`); + effectiveProjectUrl = resolved.projectUrl; + } else { + core.error(`Temporary project ID '${projectStr}' not found. Ensure create_project was called before create_project_status_update.`); + return { + success: false, + error: `Temporary project ID '${projectStr}' not found`, + }; + } + } + if (!output.body) { core.error("Missing required field: body (status update content)"); return { diff --git a/actions/setup/js/create_project_status_update.test.cjs b/actions/setup/js/create_project_status_update.test.cjs index 59e18fe63b1..389892e00b2 100644 --- a/actions/setup/js/create_project_status_update.test.cjs +++ b/actions/setup/js/create_project_status_update.test.cjs @@ -601,3 +601,171 @@ describe("create_project_status_update", () => { delete process.env.GH_AW_PROJECT_URL; }); }); + +describe("create_project_status_update owner/number format", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves an org project via owner/number shorthand", async () => { + const projectUrl = "https://github.com/orgs/myorg/projects/42"; + + mockGithub.graphql + .mockResolvedValueOnce({ + organization: { + projectV2: { + id: "PVT_ownernum1", + number: 42, + title: "Owner Num Project", + url: projectUrl, + }, + }, + }) + .mockResolvedValueOnce({ + createProjectV2StatusUpdate: { + statusUpdate: { + id: "PVTSU_ownernum1", + body: "Status via owner/number", + bodyHTML: "

Status

", + startDate: "2025-01-01", + targetDate: "2025-12-31", + status: "ON_TRACK", + createdAt: "2025-01-06T12:00:00Z", + }, + }, + }); + + const handler = await main({ max: 10 }); + const result = await handler({ project: "myorg/42", body: "Status via owner/number" }, new Map()); + + expect(result.success).toBe(true); + expect(result.status_update_id).toBe("PVTSU_ownernum1"); + }); + + it("resolves a user project via owner/number shorthand (org fails, user succeeds)", async () => { + const projectUrl = "https://github.com/users/myuser/projects/5"; + + mockGithub.graphql + .mockRejectedValueOnce(new Error("Could not resolve to an Organization")) // org direct query fails + .mockRejectedValueOnce(new Error("org list also failed")) // org fallback list fails → auto-scope moves to user + .mockResolvedValueOnce({ + user: { + projectV2: { + id: "PVT_usernum1", + number: 5, + title: "User Num Project", + url: projectUrl, + }, + }, + }) + .mockResolvedValueOnce({ + createProjectV2StatusUpdate: { + statusUpdate: { + id: "PVTSU_usernum1", + body: "Status via user owner/number", + bodyHTML: "

Status

", + startDate: "2025-01-01", + targetDate: "2025-12-31", + status: "ON_TRACK", + createdAt: "2025-01-06T12:00:00Z", + }, + }, + }); + + const handler = await main({ max: 10 }); + const result = await handler({ project: "myuser/5", body: "Status via user owner/number" }, new Map()); + + expect(result.success).toBe(true); + expect(result.status_update_id).toBe("PVTSU_usernum1"); + }); +}); + +describe("create_project_status_update temporary ID resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves a temporary project ID from the map", async () => { + const projectUrl = "https://github.com/orgs/myorg/projects/42"; + const temporaryIdMap = new Map([["aw_proj1", { projectUrl }]]); + + mockGithub.graphql + .mockResolvedValueOnce({ + organization: { + projectV2: { + id: "PVT_tempid1", + number: 42, + title: "Temp ID Project", + url: projectUrl, + }, + }, + }) + .mockResolvedValueOnce({ + createProjectV2StatusUpdate: { + statusUpdate: { + id: "PVTSU_tempid1", + body: "Status via temp ID", + bodyHTML: "

Status

", + startDate: "2025-01-01", + targetDate: "2025-12-31", + status: "ON_TRACK", + createdAt: "2025-01-06T12:00:00Z", + }, + }, + }); + + const handler = await main({ max: 10 }); + const result = await handler({ project: "aw_proj1", body: "Status via temp ID" }, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.status_update_id).toBe("PVTSU_tempid1"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID aw_proj1")); + }); + + it("resolves a temporary project ID with # prefix from the map", async () => { + const projectUrl = "https://github.com/orgs/myorg/projects/99"; + const temporaryIdMap = new Map([["aw_proj2", { projectUrl }]]); + + mockGithub.graphql + .mockResolvedValueOnce({ + organization: { + projectV2: { + id: "PVT_tempid2", + number: 99, + title: "Hash Temp ID Project", + url: projectUrl, + }, + }, + }) + .mockResolvedValueOnce({ + createProjectV2StatusUpdate: { + statusUpdate: { + id: "PVTSU_tempid2", + body: "Status via hash temp ID", + bodyHTML: "

Status

", + startDate: "2025-01-01", + targetDate: "2025-12-31", + status: "ON_TRACK", + createdAt: "2025-01-06T12:00:00Z", + }, + }, + }); + + const handler = await main({ max: 10 }); + const result = await handler({ project: "#aw_proj2", body: "Status via hash temp ID" }, temporaryIdMap); + + expect(result.success).toBe(true); + expect(result.status_update_id).toBe("PVTSU_tempid2"); + }); + + it("returns error when temporary project ID not found in map", async () => { + const temporaryIdMap = new Map(); // Empty - ID not found + + const handler = await main({ max: 10 }); + const result = await handler({ project: "aw_notfound", body: "Status" }, temporaryIdMap); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Temporary project ID 'aw_notfound' not found/); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Temporary project ID 'aw_notfound' not found")); + }); +}); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index dd122ee3656..98ccfe11b4b 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -734,8 +734,8 @@ "properties": { "project": { "type": "string", - "pattern": "^(https://github\\.com/(orgs|users)/[^/]+/projects/\\d+|#?aw_[A-Za-z0-9]{3,8})$", - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'), or a temporary project ID from a recent create_project call (e.g., '#aw_abc1', 'aw_Test123'). Project names or numbers alone are NOT accepted." + "pattern": "^(https?://github\\.com/(orgs|users)/[^/]+/projects/\\d+|[A-Za-z0-9][A-Za-z0-9\\-]*/\\d+|#?aw_[A-Za-z0-9]{3,8})$", + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42'), owner/number shorthand (e.g., 'myorg/42'), or a temporary project ID from a recent create_project call (e.g., '#aw_abc1', 'aw_Test123')." }, "operation": { "type": "string", @@ -903,8 +903,8 @@ "properties": { "project": { "type": "string", - "pattern": "^https://github\\\\.com/(orgs|users)/[^/]+/projects/\\\\d+$", - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted." + "pattern": "^(https?://github\\.com/(orgs|users)/[^/]+/projects/\\d+|[A-Za-z0-9][A-Za-z0-9\\-]*/\\d+|#?aw_[A-Za-z0-9]{3,8})$", + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42'), owner/number shorthand (e.g., 'myorg/42'), or a temporary project ID from a recent create_project call (e.g., '#aw_abc1', 'aw_Test123')." }, "status": { "type": "string", diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index b176d9e17f6..d7851200614 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -71,43 +71,51 @@ function logGraphQLError(error, operation) { if (error.data) core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`); } /** - * Parse project number from URL - * @param {unknown} projectUrl - Project URL + * Parse project number from URL or owner/number format + * @param {unknown} projectUrl - Project URL or owner/number (e.g., 'myorg/42') * @returns {string} Project number */ function parseProjectInput(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); + throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL or owner/number format.`); } - const urlMatch = projectUrl.match(/^https:\/\/[^/]+\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); - if (!urlMatch) { - throw new Error(`${ERR_VALIDATION}: Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); + const urlMatch = projectUrl.match(/^https?:\/\/[^/]+\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); + if (urlMatch) { + return urlMatch[1]; } - return urlMatch[1]; + const ownerNumberMatch = projectUrl.match(/^[A-Za-z0-9][A-Za-z0-9\-]*\/(\d+)$/); + if (ownerNumberMatch) { + return ownerNumberMatch[1]; + } + + throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123) or owner/number format (e.g., myorg/42).`); } /** - * Parse project URL into components - * @param {unknown} projectUrl - Project URL - * @returns {{ scope: string, ownerLogin: string, projectNumber: string }} Project info + * Parse project URL or owner/number format into components. + * When the owner/number format is used, `scope` is set to `null` and is resolved + * automatically during project lookup (org tried first, then user). + * @param {unknown} projectUrl - Project URL or owner/number (e.g., 'myorg/42') + * @returns {{ scope: string | null, ownerLogin: string, projectNumber: string }} Project info */ function parseProjectUrl(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); + throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL or owner/number format.`); } - const match = projectUrl.match(/^https:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); - if (!match) { - throw new Error(`${ERR_VALIDATION}: Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); + const urlMatch = projectUrl.match(/^https?:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); + if (urlMatch) { + return { scope: urlMatch[1], ownerLogin: urlMatch[2], projectNumber: urlMatch[3] }; } - return { - scope: match[1], - ownerLogin: match[2], - projectNumber: match[3], - }; + const ownerNumberMatch = projectUrl.match(/^([A-Za-z0-9][A-Za-z0-9\-]*)\/(\d+)$/); + if (ownerNumberMatch) { + return { scope: null, ownerLogin: ownerNumberMatch[1], projectNumber: ownerNumberMatch[2] }; + } + + throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123) or owner/number format (e.g., myorg/42).`); } /** * List accessible Projects v2 for org or user @@ -211,13 +219,29 @@ function summarizeEmptyProjectsV2List(list) { return `(none${diag})`; } /** - * Resolve a project by number - * @param {{ scope: string, ownerLogin: string, projectNumber: string }} projectInfo - Project info + * Resolve a project by number. + * When `projectInfo.scope` is `null` (owner/number format), org is tried first then user. + * On success the resolved scope is written back to `projectInfo.scope`. + * @param {{ scope: string | null, ownerLogin: string, projectNumber: string }} projectInfo - Project info (mutated to set scope when null) * @param {number} projectNumberInt - Project number * @param {Object} github - GitHub client (Octokit instance) to use for GraphQL queries * @returns {Promise<{ id: string, number: number, title: string, url: string }>} Project details */ async function resolveProjectV2(projectInfo, projectNumberInt, github) { + // When scope is unknown (owner/number format), try org then user. + if (!projectInfo.scope) { + for (const tryScope of ["orgs", "users"]) { + try { + const project = await resolveProjectV2({ ...projectInfo, scope: tryScope }, projectNumberInt, github); + projectInfo.scope = tryScope; // propagate resolved scope to the caller's object + return project; + } catch (e) { + core.warning(`Project #${projectNumberInt} not found as ${tryScope === "orgs" ? "org" : "user"} "${projectInfo.ownerLogin}"; trying ${tryScope === "orgs" ? "user" : "org"}.`); + } + } + throw new Error(`${ERR_NOT_FOUND}: Project #${projectNumberInt} not found for owner "${projectInfo.ownerLogin}" (tried as org and user). Verify the owner login and project number are correct.`); + } + try { const query = projectInfo.scope === "orgs" @@ -250,6 +274,9 @@ async function resolveProjectV2(projectInfo, projectNumberInt, github) { const project = projectInfo.scope === "orgs" ? direct?.organization?.projectV2 : direct?.user?.projectV2; if (project) return project; + + // If the query succeeded but returned null, fall back to list search + core.warning(`Direct projectV2(number) query returned null; falling back to projectsV2 list search`); } catch (error) { core.warning(`Direct projectV2(number) query failed; falling back to projectsV2 list search: ${getErrorMessage(error)}`); } @@ -476,7 +503,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } let projectId; - core.info(`[2/4] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`); + core.info(`[2/4] Resolving project (scope=${projectInfo.scope ?? "auto"}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`); let resolvedProjectNumber = projectNumberFromUrl; try { diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 52b071eda7c..a88bb34f78f 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -325,12 +325,16 @@ describe("parseProjectInput", () => { expect(parseProjectInput("https://github.com/orgs/acme/projects/42")).toBe("42"); }); - it("rejects a numeric string", () => { - expect(() => parseProjectInput("17")).toThrow(/full GitHub project URL/); + it("extracts the project number from owner/number format", () => { + expect(parseProjectInput("myorg/42")).toBe("42"); + }); + + it("rejects a bare numeric string", () => { + expect(() => parseProjectInput("17")).toThrow(/Invalid project reference/); }); it("rejects a project name", () => { - expect(() => parseProjectInput("Engineering Roadmap")).toThrow(/full GitHub project URL/); + expect(() => parseProjectInput("Engineering Roadmap")).toThrow(/Invalid project reference/); }); it("throws when the project input is missing", () => { @@ -1167,7 +1171,7 @@ describe("updateProject", () => { it("rejects non-URL project identifier", async () => { const output = { type: "update_project", project: "Engineering Roadmap" }; - await expect(updateProject(output)).rejects.toThrow(/full GitHub project URL/); + await expect(updateProject(output)).rejects.toThrow(/Invalid project reference/); }); it("correctly identifies DATE fields and uses date format (not singleSelectOptionId)", async () => { @@ -1937,3 +1941,76 @@ describe("update_project temporary project ID resolution", () => { expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID")); }); }); + +describe("update_project owner/number format", () => { + let messageHandler; + + beforeEach(async () => { + vi.clearAllMocks(); + messageHandler = await updateProjectHandlerFactory({ max: 100 }); + }); + + it("resolves an org project via owner/number shorthand", async () => { + const projectUrl = "https://github.com/orgs/myorg/projects/42"; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 42, "project-owner-num", "myorg"), issueResponse("issue-id-1"), existingItemResponse("issue-id-1", "item-owner-num"), fieldsResponse([])]); + + const message = { + type: "update_project", + project: "myorg/42", + content_type: "issue", + content_number: 1, + }; + + const result = await messageHandler(message, {}, new Map()); + + expect(result.success).toBe(true); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("scope=auto")); + }); + + it("resolves a user project via owner/number shorthand (org fails, user succeeds)", async () => { + const projectUrl = "https://github.com/users/myuser/projects/5"; + + mockGithub.graphql + .mockResolvedValueOnce(repoResponse()) // repo info + .mockResolvedValueOnce(viewerResponse()) // viewer + .mockRejectedValueOnce(new Error("Could not resolve to an Organization")) // org direct query fails + .mockRejectedValueOnce(new Error("org list also failed")) // org fallback list fails → auto-scope moves to user + .mockResolvedValueOnce(userProjectV2Response(projectUrl, 5, "project-user-owner-num", "myuser")) // user query succeeds + .mockResolvedValueOnce(issueResponse("issue-id-2")) // issue lookup + .mockResolvedValueOnce(existingItemResponse("issue-id-2", "item-user-owner-num")) // existing item + .mockResolvedValueOnce(fieldsResponse([])); // fields + + const message = { + type: "update_project", + project: "myuser/5", + content_type: "issue", + content_number: 2, + }; + + const result = await messageHandler(message, {}, new Map()); + + expect(result.success).toBe(true); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('not found as org "myuser"')); + }); + + it("fails gracefully when owner/number project not found as org or user", async () => { + mockGithub.graphql + .mockResolvedValueOnce(repoResponse()) // repo info + .mockResolvedValueOnce(viewerResponse()) // viewer + .mockRejectedValueOnce(new Error("org not found")) // org query fails + .mockRejectedValueOnce(new Error("user not found")); // user query fails + + const message = { + type: "update_project", + project: "unknownowner/99", + content_type: "issue", + content_number: 1, + }; + + const result = await messageHandler(message, {}, new Map()); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/not found for owner.*unknownowner/); + }); +}); diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index f46d5d02c47..346983b12ec 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -509,10 +509,10 @@ Manages GitHub Projects boards. Requires PAT or GitHub App token ([`GH_AW_PROJEC ```yaml wrap safe-outputs: update-project: - project: "https://github.com/orgs/myorg/projects/42" # required: target project URL - max: 20 # max operations (default: 10) + project: "myorg/42" # required: target project (owner/number shorthand) + max: 20 # max operations (default: 10) github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} - views: # optional: auto-create views + views: # optional: auto-create views - name: "Sprint Board" layout: board filter: "is:issue is:open" @@ -524,12 +524,24 @@ safe-outputs: **Configuration options:** -- `project` (required in configuration): Default project URL shown in examples. Note: Agent output messages **must** explicitly include the `project` field - the configured value is for documentation purposes only. +- `project` (required in configuration): Default project reference shown in examples. Note: Agent output messages **must** explicitly include the `project` field - the configured value is for documentation purposes only. - `max`: Maximum number of operations per run (default: 10). - `github-token`: Custom token with Projects permissions (required for Projects v2 access). - `views`: Optional array of project views to create automatically. - Exposes outputs: `project-id`, `project-number`, `project-url`, `item-id`. +#### Project Reference Formats + +The `project` field in agent output messages accepts three formats: + +| Format | Example | Description | +|--------|---------|-------------| +| `owner/number` | `myorg/42` | Shorthand: org or user login, slash, project number. Org is tried first, then user. | +| Full URL | `https://github.com/orgs/myorg/projects/42` | Explicit full GitHub Projects URL. | +| Temporary ID | `aw_abc1` or `#aw_abc1` | Reference a project created earlier in the same run via `create_project`. | + +Unlike issues or pull requests, projects are scoped to an **org or user** (not a repository). The `owner/number` shorthand provides all necessary information without requiring a full URL. + #### Supported Field Types GitHub Projects V2 supports various custom field types. The following field types are automatically detected and handled: @@ -607,14 +619,14 @@ Creates status updates on GitHub Projects boards to communicate progress, findin ```yaml wrap safe-outputs: create-project-status-update: - project: "https://github.com/orgs/myorg/projects/73" # required: target project URL - max: 1 # max updates per run (default: 1) + project: "myorg/73" # required: target project (owner/number shorthand) + max: 1 # max updates per run (default: 1) github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} ``` **Configuration options:** -- `project` (required in configuration): Default project URL shown in examples. Note: Agent output messages **must** explicitly include the `project` field - the configured value is for documentation purposes only. +- `project` (required in configuration): Default project reference shown in examples. Note: Agent output messages **must** explicitly include the `project` field - the configured value is for documentation purposes only. - `max`: Maximum number of status updates per run (default: 1). - `github-token`: Custom token with Projects permissions (required for Projects v2 access). - Often used by scheduled workflows and orchestrator workflows to post run summaries. @@ -623,7 +635,7 @@ safe-outputs: | Field | Type | Description | |-------|------|-------------| -| `project` | URL | Full GitHub project URL (e.g., `https://github.com/orgs/myorg/projects/73`). **Required** in every agent output message. | +| `project` | string | Project reference: `owner/number` (e.g., `myorg/73`), full URL, or temporary ID from `create_project`. **Required** in every agent output message. | | `body` | Markdown | Status update content with summary, findings, and next steps | #### Optional Fields @@ -638,7 +650,7 @@ safe-outputs: ```yaml create-project-status-update: - project: "https://github.com/orgs/myorg/projects/73" + project: "myorg/73" status: "ON_TRACK" start_date: "2026-01-06" target_date: "2026-01-31" @@ -665,6 +677,17 @@ create-project-status-update: - Target 95% documentation coverage by end of month ``` +#### Chaining with `create_project` + +You can chain `create_project` and `create_project_status_update` in a single workflow run using temporary IDs: + +```json +{"type": "create_project", "temporary_id": "aw_myproj", "title": "My Project"} +{"type": "create_project_status_update", "project": "aw_myproj", "body": "Project created!"} +``` + +The `create_project_status_update` handler resolves `aw_myproj` to the actual project URL created earlier in the same run. + #### Status Indicators - **`ON_TRACK`**: Progressing as planned, meeting expected targets From 042142a75d4fd12c82b1e3c295f3e1f7df1471bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:31:32 +0000 Subject: [PATCH 3/3] feat: use separate owner and number params instead of owner/number combined string Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- .../setup/js/create_project_status_update.cjs | 91 ++++++++++------- .../js/create_project_status_update.test.cjs | 18 ++-- actions/setup/js/safe_outputs_tools.json | 24 ++++- actions/setup/js/update_project.cjs | 98 +++++++++++-------- actions/setup/js/update_project.test.cjs | 29 +++--- .../content/docs/reference/safe-outputs.md | 23 +++-- 6 files changed, 166 insertions(+), 117 deletions(-) diff --git a/actions/setup/js/create_project_status_update.cjs b/actions/setup/js/create_project_status_update.cjs index 3260009ce85..ee447bbdcf3 100644 --- a/actions/setup/js/create_project_status_update.cjs +++ b/actions/setup/js/create_project_status_update.cjs @@ -53,15 +53,13 @@ function logGraphQLError(error, operation) { } /** - * Parse project URL or owner/number format into components. - * When the owner/number format is used, `scope` is set to `null` and is resolved - * automatically during project lookup (org tried first, then user). - * @param {unknown} projectUrl - Project URL or owner/number (e.g., 'myorg/42') - * @returns {{ scope: string | null, ownerLogin: string, projectNumber: string }} Project info + * Parse project URL into components. + * @param {unknown} projectUrl - Full GitHub project URL + * @returns {{ scope: string, ownerLogin: string, projectNumber: string }} Project info */ function parseProjectUrl(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL or owner/number format.`); + throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } const urlMatch = projectUrl.match(/^https?:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); @@ -69,17 +67,33 @@ function parseProjectUrl(projectUrl) { return { scope: urlMatch[1], ownerLogin: urlMatch[2], projectNumber: urlMatch[3] }; } - const ownerNumberMatch = projectUrl.match(/^([A-Za-z0-9][A-Za-z0-9\-]*)\/(\d+)$/); - if (ownerNumberMatch) { - return { scope: null, ownerLogin: ownerNumberMatch[1], projectNumber: ownerNumberMatch[2] }; - } + throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); +} - throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123) or owner/number format (e.g., myorg/42).`); +/** + * Resolve project info from an output message. + * Accepts either a full GitHub project URL in `output.project`, or separate + * `output.owner` (string) and `output.number` (number) fields. + * When owner/number are used, scope is resolved automatically at lookup time. + * @param {any} output - Output message with project reference + * @returns {{ scope: string | null, ownerLogin: string, projectNumber: string }} Project info + */ +function resolveProjectInfo(output) { + if (output.owner != null && output.number != null) { + if (typeof output.owner !== "string") throw new Error(`${ERR_VALIDATION}: The "owner" field must be a string, got ${typeof output.owner}.`); + if (typeof output.number !== "number" && typeof output.number !== "string") throw new Error(`${ERR_VALIDATION}: The "number" field must be a number or string, got ${typeof output.number}.`); + const ownerLogin = output.owner.trim(); + const projectNumber = String(output.number).trim(); + if (!ownerLogin) throw new Error(`${ERR_VALIDATION}: The "owner" field must be a non-empty string.`); + if (!/^\d+$/.test(projectNumber)) throw new Error(`${ERR_VALIDATION}: The "number" field must be a positive integer.`); + return { scope: null, ownerLogin, projectNumber }; + } + return parseProjectUrl(output.project); } /** * List accessible Projects v2 for org or user - * @param {{ scope: string, ownerLogin: string, projectNumber: string }} projectInfo - Project info + * @param {{ scope: string | null, ownerLogin: string, projectNumber: string }} projectInfo - Project info * @returns {Promise<{ nodes: Array<{ id: string, number: number, title: string, closed?: boolean, url: string }>, totalCount?: number, diagnostics: { rawNodesCount: number, nullNodesCount: number, rawEdgesCount: number, nullEdgeNodesCount: number } }>} List result */ async function listAccessibleProjectsV2(projectInfo) { @@ -347,32 +361,35 @@ async function main(config = {}, githubClient = null) { const output = message; // Validate that project field is explicitly provided in the message - // The project field can be a full URL, owner/number format, or a temporary project ID + // The project field can be a full URL or a temporary project ID, or separate owner/number fields let effectiveProjectUrl = output.project; + const hasOwnerNumber = output.owner != null && output.number != null; - if (!effectiveProjectUrl || typeof effectiveProjectUrl !== "string" || effectiveProjectUrl.trim() === "") { - core.error('Missing required "project" field. The agent must explicitly include the project in the output message: {"type": "create_project_status_update", "project": "myorg/42", "body": "..."}'); + if ((!effectiveProjectUrl || typeof effectiveProjectUrl !== "string" || effectiveProjectUrl.trim() === "") && !hasOwnerNumber) { + core.error('Missing project reference. The agent must include either a "project" URL or separate "owner" and "number" fields: {"type": "create_project_status_update", "owner": "myorg", "number": 42, "body": "..."}'); return { success: false, - error: "Missing required field: project", + error: "Missing required field: project (or owner + number)", }; } - // 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); - const resolved = temporaryIdMap instanceof Map ? temporaryIdMap.get(normalizedId) : undefined; - if (resolved && typeof resolved === "object" && "projectUrl" in resolved && resolved.projectUrl) { - core.info(`Resolved temporary project ID ${projectStr} to ${resolved.projectUrl}`); - effectiveProjectUrl = resolved.projectUrl; - } else { - core.error(`Temporary project ID '${projectStr}' not found. Ensure create_project was called before create_project_status_update.`); - return { - success: false, - error: `Temporary project ID '${projectStr}' not found`, - }; + // Resolve temporary project ID if present in project field + if (effectiveProjectUrl && typeof effectiveProjectUrl === "string") { + const projectStr = effectiveProjectUrl.trim(); + const projectWithoutHash = projectStr.startsWith("#") ? projectStr.substring(1) : projectStr; + if (isTemporaryId(projectWithoutHash)) { + const normalizedId = normalizeTemporaryId(projectWithoutHash); + const resolved = temporaryIdMap instanceof Map ? temporaryIdMap.get(normalizedId) : undefined; + if (resolved && typeof resolved === "object" && "projectUrl" in resolved && resolved.projectUrl) { + core.info(`Resolved temporary project ID ${projectStr} to ${resolved.projectUrl}`); + effectiveProjectUrl = resolved.projectUrl; + } else { + core.error(`Temporary project ID '${projectStr}' not found. Ensure create_project was called before create_project_status_update.`); + return { + success: false, + error: `Temporary project ID '${projectStr}' not found`, + }; + } } } @@ -385,14 +402,16 @@ async function main(config = {}, githubClient = null) { } try { - core.info(`Creating status update for project: ${effectiveProjectUrl}`); + const effectiveOutput = effectiveProjectUrl ? { ...output, project: effectiveProjectUrl } : output; + const projectRef = effectiveProjectUrl || `${output.owner}/${output.number}`; + core.info(`Creating status update for project: ${projectRef}`); - // Parse project URL and resolve project ID - const projectInfo = parseProjectUrl(effectiveProjectUrl); + // Resolve project info from either URL or owner/number params + const projectInfo = resolveProjectInfo(effectiveOutput); const projectNumberInt = parseInt(projectInfo.projectNumber, 10); if (!Number.isFinite(projectNumberInt)) { - throw new Error(`${ERR_PARSE}: Invalid project number parsed from URL: ${projectInfo.projectNumber}`); + throw new Error(`${ERR_PARSE}: Invalid project number: ${projectInfo.projectNumber}`); } const project = await resolveProjectV2(projectInfo, projectNumberInt); @@ -411,7 +430,7 @@ async function main(config = {}, githubClient = null) { // If in staged mode, preview without executing if (isStaged) { - logStagedPreviewInfo(`Would create status update for project ${effectiveProjectUrl}`); + logStagedPreviewInfo(`Would create status update for project ${projectRef}`); return { success: true, staged: true, diff --git a/actions/setup/js/create_project_status_update.test.cjs b/actions/setup/js/create_project_status_update.test.cjs index 389892e00b2..f6a19204643 100644 --- a/actions/setup/js/create_project_status_update.test.cjs +++ b/actions/setup/js/create_project_status_update.test.cjs @@ -97,7 +97,7 @@ describe("create_project_status_update", () => { ); expect(result.success).toBe(false); - expect(result.error).toBe("Missing required field: project"); + expect(result.error).toContain("Missing required field: project"); expect(mockCore.error).toHaveBeenCalled(); }); @@ -542,8 +542,8 @@ describe("create_project_status_update", () => { const result = await handler(messageWithoutProject, {}); expect(result.success).toBe(false); - expect(result.error).toBe("Missing required field: project"); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining('Missing required "project" field')); + expect(result.error).toContain("Missing required field: project"); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Missing project reference")); // Cleanup delete process.env.GH_AW_PROJECT_URL; @@ -607,7 +607,7 @@ describe("create_project_status_update owner/number format", () => { vi.clearAllMocks(); }); - it("resolves an org project via owner/number shorthand", async () => { + it("resolves an org project via separate owner and number params", async () => { const projectUrl = "https://github.com/orgs/myorg/projects/42"; mockGithub.graphql @@ -625,7 +625,7 @@ describe("create_project_status_update owner/number format", () => { createProjectV2StatusUpdate: { statusUpdate: { id: "PVTSU_ownernum1", - body: "Status via owner/number", + body: "Status via separate owner/number", bodyHTML: "

Status

", startDate: "2025-01-01", targetDate: "2025-12-31", @@ -636,13 +636,13 @@ describe("create_project_status_update owner/number format", () => { }); const handler = await main({ max: 10 }); - const result = await handler({ project: "myorg/42", body: "Status via owner/number" }, new Map()); + const result = await handler({ owner: "myorg", number: 42, body: "Status via separate owner/number" }, new Map()); expect(result.success).toBe(true); expect(result.status_update_id).toBe("PVTSU_ownernum1"); }); - it("resolves a user project via owner/number shorthand (org fails, user succeeds)", async () => { + it("resolves a user project via separate owner and number params (org fails, user succeeds)", async () => { const projectUrl = "https://github.com/users/myuser/projects/5"; mockGithub.graphql @@ -662,7 +662,7 @@ describe("create_project_status_update owner/number format", () => { createProjectV2StatusUpdate: { statusUpdate: { id: "PVTSU_usernum1", - body: "Status via user owner/number", + body: "Status via separate user owner/number", bodyHTML: "

Status

", startDate: "2025-01-01", targetDate: "2025-12-31", @@ -673,7 +673,7 @@ describe("create_project_status_update owner/number format", () => { }); const handler = await main({ max: 10 }); - const result = await handler({ project: "myuser/5", body: "Status via user owner/number" }, new Map()); + const result = await handler({ owner: "myuser", number: 5, body: "Status via separate user owner/number" }, new Map()); expect(result.success).toBe(true); expect(result.status_update_id).toBe("PVTSU_usernum1"); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 98ccfe11b4b..4c16f02418b 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -734,8 +734,8 @@ "properties": { "project": { "type": "string", - "pattern": "^(https?://github\\.com/(orgs|users)/[^/]+/projects/\\d+|[A-Za-z0-9][A-Za-z0-9\\-]*/\\d+|#?aw_[A-Za-z0-9]{3,8})$", - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42'), owner/number shorthand (e.g., 'myorg/42'), or a temporary project ID from a recent create_project call (e.g., '#aw_abc1', 'aw_Test123')." + "pattern": "^(https?://github\\.com/(orgs|users)/[^/]+/projects/\\d+|#?aw_[A-Za-z0-9]{3,8})$", + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42') or a temporary project ID from a recent create_project call (e.g., '#aw_abc1', 'aw_Test123'). Use 'owner' and 'number' fields instead to reference a project by org/user and number." }, "operation": { "type": "string", @@ -828,6 +828,14 @@ "create_if_missing": { "type": "boolean", "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true." + }, + "owner": { + "type": "string", + "description": "Login name of the organization or user that owns the project (e.g., 'myorg' or 'username'). Required together with 'number' when not using a full project URL." + }, + "number": { + "type": "integer", + "description": "Project number (e.g., 42 for github.com/orgs/myorg/projects/42). Required together with 'owner' when not using a full project URL." } }, "additionalProperties": false @@ -903,8 +911,8 @@ "properties": { "project": { "type": "string", - "pattern": "^(https?://github\\.com/(orgs|users)/[^/]+/projects/\\d+|[A-Za-z0-9][A-Za-z0-9\\-]*/\\d+|#?aw_[A-Za-z0-9]{3,8})$", - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42'), owner/number shorthand (e.g., 'myorg/42'), or a temporary project ID from a recent create_project call (e.g., '#aw_abc1', 'aw_Test123')." + "pattern": "^(https?://github\\.com/(orgs|users)/[^/]+/projects/\\d+|#?aw_[A-Za-z0-9]{3,8})$", + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42') or a temporary project ID from a recent create_project call (e.g., '#aw_abc1', 'aw_Test123'). Use 'owner' and 'number' fields instead to reference a project by org/user and number." }, "status": { "type": "string", @@ -924,6 +932,14 @@ "body": { "type": "string", "description": "Status update body in markdown format describing progress, findings, trends, and next steps. Should provide stakeholders with clear understanding of project state." + }, + "owner": { + "type": "string", + "description": "Login name of the organization or user that owns the project (e.g., 'myorg' or 'username'). Required together with 'number' when not using a full project URL." + }, + "number": { + "type": "integer", + "description": "Project number (e.g., 42 for github.com/orgs/myorg/projects/42). Required together with 'owner' when not using a full project URL." } }, "additionalProperties": false diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index d7851200614..7b866eaa39e 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -77,7 +77,7 @@ function logGraphQLError(error, operation) { */ function parseProjectInput(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL or owner/number format.`); + throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } const urlMatch = projectUrl.match(/^https?:\/\/[^/]+\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); @@ -85,24 +85,17 @@ function parseProjectInput(projectUrl) { return urlMatch[1]; } - const ownerNumberMatch = projectUrl.match(/^[A-Za-z0-9][A-Za-z0-9\-]*\/(\d+)$/); - if (ownerNumberMatch) { - return ownerNumberMatch[1]; - } - - throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123) or owner/number format (e.g., myorg/42).`); + throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); } /** - * Parse project URL or owner/number format into components. - * When the owner/number format is used, `scope` is set to `null` and is resolved - * automatically during project lookup (org tried first, then user). - * @param {unknown} projectUrl - Project URL or owner/number (e.g., 'myorg/42') - * @returns {{ scope: string | null, ownerLogin: string, projectNumber: string }} Project info + * Parse project URL into components. + * @param {unknown} projectUrl - Project URL + * @returns {{ scope: string, ownerLogin: string, projectNumber: string }} Project info */ function parseProjectUrl(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL or owner/number format.`); + throw new Error(`${ERR_VALIDATION}: Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } const urlMatch = projectUrl.match(/^https?:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); @@ -110,16 +103,32 @@ function parseProjectUrl(projectUrl) { return { scope: urlMatch[1], ownerLogin: urlMatch[2], projectNumber: urlMatch[3] }; } - const ownerNumberMatch = projectUrl.match(/^([A-Za-z0-9][A-Za-z0-9\-]*)\/(\d+)$/); - if (ownerNumberMatch) { - return { scope: null, ownerLogin: ownerNumberMatch[1], projectNumber: ownerNumberMatch[2] }; - } + throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); +} - throw new Error(`${ERR_VALIDATION}: Invalid project reference: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123) or owner/number format (e.g., myorg/42).`); +/** + * Resolve project info from an output message. + * Accepts either a full GitHub project URL in `output.project`, or separate + * `output.owner` (string) and `output.number` (number) fields. + * When owner/number are used, scope is resolved automatically at lookup time. + * @param {any} output - Output message with project reference + * @returns {{ scope: string | null, ownerLogin: string, projectNumber: string }} Project info + */ +function resolveProjectInfo(output) { + if (output.owner != null && output.number != null) { + if (typeof output.owner !== "string") throw new Error(`${ERR_VALIDATION}: The "owner" field must be a string, got ${typeof output.owner}.`); + if (typeof output.number !== "number" && typeof output.number !== "string") throw new Error(`${ERR_VALIDATION}: The "number" field must be a number or string, got ${typeof output.number}.`); + const ownerLogin = output.owner.trim(); + const projectNumber = String(output.number).trim(); + if (!ownerLogin) throw new Error(`${ERR_VALIDATION}: The "owner" field must be a non-empty string.`); + if (!/^\d+$/.test(projectNumber)) throw new Error(`${ERR_VALIDATION}: The "number" field must be a positive integer.`); + return { scope: null, ownerLogin, projectNumber }; + } + return parseProjectUrl(output.project); } /** * List accessible Projects v2 for org or user - * @param {{ scope: string, ownerLogin: string, projectNumber: string }} projectInfo - Project info + * @param {{ scope: string | null, ownerLogin: string, projectNumber: string }} projectInfo - Project info * @param {Object} github - GitHub client (Octokit instance) to use for GraphQL queries * @returns {Promise<{ nodes: Array<{ id: string, number: number, title: string, closed?: boolean, url: string }>, totalCount?: number, diagnostics: { rawNodesCount: number, nullNodesCount: number, rawEdgesCount: number, nullEdgeNodesCount: number } }>} List result */ @@ -432,7 +441,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = throw new Error(`${ERR_CONFIG}: GitHub client is required but not provided. Either pass a github client to updateProject() or ensure global.github is set.`); } const { owner, repo } = context.repo; - const projectInfo = parseProjectUrl(output.project); + const projectInfo = resolveProjectInfo(output); const projectNumberFromUrl = projectInfo.projectNumber; const wantsCreateView = @@ -449,7 +458,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = const wantsCreateFields = output?.operation === "create_fields"; try { - core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`); + core.info(`Looking up project #${projectNumberFromUrl} for owner "${projectInfo.ownerLogin}"`); core.info("[1/4] Fetching repository information..."); let repoResult; @@ -1198,7 +1207,8 @@ async function main(config = {}, githubClient = null) { // Track state let processedCount = 0; - let firstProjectUrl = null; + /** @type {{ project?: string, owner?: any, number?: any } | null} */ + let firstProjectRef = null; let viewsCreated = false; let fieldsCreated = false; @@ -1225,22 +1235,22 @@ async function main(config = {}, githubClient = null) { try { // Validate that project field is explicitly provided in the message - // The project field is required in agent output messages and must be a full GitHub project URL + // The project field is required in agent output messages and must be a full GitHub project URL, + // or separate owner/number parameters must be provided. let effectiveProjectUrl = message.project; + const hasOwnerNumber = message.owner != null && message.number != null; - if (!effectiveProjectUrl || typeof effectiveProjectUrl !== "string" || effectiveProjectUrl.trim() === "") { - const errorMsg = 'Missing required "project" field. The agent must explicitly include the project URL in the output message.'; + if ((!effectiveProjectUrl || typeof effectiveProjectUrl !== "string" || effectiveProjectUrl.trim() === "") && !hasOwnerNumber) { + const errorMsg = 'Missing project reference. The agent must include either a "project" URL or separate "owner" and "number" fields in the output message.'; core.error(errorMsg); // 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", "owner": "myorg", "number": 42, "content_type": "draft_issue", "draft_title": "...", "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": {...}}` - ); + core.error(`For ${message.content_type} content_type, you must include: {"type": "update_project", "owner": "myorg", "number": 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", "owner": "myorg", "number": 42, "content_type": "draft_issue", "draft_title": "Task Title", "fields": {"Status": "Todo"}}'); } return { @@ -1252,7 +1262,7 @@ async function main(config = {}, githubClient = null) { // Validation passed - increment processed count processedCount++; - // Resolve temporary project ID if present + // Resolve temporary project ID if present in project field if (effectiveProjectUrl && typeof effectiveProjectUrl === "string") { // Strip # prefix if present const projectStr = effectiveProjectUrl.trim(); @@ -1272,8 +1282,8 @@ async function main(config = {}, githubClient = null) { } } - // Create effective message with resolved project URL - const resolvedMessage = { ...message, project: effectiveProjectUrl }; + // Create effective message with resolved project URL (or owner/number params) + const resolvedMessage = effectiveProjectUrl ? { ...message, project: effectiveProjectUrl } : { ...message }; const hasContentNumber = resolvedMessage.content_number !== undefined && resolvedMessage.content_number !== null && String(resolvedMessage.content_number).trim() !== ""; const hasIssue = resolvedMessage.issue !== undefined && resolvedMessage.issue !== null && String(resolvedMessage.issue).trim() !== ""; @@ -1292,22 +1302,23 @@ async function main(config = {}, githubClient = null) { } } - // Store the first project URL for view creation - if (!firstProjectUrl && effectiveProjectUrl) { - firstProjectUrl = effectiveProjectUrl; + // Store the first project ref for view/field creation + if (!firstProjectRef) { + firstProjectRef = effectiveProjectUrl ? { project: effectiveProjectUrl } : { owner: message.owner, number: message.number }; } // Create configured fields once before processing the first message // This ensures configured fields exist even if the agent doesn't explicitly emit operation=create_fields. - if (!fieldsCreated && configuredFieldDefinitions.length > 0 && firstProjectUrl) { + if (!fieldsCreated && configuredFieldDefinitions.length > 0 && firstProjectRef) { const operation = typeof resolvedMessage?.operation === "string" ? resolvedMessage.operation : ""; if (operation !== "create_fields") { fieldsCreated = true; - core.info(`Creating ${configuredFieldDefinitions.length} configured field(s) on project: ${firstProjectUrl}`); + const refDisplay = firstProjectRef.project || `${firstProjectRef.owner}/${firstProjectRef.number}`; + core.info(`Creating ${configuredFieldDefinitions.length} configured field(s) on project: ${refDisplay}`); const fieldsOutput = { type: "update_project", - project: firstProjectUrl, + ...firstProjectRef, operation: "create_fields", field_definitions: configuredFieldDefinitions, }; @@ -1333,7 +1344,7 @@ async function main(config = {}, githubClient = null) { // If in staged mode, preview without executing if (isStaged) { const operation = effectiveMessage?.operation || "update"; - logStagedPreviewInfo(`Would ${operation} project ${effectiveProjectUrl}`); + logStagedPreviewInfo(`Would ${operation} project ${effectiveProjectUrl || `${message.owner}/${message.number}`}`); return { success: true, staged: true, @@ -1349,9 +1360,10 @@ async function main(config = {}, githubClient = null) { // After processing the first message, create configured views if any // Views are created after the first item is processed to ensure the project exists - if (!viewsCreated && configuredViews.length > 0 && firstProjectUrl) { + if (!viewsCreated && configuredViews.length > 0 && firstProjectRef) { viewsCreated = true; - core.info(`Creating ${configuredViews.length} configured view(s) on project: ${firstProjectUrl}`); + const refDisplay = firstProjectRef.project || `${firstProjectRef.owner}/${firstProjectRef.number}`; + core.info(`Creating ${configuredViews.length} configured view(s) on project: ${refDisplay}`); for (let i = 0; i < configuredViews.length; i++) { const viewConfig = configuredViews[i]; @@ -1359,7 +1371,7 @@ async function main(config = {}, githubClient = null) { // Create a synthetic output item for view creation const viewOutput = { type: "update_project", - project: firstProjectUrl, + ...firstProjectRef, operation: "create_view", view: { name: viewConfig.name, diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index a88bb34f78f..5d0dc239967 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -325,10 +325,6 @@ describe("parseProjectInput", () => { expect(parseProjectInput("https://github.com/orgs/acme/projects/42")).toBe("42"); }); - it("extracts the project number from owner/number format", () => { - expect(parseProjectInput("myorg/42")).toBe("42"); - }); - it("rejects a bare numeric string", () => { expect(() => parseProjectInput("17")).toThrow(/Invalid project reference/); }); @@ -1676,8 +1672,8 @@ describe("updateProject", () => { const result = await messageHandler(messageWithoutProject, new Map()); expect(result.success).toBe(false); - expect(result.error).toContain('Missing required "project" field'); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Missing required")); + expect(result.error).toContain("Missing project reference"); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Missing project reference")); }); it("should reject update_project message with empty project field", async () => { @@ -1696,8 +1692,8 @@ describe("updateProject", () => { const result = await messageHandler(messageWithEmptyProject, new Map()); expect(result.success).toBe(false); - expect(result.error).toContain('Missing required "project" field'); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Missing required")); + expect(result.error).toContain("Missing project reference"); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Missing project reference")); }); it("should fail when project field is missing even if GH_AW_PROJECT_URL is set", async () => { @@ -1717,8 +1713,8 @@ describe("updateProject", () => { const result = await messageHandler(messageWithoutProject, new Map()); expect(result.success).toBe(false); - expect(result.error).toBe('Missing required "project" field. The agent must explicitly include the project URL in the output message.'); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining('Missing required "project" field')); + expect(result.error).toContain("Missing project reference"); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Missing project reference")); // Cleanup delete process.env.GH_AW_PROJECT_URL; @@ -1950,14 +1946,15 @@ describe("update_project owner/number format", () => { messageHandler = await updateProjectHandlerFactory({ max: 100 }); }); - it("resolves an org project via owner/number shorthand", async () => { + it("resolves an org project via separate owner and number params", async () => { const projectUrl = "https://github.com/orgs/myorg/projects/42"; queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 42, "project-owner-num", "myorg"), issueResponse("issue-id-1"), existingItemResponse("issue-id-1", "item-owner-num"), fieldsResponse([])]); const message = { type: "update_project", - project: "myorg/42", + owner: "myorg", + number: 42, content_type: "issue", content_number: 1, }; @@ -1968,7 +1965,7 @@ describe("update_project owner/number format", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("scope=auto")); }); - it("resolves a user project via owner/number shorthand (org fails, user succeeds)", async () => { + it("resolves a user project via separate owner and number params (org fails, user succeeds)", async () => { const projectUrl = "https://github.com/users/myuser/projects/5"; mockGithub.graphql @@ -1983,7 +1980,8 @@ describe("update_project owner/number format", () => { const message = { type: "update_project", - project: "myuser/5", + owner: "myuser", + number: 5, content_type: "issue", content_number: 2, }; @@ -2003,7 +2001,8 @@ describe("update_project owner/number format", () => { const message = { type: "update_project", - project: "unknownowner/99", + owner: "unknownowner", + number: 99, content_type: "issue", content_number: 1, }; diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 346983b12ec..7894512e728 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -509,7 +509,7 @@ Manages GitHub Projects boards. Requires PAT or GitHub App token ([`GH_AW_PROJEC ```yaml wrap safe-outputs: update-project: - project: "myorg/42" # required: target project (owner/number shorthand) + project: "https://github.com/orgs/myorg/projects/42" # or use separate owner/number params max: 20 # max operations (default: 10) github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} views: # optional: auto-create views @@ -524,7 +524,7 @@ safe-outputs: **Configuration options:** -- `project` (required in configuration): Default project reference shown in examples. Note: Agent output messages **must** explicitly include the `project` field - the configured value is for documentation purposes only. +- `project` (required in configuration): Default project reference shown in examples. Note: Agent output messages **must** explicitly include either the `project` field or separate `owner` and `number` fields - the configured value is for documentation purposes only. - `max`: Maximum number of operations per run (default: 10). - `github-token`: Custom token with Projects permissions (required for Projects v2 access). - `views`: Optional array of project views to create automatically. @@ -532,15 +532,15 @@ safe-outputs: #### Project Reference Formats -The `project` field in agent output messages accepts three formats: +Agent output messages can reference a project in three ways: | Format | Example | Description | |--------|---------|-------------| -| `owner/number` | `myorg/42` | Shorthand: org or user login, slash, project number. Org is tried first, then user. | -| Full URL | `https://github.com/orgs/myorg/projects/42` | Explicit full GitHub Projects URL. | +| Separate params | `"owner": "myorg", "number": 42` | Separate `owner` and `number` fields. Org is tried first, then user. | +| Full URL | `https://github.com/orgs/myorg/projects/42` | Explicit full GitHub Projects URL via `project` field. | | Temporary ID | `aw_abc1` or `#aw_abc1` | Reference a project created earlier in the same run via `create_project`. | -Unlike issues or pull requests, projects are scoped to an **org or user** (not a repository). The `owner/number` shorthand provides all necessary information without requiring a full URL. +Unlike issues or pull requests, projects are scoped to an **org or user** (not a repository). Use the separate `owner` and `number` fields for a concise reference without a full URL. #### Supported Field Types @@ -619,14 +619,14 @@ Creates status updates on GitHub Projects boards to communicate progress, findin ```yaml wrap safe-outputs: create-project-status-update: - project: "myorg/73" # required: target project (owner/number shorthand) + project: "https://github.com/orgs/myorg/projects/73" # or use separate owner/number params max: 1 # max updates per run (default: 1) github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} ``` **Configuration options:** -- `project` (required in configuration): Default project reference shown in examples. Note: Agent output messages **must** explicitly include the `project` field - the configured value is for documentation purposes only. +- `project` (required in configuration): Default project reference shown in examples. Note: Agent output messages **must** explicitly include either the `project` field or separate `owner` and `number` fields - the configured value is for documentation purposes only. - `max`: Maximum number of status updates per run (default: 1). - `github-token`: Custom token with Projects permissions (required for Projects v2 access). - Often used by scheduled workflows and orchestrator workflows to post run summaries. @@ -635,7 +635,9 @@ safe-outputs: | Field | Type | Description | |-------|------|-------------| -| `project` | string | Project reference: `owner/number` (e.g., `myorg/73`), full URL, or temporary ID from `create_project`. **Required** in every agent output message. | +| `project` | string | Full GitHub project URL or temporary ID from `create_project`. Use `owner` + `number` fields instead for a concise reference. | +| `owner` | string | Login name of the org or user that owns the project (e.g., `myorg`). Required together with `number` when not using a full URL. | +| `number` | integer | Project number (e.g., `73`). Required together with `owner` when not using a full URL. | | `body` | Markdown | Status update content with summary, findings, and next steps | #### Optional Fields @@ -650,7 +652,8 @@ safe-outputs: ```yaml create-project-status-update: - project: "myorg/73" + owner: "myorg" + number: 73 status: "ON_TRACK" start_date: "2026-01-06" target_date: "2026-01-31"