This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5651-86f865c7820e418d7a641957b3ab4b0ed6edfdb7 in repository https://gitbox.apache.org/repos/asf/texera.git
commit c305bb454346538c48cab300df55ea73352f592a Author: Julie Cao <[email protected]> AuthorDate: Sun Jun 21 11:12:23 2026 -0700 feat(comment-commands): auto suggest reviewers on pr open/update via git blame (#5651) <!-- Thanks for sending a pull request (PR)! Here are some tips for you: 1. If this is your first time, please read our contributor guidelines: [Contributing to Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md) 2. Ensure you have added or run the appropriate tests for your PR 3. If the PR is work in progress, mark it a draft on GitHub. 4. Please write your PR title to summarize what this PR proposes, we are following Conventional Commits style for PR titles as well. 5. Be sure to keep the PR description updated to reflect all changes. --> ### What changes were proposed in this PR? This PR adds an automatic reviewer suggestion CI job to `.github/workflows/comment-commands.yml`. When a PR is opened or updated (pull_request: opened, synchronize, reopened), the CI automatically runs `git blame -p` at the base commit on each changed file to identify who most recently touched that code. Candidates are split into two groups: - Committers — collaborators who can be formally review-requested via GitHub's API - Non-committer contributors — have context but cannot be review-requested; the author can @-mention them to notify The CI posts a comment in this format: `Suggested reviewers (based on git blame of changed files):` `Committers — can be formally requested: @ alice, @ bob` `Non-committer contributors — cc to notify: @ carol` `Use /request-review @ alice to request a review, or cc @ carol to notify them.` On every subsequent push, the job finds the existing suggestion comment (via a hidden HTML marker <!-- texera-reviewer-suggestion -->) and edits it in place, keeping the PR timeline clean. The CI never sends a review request on its own as the author must explicitly use /request-review @ user. Files with status added are skipped before git blame is attempted since they did not exist at the base commit. <!-- Please clarify what changes you are proposing. The purpose of this section is to outline the changes. Here are some tips for you: 1. If you propose a new API, clarify the use case for a new API. 2. If you fix a bug, you can clarify why it is a bug. 3. If it is a refactoring, clarify what has been changed. 3. It would be helpful to include a before-and-after comparison using screenshots or GIFs. 4. Please consider writing useful notes for better and faster reviews. --> ### Any related issues, documentation, discussions? Closes #5611 <!-- Please use this section to link other resources if not mentioned already. 1. If this PR fixes an issue, please include `Fixes #1234`, `Resolves #1234` or `Closes #1234`. If it is only related, simply mention the issue number. 2. If there is design documentation, please add the link. 3. If there is a discussion in the mailing list, please add the link. --> ### How was this PR tested? **Unit tests**: 83 tests across 9 suites were written locally (https://github.com/juliethecao/texera/tree/cc-test) to cover the core JavaScript logic extracted from the workflow: `git blame -p` output parsing, candidate ranking, comment body generation, find-or-update marker logic, author/bot exclusion, @ mention parsing, file status filtering, candidate accumulation, and MARKER integrity. Tests were not checked in as the logic lives inside a GitHub Actions script rather than a standalone module. **Manual CI test**: A test PR was opened on a personal fork (https://github.com/juliethecao/texera/pull/9) against the feature branch as the base. The suggest-reviewers job triggered on open, ran git blame on the changed files, and posted the suggestion comment. Closing and reopening the PR confirmed the comment was updated in place rather than duplicated. <!-- If tests were added, say they were added here. Or simply mention that if the PR is tested with existing test cases. Make sure to include/update test cases that check the changes thoroughly including negative and positive cases if possible. If it was tested in a way different from regular unit tests, please clarify how you tested step by step, ideally copy and paste-able, so that other reviewers can test and check, and descendants can verify in the future. If tests were not added, please describe why they were not added and/or why it was difficult to add. --> ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Sonnet 4.6 in compliance with ASF guidelines <!-- If generative AI tooling has been used in the process of authoring this PR, please include the phrase: 'Generated-by: ' followed by the name of the tool and its version. If no, write 'No'. Please refer to the [ASF Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html) for details. --> --------- Signed-off-by: oplaws <[email protected]> Signed-off-by: Julie Cao <[email protected]> Co-authored-by: oplaws <[email protected]> Co-authored-by: Matthew B. <[email protected]> --- .github/workflows/comment-commands.yml | 10 +- .github/workflows/suggest-reviewers.yml | 181 ++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/.github/workflows/comment-commands.yml b/.github/workflows/comment-commands.yml index f8300380ec..10b5a42c1c 100644 --- a/.github/workflows/comment-commands.yml +++ b/.github/workflows/comment-commands.yml @@ -153,7 +153,15 @@ jobs: reviewers.push(h); } if (!reviewers.length && !team_reviewers.length) { - core.warning(`No valid @mentions in '${action}'; skipping.`); + // Explicit @mentions are required — the suggest-reviewers CI job + // already posted a suggestion comment on this PR, so the author + // has candidates to choose from. + await github.rest.issues.createComment({ + owner, repo, issue_number: pull_number, + body: + `Please specify at least one reviewer: \`/${action} @user\`.\n` + + `Check the suggestion comment on this PR for candidates.`, + }); return; } diff --git a/.github/workflows/suggest-reviewers.yml b/.github/workflows/suggest-reviewers.yml new file mode 100644 index 0000000000..fdcea7d37d --- /dev/null +++ b/.github/workflows/suggest-reviewers.yml @@ -0,0 +1,181 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# On PR open/update, runs `git blame` on every changed file and posts (or +# edits) a single reviewer-suggestion comment split into two buckets: +# • Committers — collaborators who can be formally review-requested. +# The author uses `/request-review @login` to act on these. +# • Non-committer contributors — have context but cannot be review- +# requested; the author can @-mention them to notify. +# The CI never sends a review request on its own. +name: Suggest reviewers +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + suggest-reviewers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { execFileSync } = require('node:child_process'); + const pull_number = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + const { owner, repo } = context.repo; + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number }); + + try { + execFileSync('git', ['fetch', 'origin', pull.base.ref], { encoding: 'utf8' }); + } catch (e) { + core.warning(`git fetch for base ref ${pull.base.ref} failed: ${e.message}`); + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number, per_page: 100, + }); + + // Parse `git blame -p` output to find the most-recent commit per file. + function latestBlameCommit(blameOutput) { + let latest = null; + let current = null; + + function finalizeCurrent() { + if (!current || current.authorTime == null) return; + if (!latest || current.authorTime > latest.authorTime) latest = current; + } + + for (const line of blameOutput.split(/\r?\n/)) { + const header = line.match(/^([0-9a-f^]+)\s+\d+\s+\d+\s+\d+$/); + if (header) { + finalizeCurrent(); + current = { sha: header[1].replace(/^\^/, ''), authorTime: null }; + continue; + } + const authorTime = line.match(/^author-time\s+(\d+)$/); + if (authorTime && current) current.authorTime = Number(authorTime[1]); + } + + finalizeCurrent(); + return latest; + } + + // Count changed files touched per login; track collaborator status. + const committerCounts = new Map(); // collaborators + const nonCommitterCounts = new Map(); // non-collaborators with a GitHub login + + for (const { filename, status, previous_filename } of files) { + if (status === 'removed' || status === 'added') continue; + const blamePath = status === 'renamed' ? previous_filename : filename; + + let blameOutput; + try { + blameOutput = execFileSync( + 'git', ['blame', '-p', pull.base.sha, '--', blamePath], + { encoding: 'utf8' }, + ); + } catch (e) { + core.warning(`git blame on ${filename} failed: ${e.message}`); + continue; + } + + const latest = latestBlameCommit(blameOutput); + if (!latest) continue; + + let commit; + try { + ({ data: commit } = await github.rest.repos.getCommit({ owner, repo, ref: latest.sha })); + } catch (e) { + core.warning(`Commit lookup for ${latest.sha} failed: ${e.message}`); + continue; + } + + const login = commit.author?.login ?? commit.committer?.login; + if (!login) continue; + if (login.toLowerCase() === author.toLowerCase()) continue; + + const loginSource = commit.author?.login ? commit.author : commit.committer; + if (loginSource?.type === 'Bot') continue; + + let isCollaborator = false; + try { + await github.rest.repos.checkCollaborator({ owner, repo, username: login }); + isCollaborator = true; + } catch (_) { /* not a collaborator */ } + + if (isCollaborator) { + committerCounts.set(login, (committerCounts.get(login) ?? 0) + 1); + } else { + nonCommitterCounts.set(login, (nonCommitterCounts.get(login) ?? 0) + 1); + } + } + + const MAX_EACH = 3; + + const committers = [...committerCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_EACH) + .map(([l]) => l); + + const nonCommitters = [...nonCommitterCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_EACH) + .map(([l]) => l); + + const MARKER = '<!-- texera-reviewer-suggestion -->'; + + let body = `${MARKER}\n`; + body += `### Automated Reviewer Suggestions\n\n`; + body += `Based on the \`git blame\` history of the changed files, we recommend the following reviewers:\n\n`; + + if (committers.length) { + body += `- **Committers with relevant context:** ${committers.map(l => `\`@${l}\``).join(', ')}\n`; + body += ` You can request their reviews formally with \`/request-review ${committers.map(l => `@${l}`).join(' ')}\`.\n\n`; + } + + if (nonCommitters.length) { + body += `- **Contributors with relevant context:** ${nonCommitters.map(l => `\`@${l}\``).join(', ')}\n`; + body += ` You can notify them by mentioning ${nonCommitters.map(l => `\`@${l}\``).join(', ')} in a comment.\n`; + } + + if (!committers.length && !nonCommitters.length) { + body += `- No candidates found from \`git blame\` history.\n`; + } + + // Update existing suggestion comment rather than posting a new one. + const allComments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pull_number, per_page: 100, + }); + const existing = allComments.find(c => c.body?.includes(MARKER)); + + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + core.info(`Updated reviewer suggestion comment on #${pull_number}`); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + core.info(`Posted reviewer suggestion comment on #${pull_number}`); + }
