docs: Completely rewrite README with updated architecture and diagram… #2021
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: Issue Project Field Sync | ||
| # Syncs open GitHub issues into a Projects v2 board and fills in | ||
| # Status, Priority, and Type fields based on the issue's labels. | ||
| # | ||
| # SETUP: | ||
| # 1. Go to https://github.com/orgs/lightspeedwp/projects and note | ||
| # the project number (the integer in the URL, e.g. /projects/5). | ||
| # 2. Add a repo or org-level Actions secret named GH_PROJECT_TOKEN | ||
| # with a PAT that has the `project` scope. If you only need | ||
| # repo-linked projects, the built-in GITHUB_TOKEN is sufficient | ||
| # when `projects: write` is set below. | ||
| # 3. Set the project number as a repo variable: Settings → Variables | ||
| # → Actions → New → Name: PROJECT_NUMBER, Value: <number> | ||
| # 4. The field names (Status / Priority / Type) are auto-discovered. | ||
| # If your project uses different names, update FIELD_STATUS, | ||
| # FIELD_PRIORITY, FIELD_TYPE in the env block below. | ||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| project_number: | ||
| description: "GitHub Projects v2 number (overrides PROJECT_NUMBER variable)" | ||
| required: false | ||
| type: string | ||
| dry_run: | ||
| description: "Dry run — report planned changes without applying them" | ||
| required: false | ||
| default: "false" | ||
| type: choice | ||
| options: | ||
| - "false" | ||
| - "true" | ||
| add_missing: | ||
| description: "Add issues not yet in the project" | ||
| required: false | ||
| default: "true" | ||
| type: choice | ||
| options: | ||
| - "true" | ||
| - "false" | ||
| schedule: | ||
| # Runs every Monday at 09:00 UTC (after issue-health-audit at 08:00) | ||
| - cron: "0 9 * * 1" | ||
| permissions: | ||
| issues: read | ||
| projects: write | ||
| env: | ||
| # Names of the project fields to sync — change these if your | ||
| # project uses different field names (case-insensitive match used). | ||
| FIELD_STATUS: "Status" | ||
| FIELD_PRIORITY: "Priority" | ||
| FIELD_TYPE: "Type" | ||
| jobs: | ||
| sync-project-fields: | ||
| name: Sync Issue Fields → Project | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Sync issues to project and fill fields | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| # Use a PAT with `project` scope if available; fall back to GITHUB_TOKEN. | ||
| # GITHUB_TOKEN works for repo-linked projects when projects: write is set. | ||
| GH_TOKEN: ${{ secrets.GH_PROJECT_TOKEN || github.token }} | ||
| DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} | ||
| ADD_MISSING: ${{ github.event.inputs.add_missing || 'true' }} | ||
| PROJECT_NUMBER_INPUT: ${{ github.event.inputs.project_number || vars.PROJECT_NUMBER || '' }} | ||
| with: | ||
| github-token: ${{ secrets.GH_PROJECT_TOKEN || github.token }} | ||
| script: | | ||
| const dryRun = process.env.DRY_RUN === 'true'; | ||
| const addMissing = process.env.ADD_MISSING === 'true'; | ||
| const projectNumberStr = process.env.PROJECT_NUMBER_INPUT; | ||
| if (!projectNumberStr) { | ||
| core.setFailed( | ||
| 'No project number supplied. Provide it via the workflow_dispatch input ' + | ||
| 'or set a repo/org variable named PROJECT_NUMBER.' | ||
| ); | ||
| return; | ||
| } | ||
| const projectNumber = parseInt(projectNumberStr, 10); | ||
| const owner = context.repo.owner; | ||
| const repo = context.repo.repo; | ||
| const report = { | ||
| added: [], | ||
| updated: [], | ||
| skipped: [], | ||
| errors: [], | ||
| }; | ||
| // ─── GraphQL helper ────────────────────────────────────────── | ||
| async function gql(query, variables = {}) { | ||
| return github.graphql(query, variables); | ||
| } | ||
| // ─── Step 1: Resolve project node ID and fields ────────────── | ||
| core.info(`Fetching project #${projectNumber} for org "${owner}"...`); | ||
| let projectId, statusField, priorityField, typeField; | ||
| try { | ||
| const projectData = await gql(` | ||
| query($owner: String!, $number: Int!) { | ||
| organization(login: $owner) { | ||
| projectV2(number: $number) { | ||
| id | ||
| title | ||
| fields(first: 30) { | ||
| nodes { | ||
| ... on ProjectV2Field { | ||
| id name dataType | ||
| } | ||
| ... on ProjectV2SingleSelectField { | ||
| id name dataType | ||
| options { id name } | ||
| } | ||
| ... on ProjectV2IterationField { | ||
| id name dataType | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `, { owner, number: projectNumber }); | ||
| const project = projectData.organization.projectV2; | ||
| projectId = project.id; | ||
| core.info(`Found project: "${project.title}" (${projectId})`); | ||
| const fieldNameMatch = (field, target) => | ||
| field.name.toLowerCase() === target.toLowerCase(); | ||
| for (const field of project.fields.nodes) { | ||
| if (fieldNameMatch(field, process.env.FIELD_STATUS)) statusField = field; | ||
| if (fieldNameMatch(field, process.env.FIELD_PRIORITY)) priorityField = field; | ||
| if (fieldNameMatch(field, process.env.FIELD_TYPE)) typeField = field; | ||
| } | ||
| core.info(`Status field: ${statusField?.name || 'NOT FOUND'}`); | ||
| core.info(`Priority field: ${priorityField?.name || 'NOT FOUND'}`); | ||
| core.info(`Type field: ${typeField?.name || 'NOT FOUND'}`); | ||
| } catch (err) { | ||
| core.setFailed(`Failed to fetch project: ${err.message}`); | ||
| return; | ||
| } | ||
| // ─── Step 2: Build a map of issues already in the project ──── | ||
| core.info('Fetching existing project items...'); | ||
| let cursor = null; | ||
| const existingItems = new Map(); // issueNumber → projectItemId | ||
| do { | ||
| const itemsData = await gql(` | ||
| query($projectId: ID!, $cursor: String) { | ||
| node(id: $projectId) { | ||
| ... on ProjectV2 { | ||
| items(first: 100, after: $cursor) { | ||
| pageInfo { hasNextPage endCursor } | ||
| nodes { | ||
| id | ||
| content { | ||
| ... on Issue { number } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `, { projectId, cursor }); | ||
| const page = itemsData.node.items; | ||
| for (const item of page.nodes) { | ||
| if (item.content?.number != null) { | ||
| existingItems.set(item.content.number, item.id); | ||
| } | ||
| } | ||
| cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; | ||
| } while (cursor); | ||
| core.info(`Project has ${existingItems.size} existing items.`); | ||
| // ─── Step 3: Fetch all open issues ─────────────────────────── | ||
| core.info('Fetching open issues...'); | ||
| const openIssues = await github.paginate(github.rest.issues.listForRepo, { | ||
| owner, | ||
| repo, | ||
| state: 'open', | ||
| per_page: 100, | ||
| }); | ||
| // listForRepo returns PRs too — filter them out | ||
| const issues = openIssues.filter(i => !i.pull_request); | ||
| core.info(`Found ${issues.length} open issues.`); | ||
| // ─── Helper: find SingleSelect option ID by name (fuzzy) ───── | ||
| function findOptionId(field, labelValue) { | ||
| if (!field?.options) return null; | ||
| // Normalise: lowercase, strip leading "status:", "priority:", "type:" | ||
| const norm = (s) => s.toLowerCase().replace(/^(status|priority|type):/, ''); | ||
| const target = norm(labelValue); | ||
| const opt = field.options.find(o => norm(o.name) === target); | ||
| return opt?.id || null; | ||
| } | ||
| // ─── Helper: map labels to field values ────────────────────── | ||
| function resolveStatus(labels) { | ||
| const names = labels.map(l => l.name); | ||
| if (names.some(n => n === 'status:in-progress')) return 'In Progress'; | ||
| if (names.some(n => n === 'status:needs-review')) return 'In Review'; | ||
| if (names.some(n => n === 'status:blocked')) return 'Blocked'; | ||
| if (names.some(n => n === 'status:ready')) return 'Ready'; | ||
| if (names.some(n => n === 'status:needs-more-info')) return 'Needs Info'; | ||
| if (names.some(n => ['status: completed', 'status:completed', 'completed'].includes(n))) return 'Done'; | ||
| return 'Todo'; | ||
| } | ||
| function resolvePriority(labels) { | ||
| const names = labels.map(l => l.name); | ||
| if (names.some(n => n === 'priority:critical')) return 'Critical'; | ||
| if (names.some(n => n === 'priority:important' || n === 'priority:high')) return 'High'; | ||
| if (names.some(n => n === 'priority:normal' || n === 'priority:medium')) return 'Medium'; | ||
| if (names.some(n => n === 'priority:minor' || n === 'priority:low')) return 'Low'; | ||
| return null; | ||
| } | ||
| function resolveType(labels) { | ||
| const typeLabel = labels.find(l => l.name.startsWith('type:')); | ||
| if (!typeLabel) return null; | ||
| // Convert "type:bug" → "Bug", "type:feature" → "Feature", etc. | ||
| const raw = typeLabel.name.replace('type:', ''); | ||
| return raw.charAt(0).toUpperCase() + raw.slice(1); | ||
| } | ||
| // ─── Helper: set a project field value ─────────────────────── | ||
| async function setFieldValue(itemId, field, value) { | ||
| if (!field || !value) return false; | ||
| if (field.dataType === 'SINGLE_SELECT') { | ||
| // Try exact match first, then fuzzy | ||
| let optId = findOptionId(field, value); | ||
| if (!optId) { | ||
| // Try partial match | ||
| const norm = value.toLowerCase(); | ||
| const opt = field.options.find(o => o.name.toLowerCase().includes(norm) || norm.includes(o.name.toLowerCase())); | ||
| optId = opt?.id || null; | ||
| } | ||
| if (!optId) { | ||
| core.info(` ⚠ No matching option "${value}" in ${field.name} field (options: ${field.options.map(o => o.name).join(', ')})`); | ||
| return false; | ||
| } | ||
| if (!dryRun) { | ||
| await gql(` | ||
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { | ||
| updateProjectV2ItemFieldValue(input: { | ||
| projectId: $projectId | ||
| itemId: $itemId | ||
| fieldId: $fieldId | ||
| value: { singleSelectOptionId: $optionId } | ||
| }) { projectV2Item { id } } | ||
| } | ||
| `, { projectId, itemId, fieldId: field.id, optionId: optId }); | ||
| } | ||
| return true; | ||
| } | ||
| if (field.dataType === 'TEXT') { | ||
| if (!dryRun) { | ||
| await gql(` | ||
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) { | ||
| updateProjectV2ItemFieldValue(input: { | ||
| projectId: $projectId | ||
| itemId: $itemId | ||
| fieldId: $fieldId | ||
| value: { text: $text } | ||
| }) { projectV2Item { id } } | ||
| } | ||
| `, { projectId, itemId, fieldId: field.id, text: value }); | ||
| } | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| // ─── Step 4: Process each open issue ───────────────────────── | ||
| for (const issue of issues) { | ||
| const labels = issue.labels || []; | ||
| const issueNumber = issue.number; | ||
| let itemId = existingItems.get(issueNumber); | ||
| // Add to project if not present | ||
| if (!itemId) { | ||
| if (!addMissing) { | ||
| report.skipped.push(`#${issueNumber} — not in project, add_missing=false`); | ||
| continue; | ||
| } | ||
| core.info(`Adding #${issueNumber} to project...`); | ||
| if (!dryRun) { | ||
| try { | ||
| const addResult = await gql(` | ||
| mutation($projectId: ID!, $contentId: ID!) { | ||
| addProjectV2ItemById(input: { | ||
| projectId: $projectId | ||
| contentId: $contentId | ||
| }) { item { id } } | ||
| } | ||
| `, { projectId, contentId: issue.node_id }); | ||
| itemId = addResult.addProjectV2ItemById.item.id; | ||
| report.added.push(`#${issueNumber}: ${issue.title.slice(0, 60)}`); | ||
| } catch (err) { | ||
| report.errors.push(`#${issueNumber} add failed: ${err.message}`); | ||
| continue; | ||
| } | ||
| } else { | ||
| report.added.push(`#${issueNumber} [DRY RUN]: ${issue.title.slice(0, 60)}`); | ||
| continue; // Can't set fields without real itemId in dry run | ||
| } | ||
| } | ||
| // Resolve target field values | ||
| const targetStatus = resolveStatus(labels); | ||
| const targetPriority = resolvePriority(labels); | ||
| const targetType = resolveType(labels); | ||
| let changed = false; | ||
| try { | ||
| if (statusField && targetStatus) { | ||
| const ok = await setFieldValue(itemId, statusField, targetStatus); | ||
| if (ok) changed = true; | ||
| } | ||
| if (priorityField && targetPriority) { | ||
| const ok = await setFieldValue(itemId, priorityField, targetPriority); | ||
| if (ok) changed = true; | ||
| } | ||
| if (typeField && targetType) { | ||
| const ok = await setFieldValue(itemId, typeField, targetType); | ||
| if (ok) changed = true; | ||
| } | ||
| if (changed) { | ||
| const summary = [ | ||
| statusField && targetStatus ? `Status→${targetStatus}` : null, | ||
| priorityField && targetPriority ? `Priority→${targetPriority}` : null, | ||
| typeField && targetType ? `Type→${targetType}` : null, | ||
| ].filter(Boolean).join(', '); | ||
| report.updated.push(`#${issueNumber}: ${summary}`); | ||
| } | ||
| } catch (err) { | ||
| report.errors.push(`#${issueNumber} field update failed: ${err.message}`); | ||
| } | ||
| } | ||
| // ─── Step 5: Summary report ────────────────────────────────── | ||
| const summary = [ | ||
| `# Issue → Project Field Sync Report`, | ||
| `> ${dryRun ? '🔍 DRY RUN — no changes were made' : '✅ Changes applied'}`, | ||
| `> Project #${projectNumber} | Run: ${new Date().toISOString()}`, | ||
| '', | ||
| `## Added to Project (${report.added.length})`, | ||
| report.added.length > 0 ? report.added.map(r => `- ${r}`).join('\n') : '_None_', | ||
| '', | ||
| `## Fields Updated (${report.updated.length})`, | ||
| report.updated.length > 0 ? report.updated.map(r => `- ${r}`).join('\n') : '_None_', | ||
| '', | ||
| `## Skipped (${report.skipped.length})`, | ||
| report.skipped.length > 0 ? report.skipped.map(r => `- ${r}`).join('\n') : '_None_', | ||
| '', | ||
| `## Errors (${report.errors.length})`, | ||
| report.errors.length > 0 ? report.errors.map(r => `- ⚠️ ${r}`).join('\n') : '_None_', | ||
| ].join('\n'); | ||
| await core.summary.addRaw(summary).write(); | ||
| core.info(summary); | ||
| if (report.errors.length > 0) { | ||
| core.setFailed(`Sync completed with ${report.errors.length} error(s).`); | ||
| } | ||