Initial announcement #717
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: LFX Proposal Board Sync | |
| on: | |
| issues: | |
| types: [opened, labeled, unlabeled, closed, reopened] | |
| permissions: | |
| contents: read | |
| jobs: | |
| sync-board: | |
| if: >- | |
| contains(github.event.issue.labels.*.name, 'lfx mentorship') | |
| && contains(github.event.issue.labels.*.name, 'Proposal') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Sync issue to project board | |
| uses: actions/github-script@v7 | |
| env: | |
| PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }} | |
| with: | |
| github-token: ${{ secrets.PROJECT_TOKEN }} | |
| script: | | |
| // ── Resolve this repo's board from board.json (dev vs prod) ── | |
| // Only the projectId is stored per repo; the Status field ID and | |
| // option IDs are resolved live from the board so the fork (dev) and | |
| // cncf/mentoring (prod) run identical code. The board's Status | |
| // column names must match the lifecycle stages used below. | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const _lib = (m) => require(path.join(process.env.GITHUB_WORKSPACE || process.cwd(), 'programs/lfx-mentorship/automation/lib', m)); | |
| const { determineStatus, shouldSkipSync, readStatusWithRetry } = _lib('board.js'); | |
| const _board = JSON.parse(fs.readFileSync('programs/lfx-mentorship/automation/board.json', 'utf8')); | |
| const _envCfg = (_board.environments || {})[process.env.GITHUB_REPOSITORY]; | |
| if (!_envCfg || !_envCfg.projectId || String(_envCfg.projectId).startsWith('REPLACE_')) { | |
| core.warning(`No board configured for ${process.env.GITHUB_REPOSITORY} in board.json; skipping board sync.`); | |
| return; | |
| } | |
| const PROJECT_ID = _envCfg.projectId; | |
| const _statusField = (await github.graphql(` | |
| query($projectId: ID!) { | |
| node(id: $projectId) { ... on ProjectV2 { | |
| field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } | |
| } } | |
| } | |
| `, { projectId: PROJECT_ID })).node.field; | |
| if (!_statusField) { | |
| core.warning(`Project ${PROJECT_ID} has no "Status" field; skipping board sync.`); | |
| return; | |
| } | |
| const STATUS_FIELD_ID = _statusField.id; | |
| const STATUS_OPTIONS = Object.fromEntries(_statusField.options.map(o => [o.name, o.id])); | |
| const issue = context.payload.issue; | |
| const labels = (issue.labels || []).map(l => l.name); | |
| const action = context.payload.action; | |
| // ── Determine target status from labels + event (see lib/board.js) ── | |
| const targetStatus = determineStatus(labels, action); | |
| console.log(`Target status: ${targetStatus}`); | |
| const optionId = STATUS_OPTIONS[targetStatus]; | |
| if (!optionId) { | |
| core.warning(`Board has no "${targetStatus}" status option; skipping board sync.`); | |
| return; | |
| } | |
| // ── Ensure issue is added to the project ── | |
| const addResult = await github.graphql(` | |
| mutation($projectId: ID!, $contentId: ID!) { | |
| addProjectV2ItemById(input: { | |
| projectId: $projectId | |
| contentId: $contentId | |
| }) { | |
| item { id } | |
| } | |
| } | |
| `, { | |
| projectId: PROJECT_ID, | |
| contentId: issue.node_id, | |
| }); | |
| const itemId = addResult.addProjectV2ItemById.item.id; | |
| console.log(`Project item: ${itemId}`); | |
| // ── Respect manual admin moves (guard logic in lib/board.js) ── | |
| // Automation owns the lifecycle only through "Exported"; the | |
| // post-export columns are advanced by hand by the administrator, so | |
| // never overwrite a card already sitting in one (except on close), | |
| // and fail closed if the status read fails while setting "Exported". | |
| const { currentStatus, readFailed } = await readStatusWithRetry( | |
| () => github.graphql(` | |
| query($itemId: ID!) { | |
| node(id: $itemId) { ... on ProjectV2Item { | |
| fieldValueByName(name: "Status") { | |
| ... on ProjectV2ItemFieldSingleSelectValue { name } | |
| } | |
| } } | |
| } | |
| `, { itemId }), | |
| { onError: (attempt, e) => core.warning(`Could not read current board status (attempt ${attempt}/2): ${e.message}`) }, | |
| ); | |
| if (shouldSkipSync(currentStatus, targetStatus, readFailed)) { | |
| if (currentStatus) { | |
| console.log(`Card is in admin-owned column "${currentStatus}"; leaving placement to the administrator.`); | |
| } else { | |
| core.warning('Status read failed while about to set "Exported"; skipping to avoid clobbering a manual placement.'); | |
| } | |
| return; | |
| } | |
| // ── Set the Status field ── | |
| await github.graphql(` | |
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { | |
| updateProjectV2ItemFieldValue(input: { | |
| projectId: $projectId | |
| itemId: $itemId | |
| fieldId: $fieldId | |
| value: { singleSelectOptionId: $optionId } | |
| }) { | |
| projectV2Item { id } | |
| } | |
| } | |
| `, { | |
| projectId: PROJECT_ID, | |
| itemId: itemId, | |
| fieldId: STATUS_FIELD_ID, | |
| optionId: optionId, | |
| }); | |
| console.log(`Status set to: ${targetStatus}`); |