name: Sync Project Fields on: issues: types: [opened, labeled, unlabeled, reopened] permissions: {} jobs: sync-fields: name: Sync Type/Priority fields from labels runs-on: ubuntu-latest steps: - name: Sync fields uses: actions/github-script@v9 with: github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} script: | const projectId = 'PVT_kwHOADH82s4BO2OT'; const issueNodeId = context.payload.issue.node_id; const issueNumber = context.payload.issue.number; const labels = (context.payload.issue.labels || []).map(l => l.name.toLowerCase()); const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const getProjectItem = async () => { const data = await github.graphql(` query($nodeId: ID!) { node(id: $nodeId) { ... on Issue { projectItems(first: 20) { nodes { id project { id } } } } } } `, { nodeId: issueNodeId }); const items = data.node?.projectItems?.nodes || []; return items.find(item => item.project.id === projectId) || null; }; let projectItem = await getProjectItem(); // add-to-project may run in parallel; retry briefly before giving up for (let i = 0; !projectItem && i < 6; i++) { console.log(`Issue #${issueNumber} not in project yet. Retry ${i + 1}/6...`); await sleep(10000); projectItem = await getProjectItem(); } if (!projectItem) { console.log(`Issue #${issueNumber} is not in project board. Skipping field sync.`); return; } const fieldsData = await github.graphql(` query($projectId: ID!) { node(id: $projectId) { ... on ProjectV2 { fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } } } `, { projectId }); const singleSelectFields = fieldsData.node?.fields?.nodes || []; const byName = new Map(singleSelectFields.map(f => [f.name, f])); const typeField = byName.get('Type'); const priorityField = byName.get('Priority'); if (!typeField && !priorityField) { console.log('Neither Type nor Priority field found. Nothing to update.'); return; } const pickOptionId = (field, optionName) => { if (!field || !optionName) return null; const opt = (field.options || []).find(o => o.name.toLowerCase() === optionName.toLowerCase()); return opt?.id || null; }; let typeName = null; if (labels.includes('bug')) typeName = 'Bug'; else if (labels.includes('enhancement')) typeName = 'Feature'; else if (labels.includes('documentation')) typeName = 'Documentation'; let priorityName = null; if (labels.includes('priority/high')) priorityName = 'High'; else if (labels.includes('priority/low')) priorityName = 'Low'; else if (labels.includes('priority/medium')) priorityName = 'Medium'; else if (labels.includes('triage')) priorityName = 'Medium'; const updates = []; const typeOptionId = pickOptionId(typeField, typeName); if (typeField && typeOptionId) { updates.push({ fieldId: typeField.id, optionId: typeOptionId, fieldName: 'Type', valueName: typeName }); } const priorityOptionId = pickOptionId(priorityField, priorityName); if (priorityField && priorityOptionId) { updates.push({ fieldId: priorityField.id, optionId: priorityOptionId, fieldName: 'Priority', valueName: priorityName }); } for (const update of updates) { 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, itemId: projectItem.id, fieldId: update.fieldId, optionId: update.optionId }); console.log(`Issue #${issueNumber}: set ${update.fieldName} = ${update.valueName}`); } if (updates.length === 0) { console.log(`Issue #${issueNumber}: no matching field updates for labels [${labels.join(', ')}]`); }