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
56 changes: 53 additions & 3 deletions actions/setup/js/update_project.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ async function updateProject(output) {
if (Object.keys(fieldsToUpdate).length > 0) {
const projectFields = (
await github.graphql(
"query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }",
"query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2DateField {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n ... on ProjectV2IterationField {\n id\n name\n dataType\n configuration {\n iterations {\n id\n title\n startDate\n duration\n }\n }\n }\n }\n }\n }\n }\n }",
{ projectId }
)
).node.fields.nodes;
Expand Down Expand Up @@ -411,7 +411,32 @@ async function updateProject(output) {
continue;
}
if (field.dataType === "DATE") valueToSet = { date: String(fieldValue) };
else if (field.options) {
else if (field.dataType === "NUMBER") {
// NUMBER fields use ProjectV2FieldValue input type with number property
// The number value must be a valid float or integer
// Convert string values to numbers if needed
const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue));
if (isNaN(numValue)) {
core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`);
continue;
}
valueToSet = { number: numValue };
} else if (field.dataType === "ITERATION") {
// ITERATION fields use ProjectV2FieldValue input type with iterationId property
// The value should match an iteration title or ID
if (!field.configuration || !field.configuration.iterations) {
core.warning(`Iteration field "${fieldName}" has no configured iterations`);
continue;
}
// Try to find iteration by title (case-insensitive match)
const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase());
if (!iteration) {
const availableIterations = field.configuration.iterations.map(i => i.title).join(", ");
core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`);
continue;
}
valueToSet = { iterationId: iteration.id };
} else if (field.options) {
let option = field.options.find(o => o.name === fieldValue);
if (!option)
try {
Expand Down Expand Up @@ -496,7 +521,7 @@ async function updateProject(output) {
if (Object.keys(fieldsToUpdate).length > 0) {
const projectFields = (
await github.graphql(
"query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }",
"query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2DateField {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n ... on ProjectV2IterationField {\n id\n name\n dataType\n configuration {\n iterations {\n id\n title\n startDate\n duration\n }\n }\n }\n }\n }\n }\n }\n }",
{ projectId }
)
).node.fields.nodes;
Expand Down Expand Up @@ -539,6 +564,31 @@ async function updateProject(output) {
// The date value must be in ISO 8601 format (YYYY-MM-DD) with no time component
// Unlike other field types that may require IDs, date fields accept the date string directly
valueToSet = { date: String(fieldValue) };
} else if (field.dataType === "NUMBER") {
// NUMBER fields use ProjectV2FieldValue input type with number property
// The number value must be a valid float or integer
// Convert string values to numbers if needed
const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue));
if (isNaN(numValue)) {
core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`);
continue;
}
valueToSet = { number: numValue };
} else if (field.dataType === "ITERATION") {
// ITERATION fields use ProjectV2FieldValue input type with iterationId property
// The value should match an iteration title or ID
if (!field.configuration || !field.configuration.iterations) {
core.warning(`Iteration field "${fieldName}" has no configured iterations`);
continue;
}
// Try to find iteration by title (case-insensitive match)
const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase());
if (!iteration) {
const availableIterations = field.configuration.iterations.map(i => i.title).join(", ");
core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`);
continue;
}
valueToSet = { iterationId: iteration.id };
} else if (field.options) {
let option = field.options.find(o => o.name === fieldValue);
if (!option)
Expand Down
203 changes: 203 additions & 0 deletions actions/setup/js/update_project.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -595,4 +595,207 @@ describe("updateProject", () => {
// Explicitly verify it's NOT using singleSelectOptionId
expect(updateCall[1].value).not.toHaveProperty("singleSelectOptionId");
});

it("correctly handles NUMBER fields with numeric values", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 80,
fields: {
story_points: 5,
},
};

queueResponses([
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-number-field"),
issueResponse("issue-id-80"),
existingItemResponse("issue-id-80", "item-number-field"),
fieldsResponse([{ id: "field-story-points", name: "Story Points", dataType: "NUMBER" }]),
updateFieldValueResponse(),
]);

await updateProject(output);

const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue"));
expect(updateCall).toBeDefined();
expect(updateCall[1].value).toEqual({ number: 5 });
});

it("correctly converts string to number for NUMBER fields", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 81,
fields: {
story_points: "8.5",
},
};

queueResponses([
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-number-field"),
issueResponse("issue-id-81"),
existingItemResponse("issue-id-81", "item-number-field-string"),
fieldsResponse([{ id: "field-story-points", name: "Story Points", dataType: "NUMBER" }]),
updateFieldValueResponse(),
]);

await updateProject(output);

const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue"));
expect(updateCall).toBeDefined();
expect(updateCall[1].value).toEqual({ number: 8.5 });
});

it("handles invalid NUMBER field values with warning", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 82,
fields: {
story_points: "not-a-number",
},
};

queueResponses([
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-number-field"),
issueResponse("issue-id-82"),
existingItemResponse("issue-id-82", "item-number-field-invalid"),
fieldsResponse([{ id: "field-story-points", name: "Story Points", dataType: "NUMBER" }]),
]);

await updateProject(output);

expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Invalid number value "not-a-number"'));
});

it("correctly handles ITERATION fields by matching title", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 85,
fields: {
sprint: "Sprint 42",
},
};

queueResponses([
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-iteration-field"),
issueResponse("issue-id-85"),
existingItemResponse("issue-id-85", "item-iteration-field"),
fieldsResponse([
{
id: "field-sprint",
name: "Sprint",
dataType: "ITERATION",
configuration: {
iterations: [
{ id: "iter-41", title: "Sprint 41", startDate: "2026-01-01", duration: 2 },
{ id: "iter-42", title: "Sprint 42", startDate: "2026-01-15", duration: 2 },
{ id: "iter-43", title: "Sprint 43", startDate: "2026-01-29", duration: 2 },
],
},
},
]),
updateFieldValueResponse(),
]);

await updateProject(output);

const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue"));
expect(updateCall).toBeDefined();
expect(updateCall[1].value).toEqual({ iterationId: "iter-42" });
});

it("handles case-insensitive iteration title matching", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 86,
fields: {
sprint: "sprint 42",
},
};

queueResponses([
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-iteration-field"),
issueResponse("issue-id-86"),
existingItemResponse("issue-id-86", "item-iteration-field-case"),
fieldsResponse([
{
id: "field-sprint",
name: "Sprint",
dataType: "ITERATION",
configuration: {
iterations: [{ id: "iter-42", title: "Sprint 42", startDate: "2026-01-15", duration: 2 }],
},
},
]),
updateFieldValueResponse(),
]);

await updateProject(output);

const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue"));
expect(updateCall).toBeDefined();
expect(updateCall[1].value).toEqual({ iterationId: "iter-42" });
});

it("handles ITERATION field with non-existent iteration with warning", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 87,
fields: {
sprint: "Sprint 99",
},
};

queueResponses([
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-iteration-field"),
issueResponse("issue-id-87"),
existingItemResponse("issue-id-87", "item-iteration-field-missing"),
fieldsResponse([
{
id: "field-sprint",
name: "Sprint",
dataType: "ITERATION",
configuration: {
iterations: [
{ id: "iter-41", title: "Sprint 41", startDate: "2026-01-01", duration: 2 },
{ id: "iter-42", title: "Sprint 42", startDate: "2026-01-15", duration: 2 },
],
},
},
]),
]);

await updateProject(output);

expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Iteration "Sprint 99" not found'));
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Available iterations: Sprint 41, Sprint 42"));
});
});
25 changes: 25 additions & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,31 @@ safe-outputs:

Agent must provide full project URL (e.g., `https://github.com/orgs/myorg/projects/42`). Optional `campaign_id` applies `campaign:<id>` labels for [Campaign Workflows](/gh-aw/guides/campaigns/). Exposes outputs: `project-id`, `project-number`, `project-url`, `campaign-id`, `item-id`.

#### Supported Field Types

GitHub Projects V2 supports various custom field types. The following field types are automatically detected and handled:

- **`TEXT`** — Text fields (default)
- **`DATE`** — Date fields (format: `YYYY-MM-DD`)
- **`NUMBER`** — Numeric fields (story points, estimates, etc.)
- **`ITERATION`** — Sprint/iteration fields (matched by iteration title)
- **`SINGLE_SELECT`** — Dropdown/select fields (creates missing options automatically)

**Example field usage:**
```yaml
fields:
status: "In Progress" # SINGLE_SELECT field
start_date: "2026-01-04" # DATE field
story_points: 8 # NUMBER field
sprint: "Sprint 42" # ITERATION field (by title)
priority: "High" # SINGLE_SELECT field
```

:::note
Field names are case-insensitive and automatically normalized (e.g., `story_points` matches `Story Points`).
:::


### Pull Request Creation (`create-pull-request:`)

Creates PRs with code changes. Falls back to issue if creation fails (e.g., org settings block it). `expires` field (same-repo only) auto-closes after period: integers (days) or `2h`, `7d`, `2w`, `1m`, `1y` (hours < 24 treated as 1 day).
Expand Down
Loading