name: Sync Project Status on: issues: types: [labeled] jobs: update-project: runs-on: ubuntu-latest permissions: contents: read issues: write repository-projects: write steps: - name: Update project status from label uses: actions/github-script@v7 with: github-token: ${{ secrets.PROJECT_AUTOMATION_TOKEN != '' && secrets.PROJECT_AUTOMATION_TOKEN || github.token }} script: | const labelMap = { "backlog": "Backlog", "needs discussion": "Needs discussion", "approved": "Approved", "ready": "Ready", "in progress": "In progress", "in review": "In review", "done": "Done" }; const label = context.payload.label.name.toLowerCase(); const targetOptionName = labelMap[label]; if (!targetOptionName) return; const issueNodeId = context.payload.issue.node_id; const configuredProjectId = "PVT_kwHOBeIeKs4AfmUO"; const fieldId = "PVTSSF_lAHOBeIeKs4AfmUOzgU5pCI"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function fetchIssueProjectItems() { const result = await github.graphql(` query($issueId: ID!) { node(id: $issueId) { ... on Issue { projectItems(first: 100) { nodes { id project { id title } } } } } } `, { issueId: issueNodeId }); return result.node?.projectItems?.nodes ?? []; } // Retry a few times in case project membership is still syncing. let issueProjectItems = []; for (let attempt = 1; attempt <= 4; attempt++) { issueProjectItems = await fetchIssueProjectItems(); if (issueProjectItems.length > 0) break; if (attempt < 4) await sleep(3000); } let exactMatch = issueProjectItems.find( (node) => node.project?.id === configuredProjectId ); let itemId = exactMatch?.id; let projectId = exactMatch?.project?.id; // If issue is not in the configured project yet, try to add it. if (!itemId) { try { const added = await github.graphql(` mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } } } `, { projectId: configuredProjectId, contentId: issueNodeId }); itemId = added.addProjectV2ItemById?.item?.id; projectId = configuredProjectId; } catch (error) { const availableProjects = issueProjectItems .map((node) => `${node.project?.title ?? "(untitled)"} [${node.project?.id ?? "no-id"}]`) .join(", "); console.log(`Issue not in configured project ${configuredProjectId}. Available: ${availableProjects || "none"}`); console.log(`Failed to auto-add issue to configured project: ${error.message}`); return; } } if (!itemId || !projectId) { const availableProjects = issueProjectItems .map((node) => `${node.project?.title ?? "(untitled)"} [${node.project?.id ?? "no-id"}]`) .join(", "); console.log(`Issue not in configured project ${configuredProjectId}. Available: ${availableProjects || "none"}`); return; } const fieldResult = await github.graphql(` query($fieldId: ID!) { node(id: $fieldId) { ... on ProjectV2SingleSelectField { id options { id name } } } } `, { fieldId: fieldId }); const options = fieldResult.node?.options ?? []; const selectedOption = options.find( (option) => option.name?.toLowerCase() === targetOptionName.toLowerCase() ); if (!selectedOption?.id) { const availableOptions = options.map((option) => option.name).join(", "); console.log(`Could not find option '${targetOptionName}' for field ${fieldId}. Available options: ${availableOptions || "none"}`); return; } // update 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: projectId, itemId: itemId, fieldId: fieldId, optionId: selectedOption.id });