Skip to content

Project Automation #116

Project Automation

Project Automation #116

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