This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch release/v1.1.0-incubating
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/release/v1.1.0-incubating by
this push:
new e03b299afd feat(ci): sync issue assignees from PR closing refs and
handle `/take` comments (#4885)
e03b299afd is described below
commit e03b299afd5d99acec664a55735c70a0b13c5d18
Author: Yicong Huang <[email protected]>
AuthorDate: Mon May 4 03:31:28 2026 +0000
feat(ci): sync issue assignees from PR closing refs and handle `/take`
comments (#4885)
### What changes were proposed in this PR?
Adds three automation flows. By example (`#N`, `#M` are placeholders):
**Example 1 — You open a PR with `Closes #N` in the body.**
You are added as an assignee of issue `#N`, and `#N`'s `triage` label is
removed (downstream of the existing assigned-event handler). Other
assignees of `#N` (e.g. a maintainer) are not touched.
**Example 2 — You then edit the PR body to drop `Closes #N`.**
You are removed from issue `#N`'s assignees. If you were the only
assignee, `triage` is added back. The detection uses `changes.body.from`
plus a regex to find which closing references disappeared;
`closingIssuesReferences` (GraphQL) only reflects the new state and is
not enough on its own.
**Example 3 — A contributor comments `/take` on issue `#M`.**
The commenter is auto-assigned and `triage` is removed. Exact-line
match: `/take this issue please` does not trigger. `/take` from a
`[bot]` account is filtered at the job level. `/take` posted on a PR (vs
an issue) is filtered by `issue.pull_request == null`.
**Example 4 — A maintainer merges a PR.**
Behavior unchanged. The existing `credit-issue-on-pr-merge` step
overwrites issue assignees with the credited authors set (PR opener +
commit authors, deduped, max 10, `[bot]` filtered).
**Example 5 — A bare issue is created (no template, e.g., via API or
migration).**
The `triage` label is added automatically. Issues created via the
existing templates (which already attach `labels: ["triage"]`) are
unaffected — the workflow no-ops when the label is already there. Issues
opened with an assignee pre-set (e.g., creator self-assigns at creation)
skip this step so the assigned-event handler does not immediately remove
the just-added label.
`auto-assign.yml` is renamed to `pr-assignment.yml` (its scope now
extends past pure PR self-assign). The 3 prior jobs are collapsed into 1
job with 3 step-level if-guards so the PR Checks UI shows one entry per
PR event instead of 2-3 skipped siblings.
### Any related issues, documentation, discussions?
Closes #4884
### How was this PR tested?
Static YAML parse via `python3 -c 'import yaml; yaml.safe_load(...)'`.
End-to-end behavior is verified post-merge via the example scenarios
above (each scenario is the manual reproduction step).
### Was this PR authored or co-authored using generative AI tooling?
(backported from commit b16d0db4d34a8708bbc736b1a7289f4fde1bf5a4)
Generated-by: Claude Code (Opus 4.7)
---
.github/workflows/auto-assign.yml | 96 --------------
.github/workflows/issue-triage.yml | 85 +++++++++++-
.github/workflows/pr-assignment.yml | 258 ++++++++++++++++++++++++++++++++++++
3 files changed, 342 insertions(+), 97 deletions(-)
diff --git a/.github/workflows/auto-assign.yml
b/.github/workflows/auto-assign.yml
deleted file mode 100644
index 9fbc507bb6..0000000000
--- a/.github/workflows/auto-assign.yml
+++ /dev/null
@@ -1,96 +0,0 @@
-# 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.
-
-name: Auto-assign
-on:
- pull_request_target:
- types: [opened, closed]
-
-permissions:
- issues: write
- pull-requests: write
-
-jobs:
- assign-pr-author:
- if: >-
- github.event.action == 'opened'
- && github.event.pull_request.user.type != 'Bot'
- && github.event.pull_request.assignees[0] == null
- runs-on: ubuntu-latest
- steps:
- - uses: actions/github-script@v8
- with:
- script: |
- const pr = context.payload.pull_request;
- await github.rest.issues.addAssignees({
- ...context.repo,
- issue_number: pr.number,
- assignees: [pr.user.login],
- });
-
- credit-issue-on-pr-merge:
- if: github.event.action == 'closed' && github.event.pull_request.merged
- runs-on: ubuntu-latest
- steps:
- - uses: actions/github-script@v8
- with:
- script: |
- const { owner, repo } = context.repo;
- const opener = context.payload.pull_request.user;
- const isHuman = (l) => l && !l.endsWith('[bot]');
-
- const { repository: { pullRequest: prq } } = await github.graphql(`
- query($owner: String!, $repo: String!, $pr: Int!) {
- repository(owner: $owner, name: $repo) {
- pullRequest(number: $pr) {
- closingIssuesReferences(first: 50) {
- nodes {
- number
- repository { nameWithOwner }
- assignees(first: 20) { nodes { login } }
- }
- }
- commits(first: 250) {
- nodes { commit {
- parents { totalCount }
- authors(first: 10) { nodes { user { login } } }
- } }
- }
- }
- }
- }`, { owner, repo, pr: context.payload.pull_request.number });
-
- const authors = new Set();
- if (opener.type !== 'Bot' && isHuman(opener.login))
authors.add(opener.login);
- for (const { commit } of prq.commits.nodes) {
- if (commit.parents.totalCount > 1) continue;
- for (const a of commit.authors.nodes) {
- if (isHuman(a.user?.login)) authors.add(a.user.login);
- }
- }
- const credited = [...authors].slice(0, 10);
- if (!credited.length) return;
- const creditedSet = new Set(credited);
-
- for (const issue of prq.closingIssuesReferences.nodes) {
- if (issue.repository.nameWithOwner !== `${owner}/${repo}`)
continue;
- const current = issue.assignees.nodes.map(n => n.login);
- const toRemove = current.filter(l => !creditedSet.has(l));
- const toAdd = credited.filter(l => !current.includes(l));
- const args = { owner, repo, issue_number: issue.number };
- if (toRemove.length) await github.rest.issues.removeAssignees({
...args, assignees: toRemove });
- if (toAdd.length) await github.rest.issues.addAssignees({
...args, assignees: toAdd });
- }
diff --git a/.github/workflows/issue-triage.yml
b/.github/workflows/issue-triage.yml
index 710cff11c6..d2f13b9041 100644
--- a/.github/workflows/issue-triage.yml
+++ b/.github/workflows/issue-triage.yml
@@ -17,7 +17,9 @@
name: Issue triage
on:
issues:
- types: [assigned, unassigned]
+ types: [opened, assigned, unassigned]
+ issue_comment:
+ types: [created]
permissions:
issues: write
@@ -31,6 +33,37 @@ jobs:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
+ - name: Add 'triage' label when issue is opened with no assignees
+ # Issue templates already attach `labels: ["triage"]`, but issues
+ # opened bare (no template, or via API/migration) miss that.
+ # Skip when the issue was opened with an assignee already in place
+ # so the assigned-handler below does not immediately remove it.
+ if: >-
+ github.event.action == 'opened'
+ && github.event.issue.assignees[0] == null
+ uses: actions/github-script@v8
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { owner, repo } = context.repo;
+ const issue_number = context.payload.issue.number;
+ const labels = (context.payload.issue.labels || []).map((l) =>
l.name);
+ if (labels.includes('triage')) {
+ core.info(`Issue #${issue_number} already has 'triage' (template
applied it).`);
+ return;
+ }
+ try {
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number,
+ labels: ['triage'],
+ });
+ core.info(`Added 'triage' to issue #${issue_number}`);
+ } catch (e) {
+ core.warning(`Failed to add 'triage' to issue #${issue_number}:
${e.message}`);
+ }
+
- name: Remove 'triage' label when issue is assigned
if: github.event.action == 'assigned'
uses: actions/github-script@v8
@@ -39,6 +72,8 @@ jobs:
script: |
const { owner, repo } = context.repo;
const issue_number = context.payload.issue.number;
+ const newAssignee = context.payload.assignee?.login || '(unknown)';
+ core.info(`Issue #${issue_number} assigned to ${newAssignee};
removing 'triage' label.`);
try {
await github.rest.issues.removeLabel({
@@ -66,8 +101,10 @@ jobs:
const issue = context.payload.issue;
const { owner, repo } = context.repo;
const issue_number = issue.number;
+ const removed = context.payload.assignee?.login || '(unknown)';
const assignees = issue.assignees || [];
+ core.info(`Issue #${issue_number} unassigned ${removed}; remaining
assignees: [${assignees.map((a) => a.login).join(', ') || '(none)'}]`);
if (assignees.length > 0) {
core.info(
`Issue #${issue_number} still has ${assignees.length}
assignee(s), not adding 'triage'.`
@@ -83,7 +120,53 @@ jobs:
issue_number,
labels: ['triage'],
});
+ core.info(`Added 'triage' to issue #${issue_number}`);
} catch (e) {
core.warning(`Failed to add 'triage' to issue #${issue_number}:
${e.message}`);
throw e;
}
+
+ # --------------------------------------------------------
+ # 2) /take comment self-claim: anyone commenting "/take" on
+ # an issue (not on a PR) gets assigned to it. The
+ # resulting issues.assigned event then drops the 'triage'
+ # label via the job above. The startsWith filter at the
+ # job level keeps non-/take comments from allocating a
+ # runner; the regex inside the script enforces an exact
+ # "/take" line so suffixes like "/take this" do not
+ # silently match.
+ # --------------------------------------------------------
+ take-comment:
+ 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')
+ 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;
+ core.info(`/take candidate: comment by ${login} on issue
#${issue_number}; body=${JSON.stringify(body)}`);
+ if (!/^\/take\s*$/.test(body)) {
+ core.info(`Comment does not match exact '/take'; skipping.`);
+ return;
+ }
+
+ const { owner, repo } = context.repo;
+ try {
+ await github.rest.issues.addAssignees({
+ owner,
+ repo,
+ issue_number,
+ assignees: [login],
+ });
+ core.info(`Assigned ${login} to issue #${issue_number}`);
+ } catch (e) {
+ core.warning(`Failed to assign ${login} to #${issue_number}:
${e.message}`);
+ }
diff --git a/.github/workflows/pr-assignment.yml
b/.github/workflows/pr-assignment.yml
new file mode 100644
index 0000000000..92a6515f4e
--- /dev/null
+++ b/.github/workflows/pr-assignment.yml
@@ -0,0 +1,258 @@
+# 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.
+
+name: PR assignment
+on:
+ pull_request_target:
+ types: [opened, edited, closed]
+
+permissions:
+ issues: write
+ pull-requests: write
+
+jobs:
+ # All three behaviors live as steps under one job so the PR Checks
+ # tab shows a single entry per event instead of two-or-three skipped
+ # siblings. Step-level if-guards keep the actual work scoped.
+ pr-assignment:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Self-assign PR author to the PR
+ if: >-
+ github.event.action == 'opened'
+ && github.event.pull_request.user.type != 'Bot'
+ && github.event.pull_request.assignees[0] == null
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ core.info(`PR #${pr.number} opened by ${pr.user.login};
self-assigning.`);
+ try {
+ await github.rest.issues.addAssignees({
+ ...context.repo,
+ issue_number: pr.number,
+ assignees: [pr.user.login],
+ });
+ core.info(`Assigned ${pr.user.login} to PR #${pr.number}`);
+ } catch (e) {
+ core.warning(`Self-assign on PR #${pr.number} failed:
${e.message}`);
+ }
+
+ - name: Sync PR opener as assignee on linked issues
+ # Mirror the PR opener as an assignee on each same-repo issue listed
+ # in closingIssuesReferences. On body edits, also drop the opener
+ # from issues whose closing keyword was removed. Other manual
+ # assignees are never touched here, so this never fights with the
+ # merge-time credit step or human triage decisions.
+ if: >-
+ contains(fromJSON('["opened","edited"]'), github.event.action)
+ && github.event.pull_request.state == 'open'
+ && github.event.pull_request.user.type != 'Bot'
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const pr = context.payload.pull_request;
+ const opener = pr.user.login;
+ core.info(`Event ${context.payload.action} on PR #${pr.number} by
${opener}; syncing closing-issue assignees.`);
+
+ const { repository: { pullRequest: prq } } = await github.graphql(`
+ query($owner: String!, $repo: String!, $pr: Int!) {
+ repository(owner: $owner, name: $repo) {
+ pullRequest(number: $pr) {
+ closingIssuesReferences(first: 50) {
+ nodes { number repository { nameWithOwner } }
+ }
+ }
+ }
+ }`, { owner, repo, pr: pr.number });
+
+ const sameRepo = `${owner}/${repo}`;
+ const allRefs = prq.closingIssuesReferences.nodes;
+ const linked = allRefs
+ .filter((n) => n.repository.nameWithOwner === sameRepo)
+ .map((n) => n.number);
+ const crossRepo = allRefs.filter((n) => n.repository.nameWithOwner
!== sameRepo);
+ core.info(`Found ${linked.length} same-repo closing reference(s):
${linked.join(', ') || '(none)'}`);
+ if (crossRepo.length) {
+ core.info(`Skipping ${crossRepo.length} cross-repo reference(s):
${crossRepo.map((n) => `${n.repository.nameWithOwner}#${n.number}`).join(',
')}`);
+ }
+
+ for (const issue_number of linked) {
+ try {
+ await github.rest.issues.addAssignees({
+ owner, repo, issue_number, assignees: [opener],
+ });
+ core.info(`Assigned ${opener} to issue #${issue_number}`);
+ } catch (e) {
+ core.warning(`addAssignees on #${issue_number} failed:
${e.message}`);
+ }
+ }
+
+ // On body edit, find closing refs that disappeared from the body
+ // and remove the opener from those issues. closingIssuesReferences
+ // is a snapshot of the *new* state, so we need text-diff to detect
+ // removals. Cross-repo refs are intentionally skipped.
+ if (
+ context.payload.action === 'edited' &&
+ context.payload.changes &&
+ context.payload.changes.body &&
+ typeof context.payload.changes.body.from === 'string'
+ ) {
+ // GitHub also recognizes the colon form ("Closes: #123"), so
+ // allow an optional ":" between the keyword and the issue
+ // ref. Multiple refs on one line still need their own
+ // keyword each ("Closes #1, closes #2"), matching the
+ // `closingIssuesReferences` semantics — `Closes #1, #2`
+ // links only #1, so the diff treats only #1 as removed.
+ const re =
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)/gi;
+ const oldBody = context.payload.changes.body.from || '';
+ const newBody = pr.body || '';
+ const oldRefs = new Set([...oldBody.matchAll(re)].map((m) =>
Number(m[1])));
+ const newRefs = new Set([...newBody.matchAll(re)].map((m) =>
Number(m[1])));
+ const removed = [...oldRefs].filter((n) => !newRefs.has(n));
+ core.info(`Body-diff: oldRefs=[${[...oldRefs].join(',')}]
newRefs=[${[...newRefs].join(',')}] removed=[${removed.join(',')}]`);
+ for (const issue_number of removed) {
+ try {
+ await github.rest.issues.removeAssignees({
+ owner, repo, issue_number, assignees: [opener],
+ });
+ core.info(`Unassigned ${opener} from issue
#${issue_number}`);
+ } catch (e) {
+ core.warning(`removeAssignees on #${issue_number} failed:
${e.message}`);
+ }
+ }
+ } else if (context.payload.action === 'edited') {
+ core.info(`Body unchanged on edit; skipping removal-detection.`);
+ }
+
+ - name: Unassign PR opener from linked issues on PR close without merge
+ # When a PR is closed without merging, the opener was added to
+ # linked issues by the "Sync PR opener" step on open/edit. Mirror
+ # the close: remove them so abandoned PRs do not leave stale
+ # assignees. The issue-triage workflow's unassigned handler then
+ # re-adds `triage` if no other assignees remain. Cross-repo refs
+ # are skipped, consistent with the assign side.
+ if: >-
+ github.event.action == 'closed'
+ && github.event.pull_request.merged == false
+ && github.event.pull_request.user.type != 'Bot'
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const pr = context.payload.pull_request;
+ const opener = pr.user.login;
+ core.info(`PR #${pr.number} closed without merge; unassigning
${opener} from linked issues.`);
+
+ const { repository: { pullRequest: prq } } = await github.graphql(`
+ query($owner: String!, $repo: String!, $pr: Int!) {
+ repository(owner: $owner, name: $repo) {
+ pullRequest(number: $pr) {
+ closingIssuesReferences(first: 50) {
+ nodes { number repository { nameWithOwner } }
+ }
+ }
+ }
+ }`, { owner, repo, pr: pr.number });
+
+ const sameRepo = `${owner}/${repo}`;
+ const linked = prq.closingIssuesReferences.nodes
+ .filter((n) => n.repository.nameWithOwner === sameRepo)
+ .map((n) => n.number);
+ core.info(`Found ${linked.length} same-repo closing reference(s):
${linked.join(', ') || '(none)'}`);
+
+ for (const issue_number of linked) {
+ try {
+ await github.rest.issues.removeAssignees({
+ owner, repo, issue_number, assignees: [opener],
+ });
+ core.info(`Unassigned ${opener} from issue #${issue_number}`);
+ } catch (e) {
+ core.warning(`removeAssignees on #${issue_number} failed:
${e.message}`);
+ }
+ }
+
+ - name: Credit issue assignees on PR merge
+ if: github.event.action == 'closed' && github.event.pull_request.merged
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const pr = context.payload.pull_request;
+ const opener = pr.user;
+ const isHuman = (l) => l && !l.endsWith('[bot]');
+ core.info(`PR #${pr.number} merged by opener=${opener.login};
computing credited authors.`);
+
+ const { repository: { pullRequest: prq } } = await github.graphql(`
+ query($owner: String!, $repo: String!, $pr: Int!) {
+ repository(owner: $owner, name: $repo) {
+ pullRequest(number: $pr) {
+ closingIssuesReferences(first: 50) {
+ nodes {
+ number
+ repository { nameWithOwner }
+ assignees(first: 20) { nodes { login } }
+ }
+ }
+ commits(first: 250) {
+ nodes { commit {
+ parents { totalCount }
+ authors(first: 10) { nodes { user { login } } }
+ } }
+ }
+ }
+ }
+ }`, { owner, repo, pr: pr.number });
+
+ const authors = new Set();
+ if (opener.type !== 'Bot' && isHuman(opener.login))
authors.add(opener.login);
+ for (const { commit } of prq.commits.nodes) {
+ if (commit.parents.totalCount > 1) continue;
+ for (const a of commit.authors.nodes) {
+ if (isHuman(a.user?.login)) authors.add(a.user.login);
+ }
+ }
+ const credited = [...authors].slice(0, 10);
+ const creditedSet = new Set(credited);
+ core.info(`Credited authors (max 10, [bot] filtered):
[${credited.join(', ') || '(none)'}]`);
+ if (!credited.length) {
+ core.info(`No human authors to credit; skipping all linked
issues.`);
+ return;
+ }
+
+ const sameRepoIssues =
prq.closingIssuesReferences.nodes.filter((n) => n.repository.nameWithOwner ===
`${owner}/${repo}`);
+ core.info(`Linked same-repo issues to credit:
[${sameRepoIssues.map((i) => `#${i.number}`).join(', ') || '(none)'}]`);
+
+ for (const issue of sameRepoIssues) {
+ const current = issue.assignees.nodes.map(n => n.login);
+ const toRemove = current.filter(l => !creditedSet.has(l));
+ const toAdd = credited.filter(l => !current.includes(l));
+ const args = { owner, repo, issue_number: issue.number };
+ core.info(`Issue #${issue.number}:
current=[${current.join(',')}] credited=[${credited.join(',')}]
toRemove=[${toRemove.join(',')}] toAdd=[${toAdd.join(',')}]`);
+ try {
+ if (toRemove.length) {
+ await github.rest.issues.removeAssignees({ ...args,
assignees: toRemove });
+ core.info(`Removed [${toRemove.join(', ')}] from issue
#${issue.number}`);
+ }
+ if (toAdd.length) {
+ await github.rest.issues.addAssignees({ ...args, assignees:
toAdd });
+ core.info(`Added [${toAdd.join(', ')}] to issue
#${issue.number}`);
+ }
+ } catch (e) {
+ core.warning(`Updating assignees on #${issue.number} failed:
${e.message}`);
+ }
+ }