Repository navigation
Project dates from status #636
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); |