Project Automation #116
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 Automation | |
| on: | |
| issues: | |
| types: [opened, labeled, unlabeled, closed, reopened] | |
| pull_request: | |
| types: [opened, ready_for_review, closed] | |
| push: | |
| branches: [main] | |
| paths: | |
| - '.github/labels.yml' | |
| tags: | |
| - 'v*' | |
| schedule: | |
| - cron: '0 9 * * 1' # Weekly stale check, Monday 9am UTC | |
| env: | |
| PROJECT_NUMBER: 4 | |
| jobs: | |
| # ============================================================ | |
| # LABEL SYNC: Sync labels from labels.yml on push | |
| # ============================================================ | |
| label-sync: | |
| if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install js-yaml | |
| run: npm install js-yaml | |
| - name: Sync labels | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const yaml = require('js-yaml'); | |
| const labelsFile = '.github/labels.yml'; | |
| if (!fs.existsSync(labelsFile)) { | |
| console.log(`Labels file not found: ${labelsFile}`); | |
| return; | |
| } | |
| const content = fs.readFileSync(labelsFile, 'utf8'); | |
| const labelDefs = yaml.load(content); | |
| if (!Array.isArray(labelDefs)) { | |
| console.log('Labels file must contain an array of label definitions'); | |
| return; | |
| } | |
| const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| per_page: 100 | |
| }); | |
| const existingMap = new Map(existingLabels.map(l => [l.name, l])); | |
| for (const label of labelDefs) { | |
| if (!label.name) continue; | |
| const existing = existingMap.get(label.name); | |
| const color = (label.color || '').replace('#', ''); | |
| const description = label.description || ''; | |
| if (existing) { | |
| const needsUpdate = | |
| existing.color.toLowerCase() !== color.toLowerCase() || | |
| (existing.description || '') !== description; | |
| if (needsUpdate) { | |
| console.log(`Updating: ${label.name}`); | |
| await github.rest.issues.updateLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: label.name, | |
| color: color, | |
| description: description | |
| }); | |
| } else { | |
| console.log(`Unchanged: ${label.name}`); | |
| } | |
| } else { | |
| console.log(`Creating: ${label.name}`); | |
| await github.rest.issues.createLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: label.name, | |
| color: color, | |
| description: description | |
| }); | |
| } | |
| } | |
| console.log('Label sync complete'); | |
| # ============================================================ | |
| # AUTO-LABEL: Label new issues based on content (skips pre-labeled) | |
| # ============================================================ | |
| auto-label: | |
| if: github.event_name == 'issues' && github.event.action == 'opened' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| steps: | |
| - name: Auto-label based on content | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const issue = context.payload.issue; | |
| // Skip if issue already has meaningful labels (was pre-triaged) | |
| const existingLabels = issue.labels.map(l => l.name); | |
| const hasTypeLabel = existingLabels.some(l => l.startsWith('type:')); | |
| const hasScopeLabel = existingLabels.some(l => l.startsWith('scope:')); | |
| const hasPriorityLabel = existingLabels.some(l => l.startsWith('priority:')); | |
| if (hasTypeLabel || hasScopeLabel || hasPriorityLabel) { | |
| console.log('Issue already has labels, skipping auto-label'); | |
| return; | |
| } | |
| const text = `${issue.title} ${issue.body}`.toLowerCase(); | |
| const labels = []; | |
| // Type detection - use word boundaries for precision | |
| if (text.match(/\b(bug|error|fail(ed|ing|ure)?|broken|crash)\b/)) { | |
| labels.push('type:bug'); | |
| } else if (text.match(/^feat[:(]/) || text.match(/\b(feature|enhancement)\b/)) { | |
| labels.push('type:feature'); | |
| } else if (text.match(/\b(documentation|readme|typo)\b/)) { | |
| labels.push('type:docs'); | |
| } | |
| // Scope detection - more specific patterns | |
| if (text.match(/\bgateway\b/i)) labels.push('scope:gateway'); | |
| if (text.match(/\bintelligence\b/i)) labels.push('scope:intelligence'); | |
| if (text.match(/\b(fhir|hl7|epic)\b/i)) labels.push('scope:fhir'); | |
| if (text.match(/\bpdf\b/i)) labels.push('scope:pdf'); | |
| if (text.match(/\b(llm|gpt-?\d|claude|openai|embedding)\b/i)) labels.push('scope:llm'); | |
| if (labels.length > 0) { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| labels: labels | |
| }); | |
| console.log(`Added labels: ${labels.join(', ')}`); | |
| } else { | |
| console.log('No labels matched'); | |
| } | |
| # ============================================================ | |
| # AUTO-ASSIGN: Assign PR author as assignee | |
| # ============================================================ | |
| auto-assign-author: | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'opened' && | |
| github.event.pull_request.user.type != 'Bot' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: write | |
| steps: | |
| - name: Assign PR author | |
| run: gh pr edit "$PR_URL" --add-assignee "$AUTHOR" | |
| env: | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| AUTHOR: ${{ github.event.pull_request.user.login }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # ============================================================ | |
| # STALE MANAGEMENT: Mark and close inactive issues | |
| # ============================================================ | |
| stale: | |
| if: github.event_name == 'schedule' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| steps: | |
| - uses: actions/stale@v9 | |
| with: | |
| stale-issue-message: | | |
| This issue has been automatically marked as stale because it has not had | |
| recent activity. It will be closed in 14 days if no further activity occurs. | |
| close-issue-message: | | |
| This issue was closed because it has been stale for 14 days with no activity. | |
| Feel free to reopen if this is still relevant. | |
| stale-issue-label: 'status:stale' | |
| exempt-issue-labels: 'priority:high,status:blocked' | |
| days-before-stale: 60 | |
| days-before-close: 14 | |
| operations-per-run: 30 | |
| # ============================================================ | |
| # PR AUTOMATION: Auto-merge Renovate PRs | |
| # ============================================================ | |
| auto-merge-renovate: | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action != 'closed' && | |
| github.event.pull_request.user.login == 'renovate[bot]' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Enable auto-merge for Renovate PRs | |
| run: gh pr merge --auto --squash "$PR_URL" | |
| env: | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # ============================================================ | |
| # PROJECT: Add PRs to Project #4 with workstream assignment | |
| # ============================================================ | |
| add-pr-to-project: | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action != 'closed' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout for file detection | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Add PR to project | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.PROJECT_TOKEN }} | |
| script: | | |
| const pr = context.payload.pull_request; | |
| // Get project and field info | |
| const projectResult = await github.graphql(` | |
| query($owner: String!, $repo: String!, $number: Int!) { | |
| repository(owner: $owner, name: $repo) { | |
| projectV2(number: $number) { | |
| id | |
| fields(first: 20) { | |
| nodes { | |
| ... on ProjectV2SingleSelectField { | |
| id | |
| name | |
| options { id name } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| number: parseInt(process.env.PROJECT_NUMBER) | |
| }); | |
| const project = projectResult.repository.projectV2; | |
| if (!project) { | |
| console.log(`Project #${process.env.PROJECT_NUMBER} not found`); | |
| return; | |
| } | |
| const workstreamField = project.fields.nodes.find(f => f.name === 'Workstream'); | |
| // Add PR to project | |
| const addItemMutation = ` | |
| mutation($projectId: ID!, $contentId: ID!) { | |
| addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { | |
| item { id } | |
| } | |
| } | |
| `; | |
| const addResult = await github.graphql(addItemMutation, { | |
| projectId: project.id, | |
| contentId: pr.node_id | |
| }); | |
| const itemId = addResult.addProjectV2ItemById.item.id; | |
| console.log(`Added PR #${pr.number} to project, item ID: ${itemId}`); | |
| // Determine workstream based on changed files | |
| const { data: files } = await github.rest.pulls.listFiles({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| per_page: 100 | |
| }); | |
| const touchesGateway = files.some(f => f.filename.startsWith('apps/gateway/')); | |
| const touchesIntelligence = files.some(f => f.filename.startsWith('apps/intelligence/')); | |
| let workstreamValue = null; | |
| if (touchesGateway && !touchesIntelligence) { | |
| workstreamValue = 'Gateway (.NET)'; | |
| } else if (touchesIntelligence && !touchesGateway) { | |
| workstreamValue = 'Intelligence (Python)'; | |
| } | |
| // If both or neither, leave unset | |
| if (workstreamValue && workstreamField) { | |
| const option = workstreamField.options.find(o => o.name === workstreamValue); | |
| if (option) { | |
| const updateFieldMutation = ` | |
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { | |
| updateProjectV2ItemFieldValue(input: { | |
| projectId: $projectId | |
| itemId: $itemId | |
| fieldId: $fieldId | |
| value: { singleSelectOptionId: $optionId } | |
| }) { | |
| projectV2Item { id } | |
| } | |
| } | |
| `; | |
| await github.graphql(updateFieldMutation, { | |
| projectId: project.id, | |
| itemId: itemId, | |
| fieldId: workstreamField.id, | |
| optionId: option.id | |
| }); | |
| console.log(`Set Workstream to: ${workstreamValue}`); | |
| } | |
| } | |
| # ============================================================ | |
| # PROJECT: Add issues to Project #4 with priority and workstream | |
| # ============================================================ | |
| add-issue-to-project: | |
| if: github.event_name == 'issues' && github.event.action == 'opened' | |
| needs: auto-label | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Add issue to project | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.PROJECT_TOKEN }} | |
| script: | | |
| const issue = context.payload.issue; | |
| // Get project and field info | |
| const projectResult = await github.graphql(` | |
| query($owner: String!, $repo: String!, $number: Int!) { | |
| repository(owner: $owner, name: $repo) { | |
| projectV2(number: $number) { | |
| id | |
| fields(first: 20) { | |
| nodes { | |
| ... on ProjectV2SingleSelectField { | |
| id | |
| name | |
| options { id name } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| number: parseInt(process.env.PROJECT_NUMBER) | |
| }); | |
| const project = projectResult.repository.projectV2; | |
| if (!project) { | |
| console.log(`Project #${process.env.PROJECT_NUMBER} not found`); | |
| return; | |
| } | |
| const priorityField = project.fields.nodes.find(f => f.name === 'Priority'); | |
| const workstreamField = project.fields.nodes.find(f => f.name === 'Workstream'); | |
| // Add issue to project | |
| const addItemMutation = ` | |
| mutation($projectId: ID!, $contentId: ID!) { | |
| addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { | |
| item { id } | |
| } | |
| } | |
| `; | |
| const addResult = await github.graphql(addItemMutation, { | |
| projectId: project.id, | |
| contentId: issue.node_id | |
| }); | |
| const itemId = addResult.addProjectV2ItemById.item.id; | |
| console.log(`Added issue #${issue.number} to project, item ID: ${itemId}`); | |
| // Re-fetch issue to get labels (auto-triage may have added them) | |
| const { data: freshIssue } = await github.rest.issues.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number | |
| }); | |
| const labels = freshIssue.labels.map(l => l.name); | |
| // Set Priority based on labels | |
| if (priorityField) { | |
| let priorityValue = 'Medium'; // Default | |
| if (labels.includes('priority:high')) { | |
| priorityValue = 'High'; | |
| } else if (labels.includes('priority:low')) { | |
| priorityValue = 'Low'; | |
| } | |
| const option = priorityField.options.find(o => o.name === priorityValue); | |
| if (option) { | |
| const updateFieldMutation = ` | |
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { | |
| updateProjectV2ItemFieldValue(input: { | |
| projectId: $projectId | |
| itemId: $itemId | |
| fieldId: $fieldId | |
| value: { singleSelectOptionId: $optionId } | |
| }) { | |
| projectV2Item { id } | |
| } | |
| } | |
| `; | |
| await github.graphql(updateFieldMutation, { | |
| projectId: project.id, | |
| itemId: itemId, | |
| fieldId: priorityField.id, | |
| optionId: option.id | |
| }); | |
| console.log(`Set Priority to: ${priorityValue}`); | |
| } | |
| } | |
| // Set Workstream based on scope labels | |
| if (workstreamField) { | |
| let workstreamValue = null; | |
| if (labels.includes('scope:gateway')) { | |
| workstreamValue = 'Gateway (.NET)'; | |
| } else if (labels.includes('scope:intelligence')) { | |
| workstreamValue = 'Intelligence (Python)'; | |
| } | |
| if (workstreamValue) { | |
| const option = workstreamField.options.find(o => o.name === workstreamValue); | |
| if (option) { | |
| const updateFieldMutation = ` | |
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { | |
| updateProjectV2ItemFieldValue(input: { | |
| projectId: $projectId | |
| itemId: $itemId | |
| fieldId: $fieldId | |
| value: { singleSelectOptionId: $optionId } | |
| }) { | |
| projectV2Item { id } | |
| } | |
| } | |
| `; | |
| await github.graphql(updateFieldMutation, { | |
| projectId: project.id, | |
| itemId: itemId, | |
| fieldId: workstreamField.id, | |
| optionId: option.id | |
| }); | |
| console.log(`Set Workstream to: ${workstreamValue}`); | |
| } | |
| } | |
| } | |
| # ============================================================ | |
| # RELEASE AUTOMATION: Generate changelog and release | |
| # ============================================================ | |
| release: | |
| if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Generate changelog | |
| id: changelog | |
| uses: orhun/git-cliff-action@v3 | |
| with: | |
| config: .github/cliff.toml | |
| args: --latest --strip header | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| body: ${{ steps.changelog.outputs.content }} | |
| generate_release_notes: false |