Fix project sync ci #5
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 Sync | |
| on: | |
| push: | |
| branches: [ main, '**' ] | |
| pull_request: | |
| types: [opened, reopened, synchronize] | |
| issues: | |
| types: [opened] | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: read | |
| env: | |
| # Set these to match your environment. | |
| GITHUB_USER: pynip | |
| # Replace with the numeric project number for "Virtualization Studio ARM macOS" | |
| PROJECT_NUMBER: 3 # Project number extracted from https://github.com/users/pynip/projects/3 | |
| # Status names expected in the project's single-select Status field | |
| STATUS_TODO_NAME: "To do" | |
| STATUS_INPROGRESS_NAME: "In Progress" | |
| STATUS_DONE_NAME: "Done" | |
| jobs: | |
| add-new-issues: | |
| name: Add newly opened issues to project (To do) | |
| if: github.event_name == 'issues' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Add to project | |
| uses: actions/[email protected] | |
| with: | |
| project-url: https://github.com/users/${{ env.GITHUB_USER }}/projects/${{ env.PROJECT_NUMBER }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| sync-from-push: | |
| name: Sync referenced issues from push commits | |
| if: github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Extract issue numbers from commit messages | |
| id: extract | |
| run: | | |
| issues=$(echo "${{ toJson(github.event.commits) }}" | jq -r '.[].message' | grep -Eo '#[0-9]+' | tr -d '#' | sort -u || true) | |
| if [ -n "$issues" ]; then | |
| echo "issues=$issues" >> $GITHUB_OUTPUT | |
| else | |
| echo "issues=" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Add referenced issues to project (if any) | |
| if: steps.extract.outputs.issues != '' | |
| uses: actions/[email protected] | |
| with: | |
| project-url: https://github.com/users/${{ env.GITHUB_USER }}/projects/${{ env.PROJECT_NUMBER }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| content-id: ${{ steps.extract.outputs.issues }} | |
| continue-on-error: true | |
| - name: Prepare status update script | |
| if: steps.extract.outputs.issues != '' | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Update status for closed issues (Done) | |
| if: steps.extract.outputs.issues != '' | |
| run: | | |
| sudo apt-get update && sudo apt-get install -y jq | |
| curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg | |
| sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg | |
| echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null | |
| sudo apt-get update && sudo apt-get install gh -y | |
| gh auth status || gh auth login --with-token <<<"${GITHUB_TOKEN}" | |
| LOGIN=${{ env.GITHUB_USER }} | |
| PROJECT_NUM=${{ env.PROJECT_NUMBER }} | |
| ISSUES="${{ steps.extract.outputs.issues }}" | |
| # Fetch project + status field metadata | |
| projectQuery='query($login:String!,$number:Int!){ user(login:$login){ projectV2(number:$number){ id fields(first:50){ nodes { __typename ... on ProjectV2SingleSelectField { id name options { id name } } } } items(first:200){ nodes { id content { __typename ... on Issue { number id state } } } } } } }' | |
| resp=$(gh api graphql -f query="$projectQuery" -F login="$LOGIN" -F number=$PROJECT_NUM) | |
| projectId=$(echo "$resp" | jq -r '.data.user.projectV2.id') | |
| statusFieldId=$(echo "$resp" | jq -r '.data.user.projectV2.fields.nodes[] | select(.name=="Status") | .id') | |
| optDone=$(echo "$resp" | jq -r '.data.user.projectV2.fields.nodes[] | select(.name=="Status") | .options[] | select(.name=="'"${STATUS_DONE_NAME}"'") | .id') | |
| optInProg=$(echo "$resp" | jq -r '.data.user.projectV2.fields.nodes[] | select(.name=="Status") | .options[] | select(.name=="'"${STATUS_INPROGRESS_NAME}"'") | .id') | |
| for issue in $ISSUES; do | |
| state=$(gh api repos/${{ github.repository }}/issues/$issue --jq '.state') | |
| # Find item id in project | |
| itemId=$(echo "$resp" | jq -r --arg n "$issue" '.data.user.projectV2.items.nodes[] | select(.content.number==($n|tonumber)) | .id') | |
| steps: | |
| - name: Sync issues via github-script | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const login = process.env.GITHUB_USER; | |
| const projectNumber = parseInt(process.env.PROJECT_NUMBER, 10); | |
| const statusTodo = process.env.STATUS_TODO_NAME; | |
| const statusInProg = process.env.STATUS_INPROGRESS_NAME; | |
| const statusDone = process.env.STATUS_DONE_NAME; | |
| const commits = context.payload.commits || []; | |
| const issueSet = new Set(); | |
| for (const c of commits) { | |
| const matches = (c.message.match(/#[0-9]+/g) || []); | |
| for (const m of matches) issueSet.add(parseInt(m.substring(1),10)); | |
| } | |
| if (issueSet.size === 0) { core.info('No referenced issues in commit messages.'); return; } | |
| core.info(`Referenced issues: ${[...issueSet].join(', ')}`); | |
| const qProject = `query($login:String!,$number:Int!){ user(login:$login){ projectV2(number:$number){ id fields(first:50){ nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } items(first:200){ nodes { id content { ... on Issue { id number state } } } } } } }`; | |
| const projectResp = await github.graphql(qProject, { login, number: projectNumber }); | |
| const project = projectResp.user.projectV2; if(!project) { core.setFailed('Project not found'); return; } | |
| const projectId = project.id; | |
| // Find Status field | |
| const statusField = project.fields.nodes.find(f=>f && f.name==='Status'); | |
| if(!statusField) { core.setFailed('Status field not found'); return; } | |
| const optMap = {}; if(statusField.options){ for(const o of statusField.options){ optMap[o.name]=o.id; } } | |
| const ensureIssueNodeId = async (issueNumber)=>{ | |
| const qIssue = `query($owner:String!,$repo:String!,$num:Int!){ repository(owner:$owner,name:$repo){ issue(number:$num){ id number state } } }`; | |
| const ir = await github.graphql(qIssue,{ owner: context.repo.owner, repo: context.repo.repo, num: issueNumber }); | |
| return ir.repository.issue; | |
| }; | |
| const existingItems = new Map(); | |
| for(const item of project.items.nodes){ if(item.content && item.content.number) existingItems.set(item.content.number, item); } | |
| const mAdd = `mutation($project:ID!,$content:ID!){ addProjectV2ItemById(input:{projectId:$project contentId:$content}){ item { id } } }`; | |
| const mUpdate = `mutation($project:ID!,$item:ID!,$field:ID!,$option:String!){ updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{singleSelectOptionId:$option}}){ projectV2Item { id } } }`; | |
| for(const issueNumber of issueSet){ | |
| let item = existingItems.get(issueNumber); | |
| let issueNode = null; | |
| if(!item){ | |
| issueNode = await ensureIssueNodeId(issueNumber); | |
| if(!issueNode){ core.warning(`Issue #${issueNumber} not found`); continue; } | |
| try { await github.graphql(mAdd,{ project: projectId, content: issueNode.id }); core.info(`Added issue #${issueNumber} to project`); } catch(e){ core.warning(`Add failed for #${issueNumber}: ${e.message}`); } | |
| } else { | |
| // Retrieve issue state | |
| issueNode = { id: item.content.id, number: item.content.number, state: item.content.state }; | |
| } | |
| if(!issueNode){ issueNode = await ensureIssueNodeId(issueNumber); } | |
| const desired = issueNode.state === 'CLOSED' ? statusDone : statusInProg; | |
| if(!optMap[desired]) { core.warning(`Desired status ${desired} option missing`); continue; } | |
| // Need item id; refresh to capture newly added items when necessary | |
| if(!item){ | |
| const refresh = await github.graphql(qProject,{ login, number: projectNumber }); | |
| for(const it of refresh.user.projectV2.items.nodes){ if(it.content && it.content.number) existingItems.set(it.content.number, it); } | |
| item = existingItems.get(issueNumber); | |
| } | |
| if(item){ | |
| try { await github.graphql(mUpdate,{ project: projectId, item: item.id, field: statusField.id, option: optMap[desired] }); core.info(`Set #${issueNumber} -> ${desired}`); } catch(e){ core.warning(`Status update failed for #${issueNumber}: ${e.message}`); } | |
| } | |
| } | |
| core.info('Sync complete.'); | |
| resp=$(gh api graphql -f query="$projectQuery" -F login="$LOGIN" -F number=$PROJECT_NUM) | |
| itemId=$(echo "$resp" | jq -r --arg n "$issue" '.data.user.projectV2.items.nodes[] | select(.content.number==($n|tonumber)) | .id') | |
| fi | |
| gh api graphql -f query="$updateMutation" -f project=$projectId -f item=$itemId -f field=$statusFieldId -f option=$optInProg >/dev/null || true | |
| done | |
| finalize-merged: | |
| name: Mark closed issues Done after merge | |
| if: github.event_name == 'pull_request' && github.event.pull_request.merged == true | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Extract issues from PR body/title | |
| id: issues | |
| run: | | |
| body="${{ github.event.pull_request.body }}\n${{ github.event.pull_request.title }}" | |
| issues=$(echo "$body" | grep -Eo '#[0-9]+' | tr -d '#' | sort -u || true) | |
| echo "issues=$issues" >> $GITHUB_OUTPUT | |
| - name: Install gh + jq | |
| if: steps.issues.outputs.issues != '' | |
| run: | | |
| sudo apt-get update && sudo apt-get install -y jq | |
| curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg | |
| sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg | |
| echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null | |
| sudo apt-get update && sudo apt-get install gh -y | |
| - name: Set Status=Done | |
| if: steps.issues.outputs.issues != '' | |
| env: | |
| ISSUES: ${{ steps.issues.outputs.issues }} | |
| run: | | |
| gh auth status || gh auth login --with-token <<<"${GITHUB_TOKEN}" | |
| LOGIN=${{ env.GITHUB_USER }} | |
| PROJECT_NUM=${{ env.PROJECT_NUMBER }} | |
| projectQuery='query($login:String!,$number:Int!){ user(login:$login){ projectV2(number:$number){ id fields(first:50){ nodes { __typename ... on ProjectV2SingleSelectField { id name options { id name } } } } items(first:200){ nodes { id content { __typename ... on Issue { number id state } } } } } } }' | |
| resp=$(gh api graphql -f query="$projectQuery" -F login="$LOGIN" -F number=$PROJECT_NUM) | |
| projectId=$(echo "$resp" | jq -r '.data.user.projectV2.id') | |
| statusFieldId=$(echo "$resp" | jq -r '.data.user.projectV2.fields.nodes[] | select(.name=="Status") | .id') | |
| optDone=$(echo "$resp" | jq -r '.data.user.projectV2.fields.nodes[] | select(.name=="Status") | .options[] | select(.name=="'"${STATUS_DONE_NAME}"'") | .id') | |
| updateMutation='mutation($project:ID!,$item:ID!,$field:ID!,$option: String!){ updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{singleSelectOptionId:$option}}){ projectV2Item { id } } }' | |
| for issue in $ISSUES; do | |
| itemId=$(echo "$resp" | jq -r --arg n "$issue" '.data.user.projectV2.items.nodes[] | select(.content.number==($n|tonumber)) | .id') | |
| if [ -n "$itemId" ]; then | |
| gh api graphql -f query="$updateMutation" -f project=$projectId -f item=$itemId -f field=$statusFieldId -f option=$optDone >/dev/null || true | |
| fi | |
| done | |
| notes: | |
| name: Guidance (no-op) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Display guidance | |
| run: | | |
| echo "Project Sync workflow installed. IMPORTANT:" | |
| echo "1. Update PROJECT_NUMBER in the workflow to match your project's number." | |
| echo "2. Ensure the project has a 'Status' single-select field with options: To do, In Progress, Done." | |
| echo "3. (Optional) Configure built-in project workflows: On item added -> Status=To do; On item closed -> Status=Done." |