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}`);
+            }

Reply via email to