Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions actions/setup/js/update_project.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>|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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
80 changes: 80 additions & 0 deletions actions/setup/js/update_project.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
Loading