From 8594e175f1896715c4da17a6e171ff9432e14de3 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 8 Mar 2026 00:49:08 +0100 Subject: [PATCH] feat: improve CI and project automation workflows (#390) - Harden docker/release workflow with manual release guardrails and concurrency - Add stale issue cleanup workflow (issues only) - Add project field sync workflow from issue labels - Add weekly triage report workflow - Add CODEOWNERS for automatic review routing --- .github/CODEOWNERS | 11 ++ .github/workflows/close-inactive-issues.yml | 27 ++++ .github/workflows/docker-build.yml | 65 +++++++-- .github/workflows/sync-project-fields.yml | 144 ++++++++++++++++++++ .github/workflows/weekly-triage-report.yml | 77 +++++++++++ 5 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/close-inactive-issues.yml create mode 100644 .github/workflows/sync-project-fields.yml create mode 100644 .github/workflows/weekly-triage-report.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..eeb0a0e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# MedAssist ownership +# This routes review requests automatically to the maintainer. + +* @DanielVolz + +# Explicit domains for clarity +/backend/ @DanielVolz +/frontend/ @DanielVolz +/.github/ @DanielVolz +/doku/ @DanielVolz +/docs/ @DanielVolz diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml new file mode 100644 index 0000000..98c9975 --- /dev/null +++ b/.github/workflows/close-inactive-issues.yml @@ -0,0 +1,27 @@ +name: Close inactive issues + +on: + schedule: + - cron: "30 1 * * *" + workflow_dispatch: + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Mark and close stale issues + uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-issue-stale: 30 + days-before-issue-close: 14 + stale-issue-label: stale + stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." + close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." + days-before-pr-stale: -1 + days-before-pr-close: -1 + exempt-issue-labels: pinned,security + operations-per-run: 200 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index cef0390..754a3d1 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -13,9 +13,18 @@ on: workflow_dispatch: inputs: tag: - description: 'Image tag (leave empty for "latest")' + description: 'Image/release tag (e.g. v1.19.1 or latest)' required: false default: '' + create_release: + description: 'Create GitHub release entry (requires tag starting with v)' + required: false + default: false + type: boolean + +concurrency: + group: docker-build-${{ github.ref }} + cancel-in-progress: true # Default minimal permissions permissions: @@ -89,12 +98,12 @@ jobs: sbom: false # ============================================================================= - # Create GitHub Release (only on tag push) + # Create GitHub Release (on tag push or manual dispatch with create_release) # ============================================================================= create-release: runs-on: ubuntu-latest needs: build-and-push - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true') permissions: contents: write @@ -104,10 +113,31 @@ jobs: with: fetch-depth: 0 # Fetch all history for changelog generation + - name: Resolve current tag + id: current_tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + CURRENT_TAG="${{ github.event.inputs.tag }}" + else + CURRENT_TAG="${GITHUB_REF#refs/tags/}" + fi + + if [ -z "$CURRENT_TAG" ]; then + echo "Release tag is required. Provide workflow_dispatch input 'tag'." + exit 1 + fi + + if [[ "$CURRENT_TAG" != v* ]]; then + echo "Release tag must start with 'v' (example: v1.19.1)." + exit 1 + fi + + echo "value=$CURRENT_TAG" >> "$GITHUB_OUTPUT" + - name: Check if release exists id: check_release run: | - CURRENT_TAG=${GITHUB_REF#refs/tags/} + CURRENT_TAG="${{ steps.current_tag.outputs.value }}" if gh release view "$CURRENT_TAG" &>/dev/null; then echo "exists=true" >> $GITHUB_OUTPUT echo "Release $CURRENT_TAG already exists, skipping creation" @@ -121,25 +151,36 @@ jobs: if: steps.check_release.outputs.exists == 'false' id: prev_tag run: | - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + CURRENT_TAG="${{ steps.current_tag.outputs.value }}" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -vx "$CURRENT_TAG" | head -1 || true) + else + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + fi + echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT - name: Generate changelog if: steps.check_release.outputs.exists == 'false' id: changelog run: | - CURRENT_TAG=${GITHUB_REF#refs/tags/} + CURRENT_TAG="${{ steps.current_tag.outputs.value }}" PREV_TAG="${{ steps.prev_tag.outputs.tag }}" - echo "## What's Changed" > changelog.md + echo "## What's New" > changelog.md + echo "" >> changelog.md + echo "This release includes updates and fixes shipped with ${CURRENT_TAG}." >> changelog.md + echo "" >> changelog.md + echo "### Highlights" >> changelog.md echo "" >> changelog.md if [ -n "$PREV_TAG" ]; then - # Get commits between tags - git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"* %s (%h)" --no-merges >> changelog.md + echo "Changes from ${PREV_TAG} to ${CURRENT_TAG}:" >> changelog.md + git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"- %s (%h)" --no-merges >> changelog.md else - # First release - get recent commits - git log -20 --pretty=format:"* %s (%h)" --no-merges >> changelog.md + echo "Recent shipped commits:" >> changelog.md + git log -20 --pretty=format:"- %s (%h)" --no-merges >> changelog.md fi echo "" >> changelog.md @@ -157,6 +198,8 @@ jobs: if: steps.check_release.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.current_tag.outputs.value }} + target_commitish: ${{ github.sha }} body_path: changelog.md generate_release_notes: false draft: false diff --git a/.github/workflows/sync-project-fields.yml b/.github/workflows/sync-project-fields.yml new file mode 100644 index 0000000..12ff9ab --- /dev/null +++ b/.github/workflows/sync-project-fields.yml @@ -0,0 +1,144 @@ +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@v8 + 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(', ')}]`); + } diff --git a/.github/workflows/weekly-triage-report.yml b/.github/workflows/weekly-triage-report.yml new file mode 100644 index 0000000..8dd8291 --- /dev/null +++ b/.github/workflows/weekly-triage-report.yml @@ -0,0 +1,77 @@ +name: Weekly Triage Report + +on: + schedule: + - cron: '0 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + weekly-report: + runs-on: ubuntu-latest + steps: + - name: Build weekly summary + id: summary + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const weekLabel = new Date().toISOString().split('T')[0]; + + const q = async (query) => { + const res = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 1 }); + return res.data.total_count; + }; + + const openIssues = await q(`repo:${owner}/${repo} is:issue is:open`); + const newIssues = await q(`repo:${owner}/${repo} is:issue created:>=${since}`); + const bugs = await q(`repo:${owner}/${repo} is:issue is:open label:bug`); + const enhancements = await q(`repo:${owner}/${repo} is:issue is:open label:enhancement`); + const triage = await q(`repo:${owner}/${repo} is:issue is:open label:triage`); + const stale = await q(`repo:${owner}/${repo} is:issue is:open label:stale`); + const unassigned = await q(`repo:${owner}/${repo} is:issue is:open no:assignee`); + + const body = [ + `## Weekly Triage Report (${weekLabel})`, + '', + `- Open issues: **${openIssues}**`, + `- New issues (last 7 days): **${newIssues}**`, + `- Open bugs: **${bugs}**`, + `- Open enhancements: **${enhancements}**`, + `- In triage: **${triage}**`, + `- Stale: **${stale}**`, + `- Unassigned: **${unassigned}**`, + '', + '### Quick Links', + `- Triage queue: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Atriage`, + `- Stale issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Astale`, + `- Unassigned issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee`, + ].join('\n'); + + core.setOutput('title', `Weekly Triage Report - ${weekLabel}`); + core.setOutput('body', body); + + - name: Publish report issue + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const title = `${{ steps.summary.outputs.title }}`; + const body = `${{ steps.summary.outputs.body }}`; + + await github.rest.issues.create({ + owner, + repo, + title, + body, + labels: ['triage'] + });