Skip to content

Project dates from status #645

Project dates from status

Project dates from status #645

Workflow file for this run

name: Project dates from status
on:
schedule:
- cron: '0 */8 * * *'
workflow_dispatch: {}
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_PROJECTS_PAT }}
script: |
const STATUS_IN_PROGRESS = 'In Progress';
const STATUS_DONE = 'Done';
// startIssueFieldId: org-level IssueField ID used by updateIssueFieldValue
// when GitHub rejects updateProjectV2ItemFieldValue for issue-type items.
// "End date" has no org issue field equivalent so endIssueFieldId is null;
// updateProjectV2ItemFieldValue handles it directly.
// Only issues are processed. Boards also hold pull-request items, whose
// `content` carries no issue id: one of them made setDate's fallback throw,
// and the throw was unhandled, so a single PR item killed the whole run and
// every remaining item on both boards went unsynced (2026-09-02 to 09-03).
// closedToDone: safety net — a closed issue whose Status is not Done is
// flipped to Done (project 9 only; Done there means deployed, and issues
// close at deploy: on merge for deploy-on-merge repos, from cd.yml when
// the rumi-app train ships). Self-heals when the built-in "item closed"
// project workflow is off or misses an event.
const PROJECTS = [
{
number: 9,
projectId: 'PVT_kwDOCA2kqc4BUnfM',
statusFieldId: 'PVTSSF_lADOCA2kqc4BUnfMzhBucOc',
startDateFieldId: 'PVTF_lADOCA2kqc4BUnfMzhUEF1k',
startIssueFieldId: 'IFD_kgDOAYWajw',
endDateFieldId: 'PVTF_lADOCA2kqc4BUnfMzhUEGFI',
endIssueFieldId: null,
closedToDone: true,
},
{
number: 11,
projectId: 'PVT_kwDOCA2kqc4BV4Gu',
statusFieldId: 'PVTSSF_lADOCA2kqc4BV4GuzhRQrS8',
startDateFieldId: 'PVTF_lADOCA2kqc4BV4GuzhRQrT0',
startIssueFieldId: 'IFD_kgDOAYWajw',
endDateFieldId: 'PVTF_lADOCA2kqc4BV4GuzhRlSCM',
endIssueFieldId: null,
closedToDone: false,
},
];
const today = new Date().toISOString().slice(0, 10);
function getDateValue(item, pvtFieldId, issueFieldId) {
const fvs = item.fieldValues?.nodes ?? [];
const projectDate = fvs.find((n) => n.field?.id === pvtFieldId)?.date;
if (projectDate) return projectDate;
if (!issueFieldId) return null;
const issueFvs = item.content?.issueFieldValues?.nodes ?? [];
return issueFvs.find((n) => n.field?.id === issueFieldId)?.value ?? null;
}
async function listItems(projectId) {
const items = [];
let cursor = null;
while (true) {
const { node } = await github.graphql(
`query ($projectId: ID!, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
isArchived
fieldValues(first: 50) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field { ... on ProjectV2FieldCommon { id } }
}
... on ProjectV2ItemFieldDateValue {
date
field { ... on ProjectV2FieldCommon { id } }
}
}
}
content {
__typename
... on Issue {
state
issueFieldValues(first: 20) {
nodes {
... on IssueFieldDateValue {
value
field { ... on Node { id } }
}
}
}
}
}
}
}
}
}
}`,
{ headers: { 'GraphQL-Features': 'issue_fields' }, projectId, cursor }
);
const page = node?.items;
if (!page) break;
items.push(...page.nodes);
if (!page.pageInfo.hasNextPage) break;
cursor = page.pageInfo.endCursor;
}
return items;
}
async function setDate(projectId, itemId, pvtFieldId, issueFieldId, value) {
try {
await github.graphql(
`mutation ($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: Date!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { date: $value }
}
) { projectV2Item { id } }
}`,
{ projectId, itemId, fieldId: pvtFieldId, value }
);
} catch (err) {
if (!err.errors?.some((e) => e.message.includes('updateIssueFieldValue'))) throw err;
if (!issueFieldId) throw new Error(`No Issue Field ID for ${pvtFieldId} on item ${itemId}`);
const { node } = await github.graphql(
`query ($id: ID!) {
node(id: $id) {
... on ProjectV2Item { content { ... on Issue { id } } }
}
}`,
{ id: itemId }
);
const issueId = node?.content?.id;
if (!issueId) throw new Error(`Cannot resolve issue ID for item ${itemId}`);
await github.graphql(
`mutation ($issueId: ID!, $fieldId: ID!, $value: String!) {
updateIssueFieldValue(
input: {
issueId: $issueId
issueField: { fieldId: $fieldId, dateValue: $value }
}
) { issue { id } }
}`,
{ headers: { 'GraphQL-Features': 'issue_fields' }, issueId, fieldId: issueFieldId, value }
);
}
}
// Option ids are resolved by name at run time: editing a single-select
// field regenerates every option id (learned 2026-09-01).
async function statusOptionIds(cfg) {
const { node } = await github.graphql(
`query ($id: ID!) {
node(id: $id) { ... on ProjectV2 {
field(name: "Status") { ... on ProjectV2SingleSelectField { options { id name } } }
} }
}`,
{ id: cfg.projectId }
);
const byName = {};
for (const o of node?.field?.options ?? []) byName[o.name] = o.id;
return byName;
}
let totalUpdates = 0;
for (const cfg of PROJECTS) {
const items = await listItems(cfg.projectId);
core.info(`project=${cfg.number} items=${items.length}`);
const options = cfg.closedToDone ? await statusOptionIds(cfg) : {};
const doneId = options[STATUS_DONE] ?? null;
const backlogId = options['Backlog'] ?? null;
let updates = 0;
let skipped = 0;
let failed = 0;
for (const it of items) {
if (it.isArchived) continue;
if (it.content?.__typename !== 'Issue') { skipped++; continue; }
try {
const fvs = it.fieldValues?.nodes ?? [];
let status = fvs.find((n) => n.field?.id === cfg.statusFieldId)?.name ?? null;
const startDate = getDateValue(it, cfg.startDateFieldId, cfg.startIssueFieldId);
const endDate = getDateValue(it, cfg.endDateFieldId, cfg.endIssueFieldId);
if (backlogId && !status && it.content?.state === 'OPEN') {
await github.graphql(
`mutation ($project: ID!, $item: ID!, $field: ID!, $option: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $project, itemId: $item, fieldId: $field,
value: { singleSelectOptionId: $option }
}) { projectV2Item { id } }
}`,
{ project: cfg.projectId, item: it.id, field: cfg.statusFieldId, option: backlogId }
);
core.info(`project=${cfg.number} item=${it.id} statusless open issue → Backlog`);
status = 'Backlog';
updates++;
}
if (doneId && it.content?.state === 'CLOSED' && status !== STATUS_DONE) {
await github.graphql(
`mutation ($project: ID!, $item: ID!, $field: ID!, $option: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $project, itemId: $item, fieldId: $field,
value: { singleSelectOptionId: $option }
}) { projectV2Item { id } }
}`,
{ project: cfg.projectId, item: it.id, field: cfg.statusFieldId, option: doneId }
);
core.info(`project=${cfg.number} item=${it.id} closed issue: status ${status} → ${STATUS_DONE}`);
status = STATUS_DONE;
updates++;
}
if (status === STATUS_IN_PROGRESS && !startDate) {
await setDate(cfg.projectId, it.id, cfg.startDateFieldId, cfg.startIssueFieldId, today);
core.info(`project=${cfg.number} item=${it.id} set start_date=${today}`);
updates++;
}
if (status === STATUS_DONE && !endDate) {
await setDate(cfg.projectId, it.id, cfg.endDateFieldId, cfg.endIssueFieldId, today);
core.info(`project=${cfg.number} item=${it.id} set end_date=${today}`);
updates++;
}
} catch (err) {
// One unsyncable item must not blank the rest of the board.
failed++;
core.warning(`project=${cfg.number} item=${it.id}: ${err.message}`);
}
}
core.info(`project=${cfg.number} updates=${updates} skipped=${skipped} failed=${failed}`);
totalUpdates += updates;
}
core.info(`done updates=${totalUpdates}`);