Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions .github/workflows/comment-commands.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# 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.

# /take, /untake, /request-review, and /unrequest-review comment commands.
#
# Triage state is no longer materialized as a label — it is the search
# filter `is:issue is:open no:assignee`. Anyone can self-claim an issue
# by commenting `/take` (and self-release with `/untake`); PR-driven
# assignee sync is handled by `pr-assignment.yml`.
#
# On pull requests, the author can request or cancel reviewer requests
# via `/request-review @user [@user ...]` and `/unrequest-review @user
# [@user ...]`. We avoid the `/review` namespace so it stays free for
# future use (e.g. self-review).
name: Comment commands
on:
issue_comment:
types: [created]

permissions:
issues: write
pull-requests: write

jobs:
take:
# The startsWith filter at the job level keeps unrelated comments
# from allocating a runner; the regex inside the script enforces an
# exact `/take` or `/untake` so suffixes like `/take this` do not
# silently match.
if: >-
github.event_name == 'issue_comment'
&& github.event.action == 'created'
&& github.event.issue.pull_request == null
&& github.event.comment.user.type != 'Bot'
&& (startsWith(github.event.comment.body, '/take')
|| startsWith(github.event.comment.body, '/untake'))
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const body = (context.payload.comment.body || '').trim();
const issue_number = context.payload.issue.number;
const login = context.payload.comment.user.login;
const { owner, repo } = context.repo;
core.info(
`take/untake candidate: ${login} on issue #${issue_number}; ` +
`body=${JSON.stringify(body)}`,
);

if (/^\/take\s*$/.test(body)) {
try {
await github.rest.issues.addAssignees({
owner, repo, issue_number, assignees: [login],
});
core.info(`Assigned ${login} to issue #${issue_number}`);
} catch (e) {
core.warning(
`addAssignees on #${issue_number} failed: ${e.message}`,
);
}
} else if (/^\/untake\s*$/.test(body)) {
try {
await github.rest.issues.removeAssignees({
owner, repo, issue_number, assignees: [login],
});
core.info(`Unassigned ${login} from issue #${issue_number}`);
} catch (e) {
core.warning(
`removeAssignees on #${issue_number} failed: ${e.message}`,
);
}
} else {
core.info(
`Comment does not match exact '/take' or '/untake'; skipping.`,
);
}

request-review:
# Job-level startsWith gate avoids spinning up a runner for every
# PR comment; the regex inside the script enforces the exact shape.
if: >-
github.event_name == 'issue_comment'
&& github.event.action == 'created'
&& github.event.issue.pull_request != null
&& github.event.comment.user.type != 'Bot'
&& (startsWith(github.event.comment.body, '/request-review')
|| startsWith(github.event.comment.body, '/unrequest-review'))
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const body = (context.payload.comment.body || '').trim();
const pull_number = context.payload.issue.number;
const commenter = context.payload.comment.user.login;
const author = context.payload.issue.user.login;
const { owner, repo } = context.repo;

const match = body.match(
/^\/(request-review|unrequest-review)\b(.*)$/s,
);
if (!match) {
core.info(`Comment does not match exact command; skipping.`);
return;
}
const action = match[1];

if (commenter !== author) {
core.info(
`${commenter} is not the author of #${pull_number}; skipping.`,
);
return;
}

// Parse @user and @org/team mentions; route teams to the
// team_reviewers bucket. Strip self so the API doesn't
// reject the whole atomic call over one bad name. Copilot
// is a bot reviewer that the REST API expects as the exact
// slug "Copilot", so normalize any casing of @copilot.
const reviewers = [];
const team_reviewers = [];
for (const [, h] of match[2].matchAll(
Comment thread
Ma77Ball marked this conversation as resolved.
/@([\w-]+(?:\/[\w.-]+)?)/g,
)) {
if (h.includes('/')) team_reviewers.push(h.split('/')[1]);
else if (h.toLowerCase() === 'copilot') reviewers.push('Copilot');
else if (h.toLowerCase() !== author.toLowerCase())
reviewers.push(h);
}
if (!reviewers.length && !team_reviewers.length) {
core.warning(`No valid @mentions in '${action}'; skipping.`);
return;
}

const params = { owner, repo, pull_number, reviewers, team_reviewers };
try {
if (action === 'request-review') {
await github.rest.pulls.requestReviewers(params);
} else {
await github.rest.pulls.removeRequestedReviewers(params);
}
core.info(
`${action} on #${pull_number} by ${commenter}: ` +
`users=[${reviewers.join(', ')}] ` +
`teams=[${team_reviewers.join(', ')}]`,
);
} catch (e) {
core.warning(
`${action} on #${pull_number} failed: ${e.message}`,
);
}
85 changes: 0 additions & 85 deletions .github/workflows/take-commands.yml

This file was deleted.

Loading