This is an automated email from the ASF dual-hosted git repository.

Yicong-Huang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new b16d0db4d3 feat(ci): sync issue assignees from PR closing refs and 
handle `/take` comments (#4885)
b16d0db4d3 is described below

commit b16d0db4d34a8708bbc736b1a7289f4fde1bf5a4
Author: Yicong Huang <[email protected]>
AuthorDate: Sun May 3 20:31:08 2026 -0700

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

Reply via email to