diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 0752f951fc1..784ba9e2e28 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -441,6 +441,36 @@ function inferFieldDataType(fieldName, fieldValue, datePattern) { return "SINGLE_SELECT"; } +/** + * Coerce an agent-supplied `fields` value to a plain object, or return null. + * + * Agents occasionally double-encode the value as a JSON string. If that + * happens we parse it transparently. Arrays, null, and other non-object + * types are rejected with a warning so that callers can skip field updates + * without silently iterating over character indices or array elements. + * + * @param {unknown} fields - Raw value from the agent output + * @returns {Record|null} Plain object or null when unusable + */ +function resolveFieldsObject(fields) { + if (fields == null) return null; + if (typeof fields === "string") { + try { + fields = JSON.parse(fields); + } catch { + core.warning("update_project: `fields` was a string and could not be parsed as JSON; skipping field updates"); + return null; + } + // JSON.parse of "null", "1", or a quoted string yields a non-object + if (fields == null) return null; + } + if (Array.isArray(fields) || typeof fields !== "object") { + core.warning("update_project: `fields` must be a JSON object; skipping field updates"); + return null; + } + return Object.fromEntries(Object.entries(fields)); +} + /** * Apply field value updates to a project item, creating fields as needed. * @param {Object} github - GitHub client (Octokit instance) @@ -1105,8 +1135,9 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } } - if (output.fields && Object.keys(output.fields).length > 0) { - await applyFieldUpdates(github, projectId, itemId, output.fields); + const resolvedFields1 = resolveFieldsObject(output.fields); + if (resolvedFields1 && Object.keys(resolvedFields1).length > 0) { + await applyFieldUpdates(github, projectId, itemId, resolvedFields1); } core.setOutput("item-id", itemId); @@ -1195,8 +1226,9 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = ).addProjectV2ItemById.item.id; } - if (output.fields && Object.keys(output.fields).length > 0) { - await applyFieldUpdates(github, projectId, itemId, output.fields); + const resolvedFields2 = resolveFieldsObject(output.fields); + if (resolvedFields2 && Object.keys(resolvedFields2).length > 0) { + await applyFieldUpdates(github, projectId, itemId, resolvedFields2); } core.setOutput("item-id", itemId); diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 7c240a538d2..146bcb080e4 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -1230,6 +1230,86 @@ describe("updateProject", () => { expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Failed to create field "NonExistentField"')); }); + it("parses and applies field updates when fields is a JSON-encoded string (double-encoded)", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 21, + // Agent double-encoded the fields object as a JSON string + fields: '{"Lifecycle":"Sandbox"}', + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-test"), + issueResponse("issue-id-21"), + existingItemResponse("issue-id-21", "item-test"), + fieldsResponse([{ id: "field-lifecycle", name: "Lifecycle", options: [{ id: "opt-sandbox", name: "Sandbox" }] }]), + updateFieldValueResponse(), + ]); + + await updateProject(output); + + // Should NOT create bogus numeric fields (0, 1, 2, …) + const createFieldCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("createProjectV2Field")); + expect(createFieldCall).toBeUndefined(); + + // Should have applied the parsed field update + const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue")); + expect(updateCall).toBeDefined(); + }); + + it("warns and skips field updates when fields is an invalid JSON string", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 22, + fields: "not-valid-json", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-test"), issueResponse("issue-id-22"), existingItemResponse("issue-id-22", "item-test")]); + + await updateProject(output); + + // Should warn about the bad string value + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("`fields` was a string and could not be parsed as JSON")); + + // Should NOT attempt any field GraphQL operations + const createFieldCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("createProjectV2Field")); + expect(createFieldCall).toBeUndefined(); + const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue")); + expect(updateCall).toBeUndefined(); + }); + + it("warns and skips field updates when fields is an array", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 23, + fields: ["Status", "In Progress"], + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-test"), issueResponse("issue-id-23"), existingItemResponse("issue-id-23", "item-test")]); + + await updateProject(output); + + // Should warn about the non-object value + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("`fields` must be a JSON object")); + + // Should NOT attempt any field GraphQL operations + const createFieldCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("createProjectV2Field")); + expect(createFieldCall).toBeUndefined(); + const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue")); + expect(updateCall).toBeUndefined(); + }); + 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/);