From 840718e3c0188d2dfd67c569f217a7274c44b5b8 Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:23:45 +0100 Subject: [PATCH] Add actions to automatically update issues and tasks for the database boards (#4113) --- .github/workflows/README.md | 110 +++++++ .../database-projects-issues-workflow.yml | 163 ++++++++++ .../database-projects-pr-workflow.yml | 283 ++++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/database-projects-issues-workflow.yml create mode 100644 .github/workflows/database-projects-pr-workflow.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000000..940fd9ca2a --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,110 @@ +# GitHub Workflows + +## Database Team PR Automation + +**File:** `database-projects-pr-workflow.yml` + +This workflow automatically updates the Database Team's GitHub Project board based on PR events. + +### How it works + +The workflow triggers on PR events (opened, review requested, review submitted, merged, etc.) and automatically: + +1. **Checks domain labels** - Only processes PRs with labels starting with `domain::database` or `domain::core` +2. **Adds PR to project** - Ensures the PR is added to the Database Team project board +3. **Updates Status field** - Sets the PR status based on its state: + - `In Progress` - PR is a draft + - `In Review` - PR is ready for review + - `Done` - PR has been merged +4. **Updates Review Status field** - Sets the review status based on review state: + - `Awaiting` - Set when: + - PR switches from draft to ready for review + - There are pending review requests + - A reviewer who requested changes has been re-requested for review + - No reviewers have been requested yet and no approvals exist + - `Feedback` - Changes have been requested by a reviewer + - `Merge` - No pending review requests and at least one approval exists +5. **Updates linked issues** - Also updates the Status field (not Review Status) of any issues linked to the PR. This assumes a 1:1 relation between PRs and issues. + +### State transitions + +``` +Draft PR opened/converted to draft + → Status: In Progress + → Review Status: (cleared) + +PR ready for review / review requested + → Status: In Review + → Review Status: Awaiting + +Changes requested + → Status: In Review + → Review Status: Feedback + +Changes requested reviewer re-requested for review + → Status: In Review + → Review Status: Awaiting + +PR approved + → Status: In Review + → Review Status: Merge + +PR merged + → Status: Done + → Review Status: (cleared) +``` + +### Configuration + +The workflow uses these environment variables (defined at the top of the file): + +| Variable | Description | +|----------|-------------| +| `PROJECT_NUMBER` | The GitHub Project number (currently `3`) | +| `DOMAIN_LABELS` | Labels that trigger the workflow (`domain::database`, `domain::core`) | +| `STATUS_FIELD_NAME` | Name of the Status field in the project | +| `REVIEW_STATUS_FIELD_NAME` | Name of the Review Status field in the project | + +### Requirements + +- A GitHub token with project write permissions stored as `DATABASE_PROJECT_WORKFLOW_TOKEN` secret +- The project must have `Status` and `Review Status` single-select fields with the expected options + +### Manual trigger + +You can manually trigger the workflow for a specific PR using the "Run workflow" button in the Actions tab, providing the PR number. + +--- + +## Database Team Issue Automation + +**File:** `database-projects-issues-workflow.yml` + +This workflow automatically adds new issues to the Database Team's GitHub Project board. + +### How it works + +The workflow triggers when an issue is opened or labeled, and: + +1. **Checks domain labels** - Only processes issues with labels starting with `domain::database` or `domain::core` +2. **Checks if already in project** - Skips if the issue is already on the project board +3. **Adds issue to project** - Adds the issue to the Database Team project board +4. **Sets Status to Todo** - Sets the initial status to `Todo` + +### Configuration + +| Variable | Description | +|----------|-------------| +| `PROJECT_NUMBER` | The GitHub Project number (currently `3`) | +| `DOMAIN_LABELS` | Labels that trigger the workflow (`domain::database`, `domain::core`) | +| `STATUS_FIELD_NAME` | Name of the Status field in the project | +| `STATUS_TODO` | The initial status value for new issues (`Todo`) | + +### Requirements + +- A GitHub token with project write permissions stored as `DATABASE_PROJECT_WORKFLOW_TOKEN` secret +- The project must have a `Status` single-select field with a `Todo` option + +### Manual trigger + +You can manually trigger the workflow for a specific issue using the "Run workflow" button in the Actions tab, providing the issue number. diff --git a/.github/workflows/database-projects-issues-workflow.yml b/.github/workflows/database-projects-issues-workflow.yml new file mode 100644 index 0000000000..04dc2670ae --- /dev/null +++ b/.github/workflows/database-projects-issues-workflow.yml @@ -0,0 +1,163 @@ +name: Update Database Team Project Fields + +env: + PROJECT_NUMBER: '3' + DOMAIN_LABELS: '["domain::database", "domain::core"]' + STATUS_FIELD_NAME: 'Status' + STATUS_TODO: 'Todo' + +on: + issues: + types: [labeled, opened] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to process' + required: true + type: number + +jobs: + update-project-fields: + runs-on: ubuntu-latest + steps: + - name: Add Issue to Project + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.DATABASE_PROJECT_WORKFLOW_TOKEN }} + script: | + const issueNumber = context.eventName === 'workflow_dispatch' + ? ${{ github.event.inputs.issue_number || 0 }} + : context.payload.issue.number; + + console.log(`Processing Issue #${issueNumber} (event: ${context.eventName}/${context.payload.action || 'manual'})`); + + // ============================================================ + // FETCH ALL DATA WITH SINGLE GRAPHQL QUERY + // ============================================================ + + const data = await github.graphql(` + query($owner: String!, $repo: String!, $issue: Int!, $projectNumber: Int!) { + organization(login: $owner) { + projectV2(number: $projectNumber) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id, name } + } + } + } + } + } + repository(owner: $owner, name: $repo) { + issue(number: $issue) { + id + labels(first: 20) { + nodes { name } + } + projectItems(first: 10) { + nodes { + id + project { id } + } + } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + issue: issueNumber, + projectNumber: parseInt('${{ env.PROJECT_NUMBER }}') + }); + + const project = data.organization.projectV2; + const issue = data.repository.issue; + + // ============================================================ + // CHECK DOMAIN LABELS (early exit if not relevant) + // ============================================================ + + const labels = issue.labels.nodes.map(l => l.name); + const domainLabels = ${{ env.DOMAIN_LABELS }}; + const hasDomainLabel = labels.some(label => + domainLabels.some(domain => label.startsWith(domain)) + ); + + if (!hasDomainLabel) { + console.log(`Issue #${issueNumber} has no domain label (${labels.join(', ') || 'none'}), skipping`); + return; + } + + console.log(`Issue has domain label: ${labels.filter(l => l.startsWith('domain::')).join(', ')}`); + + // ============================================================ + // CHECK IF ALREADY IN PROJECT + // ============================================================ + + const existingItem = issue.projectItems.nodes.find( + item => item.project.id === project.id + ); + + if (existingItem) { + console.log('Issue is already in the project'); + return; + } + + // ============================================================ + // ADD ISSUE TO PROJECT + // ============================================================ + + const addResult = await github.graphql(` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { + projectId: $projectId + contentId: $contentId + }) { + item { + id + } + } + } + `, { + projectId: project.id, + contentId: issue.id + }); + + const itemId = addResult.addProjectV2ItemById.item.id; + console.log(`Issue added to project (item: ${itemId})`); + + // ============================================================ + // SET STATUS TO TODO + // ============================================================ + + const statusField = project.fields.nodes.find(f => f.name === '${{ env.STATUS_FIELD_NAME }}'); + const todoOption = statusField?.options.find(o => o.name === '${{ env.STATUS_TODO }}'); + + if (!statusField || !todoOption) { + console.log('Warning: Status field or Todo option not found'); + return; + } + + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: $value + }) { + projectV2Item { id } + } + } + `, { + projectId: project.id, + itemId, + fieldId: statusField.id, + value: { singleSelectOptionId: todoOption.id } + }); + + console.log(`Status → ${{ env.STATUS_TODO }}`); + console.log('Completed!'); diff --git a/.github/workflows/database-projects-pr-workflow.yml b/.github/workflows/database-projects-pr-workflow.yml new file mode 100644 index 0000000000..e7579eea5f --- /dev/null +++ b/.github/workflows/database-projects-pr-workflow.yml @@ -0,0 +1,283 @@ +name: Database Team PR Automation + +env: + PROJECT_NUMBER: '3' + DOMAIN_LABELS: '["domain::database", "domain::core"]' + STATUS_FIELD_NAME: 'Status' + REVIEW_STATUS_FIELD_NAME: 'Review Status' + STATUS_IN_PROGRESS: 'In Progress' + STATUS_IN_REVIEW: 'In Review' + STATUS_DONE: 'Done' + REVIEW_AWAITING: 'Awaiting' + REVIEW_FEEDBACK: 'Feedback' + REVIEW_MERGE: 'Merge' + +on: + pull_request: + types: [opened, reopened, ready_for_review, converted_to_draft, review_requested, review_request_removed, synchronize, labeled, closed] + pull_request_review: + types: [submitted, dismissed] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to process' + required: true + type: number + +jobs: + update-status: + runs-on: ubuntu-latest + steps: + - name: Update PR Project Status + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.DATABASE_PROJECT_WORKFLOW_TOKEN }} + script: | + const prNumber = context.eventName === 'workflow_dispatch' + ? ${{ github.event.inputs.pr_number || 0 }} + : context.payload.pull_request.number; + + console.log(`Processing PR #${prNumber} (event: ${context.eventName}/${context.payload.action || 'manual'})`); + + // ============================================================ + // FETCH ALL DATA WITH SINGLE GRAPHQL QUERY + // ============================================================ + + const data = await github.graphql(` + query($owner: String!, $repo: String!, $pr: Int!, $projectNumber: Int!) { + organization(login: $owner) { + projectV2(number: $projectNumber) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id, name } + } + } + } + } + } + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + id + isDraft + merged + mergeable + reviewDecision + labels(first: 20) { + nodes { name } + } + reviewRequests(first: 10) { + nodes { + requestedReviewer { + ... on User { login } + ... on Team { name } + } + } + } + latestOpinionatedReviews(first: 10) { + nodes { + state + author { login } + } + } + closingIssuesReferences(first: 10) { + nodes { id, number } + } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + pr: prNumber, + projectNumber: parseInt('${{ env.PROJECT_NUMBER }}') + }); + + const project = data.organization.projectV2; + const pr = data.repository.pullRequest; + + // ============================================================ + // CHECK DOMAIN LABELS (early exit if not relevant) + // ============================================================ + + const labels = pr.labels.nodes.map(l => l.name); + const domainLabels = ${{ env.DOMAIN_LABELS }}; + const hasDomainLabel = labels.some(label => + domainLabels.some(domain => label.startsWith(domain)) + ); + + if (!hasDomainLabel) { + console.log(`PR #${prNumber} has no domain label (${labels.join(', ') || 'none'}), skipping`); + return; + } + + console.log(`PR has domain label: ${labels.filter(l => l.startsWith('domain::')).join(', ')}`); + + const statusField = project.fields.nodes.find(f => f.name === '${{ env.STATUS_FIELD_NAME }}'); + const reviewStatusField = project.fields.nodes.find(f => f.name === '${{ env.REVIEW_STATUS_FIELD_NAME }}'); + + const fieldOptions = { + status: { + id: statusField.id, + 'In Progress': statusField.options.find(o => o.name === '${{ env.STATUS_IN_PROGRESS }}')?.id, + 'In Review': statusField.options.find(o => o.name === '${{ env.STATUS_IN_REVIEW }}')?.id, + 'Done': statusField.options.find(o => o.name === '${{ env.STATUS_DONE }}')?.id + }, + reviewStatus: { + id: reviewStatusField.id, + 'Awaiting': reviewStatusField.options.find(o => o.name === '${{ env.REVIEW_AWAITING }}')?.id, + 'Feedback': reviewStatusField.options.find(o => o.name === '${{ env.REVIEW_FEEDBACK }}')?.id, + 'Merge': reviewStatusField.options.find(o => o.name === '${{ env.REVIEW_MERGE }}')?.id + } + }; + + console.log(`PR state: isDraft=${pr.isDraft}, merged=${pr.merged}, reviewDecision=${pr.reviewDecision}, mergeable=${pr.mergeable}`); + + // ============================================================ + // HELPER FUNCTIONS + // ============================================================ + + async function getProjectItem(contentId) { + const result = await github.graphql(` + query($contentId: ID!) { + node(id: $contentId) { + ... on Issue { projectItems(first: 10) { nodes { id, project { id } } } } + ... on PullRequest { projectItems(first: 10) { nodes { id, project { id } } } } + } + } + `, { contentId }); + const item = result.node.projectItems.nodes.find(i => i.project.id === project.id); + return item ? item.id : null; + } + + async function addToProject(contentId) { + const result = await github.graphql(` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `, { projectId: project.id, contentId }); + return result.addProjectV2ItemById.item.id; + } + + async function ensureInProject(contentId) { + let itemId = await getProjectItem(contentId); + if (!itemId) { + console.log('Adding item to project...'); + itemId = await addToProject(contentId); + } + return itemId; + } + + async function updateField(itemId, fieldId, optionId) { + if (!optionId) return console.log('Warning: Option ID undefined, skipping'); + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: $value + }) { projectV2Item { id } } + } + `, { projectId: project.id, itemId, fieldId, value: { singleSelectOptionId: optionId } }); + } + + async function clearField(itemId, fieldId) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) { + clearProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId }) + { projectV2Item { id } } + } + `, { projectId: project.id, itemId, fieldId }); + } + + // ============================================================ + // CALCULATE STATE + // ============================================================ + + let status, reviewStatus; + + // Check if PR was merged + if (pr.merged) { + status = 'Done'; + reviewStatus = null; + console.log('PR merged → Done, clear Review Status'); + } else if (pr.isDraft) { + status = 'In Progress'; + reviewStatus = null; + console.log('Draft PR → In Progress, clear Review Status'); + } else { + status = 'In Review'; + + // Get pending reviewer identifiers (login for users, name for teams) + const pendingReviewers = pr.reviewRequests.nodes + .map(r => r.requestedReviewer?.login || r.requestedReviewer?.name) + .filter(Boolean); + + // Get reviewers who requested changes + const changesRequestedBy = pr.latestOpinionatedReviews.nodes + .filter(r => r.state === 'CHANGES_REQUESTED') + .map(r => r.author.login); + + // Check if any "changes requested" reviewer has been re-requested + const changesRequestedReviewerReRequested = changesRequestedBy.some( + reviewer => pendingReviewers.includes(reviewer) + ); + + if (pr.reviewDecision === 'CHANGES_REQUESTED' && changesRequestedReviewerReRequested) { + reviewStatus = 'Awaiting'; + console.log(`Changes requested but reviewer re-requested (${changesRequestedBy.join(', ')}) → Awaiting`); + } else if (pr.reviewDecision === 'CHANGES_REQUESTED') { + reviewStatus = 'Feedback'; + console.log('Changes requested → Feedback'); + } else if (pendingReviewers.length > 0) { + reviewStatus = 'Awaiting'; + console.log(`Has pending reviewers (${pendingReviewers.join(', ')}) → Awaiting`); + } else { + // No pending reviewers - check if at least one approval exists + const hasApproval = pr.latestOpinionatedReviews.nodes.some(r => r.state === 'APPROVED'); + if (hasApproval) { + reviewStatus = 'Merge'; + console.log('No pending reviewers + has approval → Merge'); + } else { + reviewStatus = 'Awaiting'; + console.log('No pending reviewers, no approval yet → Awaiting'); + } + } + } + + // ============================================================ + // UPDATE PROJECT FIELDS + // ============================================================ + + const prItemId = await ensureInProject(pr.id); + console.log(`PR project item: ${prItemId}`); + + await updateField(prItemId, fieldOptions.status.id, fieldOptions.status[status]); + console.log(`Status → ${status}`); + + if (reviewStatus) { + await updateField(prItemId, fieldOptions.reviewStatus.id, fieldOptions.reviewStatus[reviewStatus]); + console.log(`Review Status → ${reviewStatus}`); + } else { + await clearField(prItemId, fieldOptions.reviewStatus.id); + console.log('Review Status → cleared'); + } + + // ============================================================ + // UPDATE LINKED ISSUES + // ============================================================ + + const linkedIssues = pr.closingIssuesReferences.nodes; + if (linkedIssues.length > 0) { + console.log(`Updating ${linkedIssues.length} linked issue(s)...`); + for (const issue of linkedIssues) { + const itemId = await ensureInProject(issue.id); + await updateField(itemId, fieldOptions.status.id, fieldOptions.status[status]); + console.log(` Issue #${issue.number} → ${status}`); + } + } + + console.log('Completed!');