This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] 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 662c8cbe2b feat: add /sub-issue, /parent-issue, and unlink variants to
comment-commands (#5148)
662c8cbe2b is described below
commit 662c8cbe2ba4ed454305a27a6e57d46f501ee13f
Author: Matthew B. <[email protected]>
AuthorDate: Fri May 22 01:12:38 2026 -0700
feat: add /sub-issue, /parent-issue, and unlink variants to
comment-commands (#5148)
### What changes were proposed in this PR?
Adds four sub-issue comment commands to
`.github/workflows/comment-commands.yml`:
- `/sub-issue #N [#M ...]` — on a parent, links #N as sub-issues.
- `/unsub-issue #N [#M ...]` — on a parent, unlinks #N.
- `/parent-issue #N` — on a child, sets #N as its parent.
- `/unparent-issue [#N]` — on a child, removes its parent (auto-detected
via GraphQL if omitted).
Follows the existing `/take` / `/request-review` pattern in the same
workflow. Cross-repo refs are skipped.
### Any related issues, documentation, or discussions?
closes: #5147
### How was this PR tested?
Tested on my github fork of Texera:
https://github.com/Ma77Ball/texera/issues/55
### Was this PR authored or co-authored using generative AI tooling?
Co-Authored with Claude Opus 4.7
---
.github/workflows/comment-commands.yml | 198 ++++++++++++++++++++++++++++++++-
1 file changed, 197 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/comment-commands.yml
b/.github/workflows/comment-commands.yml
index 3300db1353..f8300380ec 100644
--- a/.github/workflows/comment-commands.yml
+++ b/.github/workflows/comment-commands.yml
@@ -14,7 +14,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-# /take, /untake, /request-review, and /unrequest-review comment commands.
+# /take, /untake, /request-review, /unrequest-review, /sub-issue,
+# /unsub-issue, /parent-issue, and /unparent-issue 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
@@ -25,6 +26,14 @@
# 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).
+#
+# Sub-issue linking can be driven from either end of the relationship:
+# `/sub-issue #N [#M ...]` on a parent links those issues as children;
+# `/parent-issue #N` on a child sets #N as its parent. Unlinking mirrors
+# this: `/unsub-issue #N [#M ...]` from the parent, `/unparent-issue`
+# from the child (omit the number to auto-detect via GraphQL, or pass
+# `/unparent-issue #N` to be explicit). Cross-repo links are not
+# supported; references like `owner/repo#N` are ignored.
name: Comment commands
on:
issue_comment:
@@ -165,3 +174,190 @@ jobs:
`${action} on #${pull_number} failed: ${e.message}`,
);
}
+
+ sub-issue:
+ # The sub-issue REST endpoints key off the issue's database `id`, so
+ # each #N reference needs a lookup before link/unlink.
+ 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, '/sub-issue')
+ || startsWith(github.event.comment.body, '/unsub-issue')
+ || startsWith(github.event.comment.body, '/parent-issue')
+ || startsWith(github.event.comment.body, '/unparent-issue'))
+ 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 commenter = context.payload.comment.user.login;
+ const { owner, repo } = context.repo;
+
+ // Longest alternatives first so `unsub-issue` isn't shadowed
+ // by `sub-issue`.
+ const match = body.match(
+ /^\/(unsub-issue|unparent-issue|sub-issue|parent-issue)\b(.*)$/s,
+ );
+ if (!match) {
+ core.info(`Comment does not match exact command; skipping.`);
+ return;
+ }
+ const action = match[1];
+ const rest = match[2];
+ core.info(
+ `${action} candidate: ${commenter} on issue #${issue_number}; ` +
+ `body=${JSON.stringify(body)}`,
+ );
+
+ // Accept `#N` or bare `N`; cross-repo `owner/repo#N` is not
+ // supported by the sub-issue endpoint.
+ const refs = [];
+ for (const token of rest.split(/\s+/)) {
+ if (!token) continue;
+ if (token.includes('/')) {
+ core.warning(`Ignoring cross-repo reference '${token}'.`);
+ continue;
+ }
+ const m = token.match(/^#?(\d+)$/);
+ if (m) refs.push(Number(m[1]));
+ }
+
+ async function getIssueId(number) {
+ const { data } = await github.rest.issues.get({
+ owner, repo, issue_number: number,
+ });
+ return data.id;
+ }
+
+ async function getParentNumber(number) {
+ const query = `
+ query($owner:String!, $name:String!, $number:Int!) {
+ repository(owner:$owner, name:$name) {
+ issue(number:$number) { parent { number } }
+ }
+ }`;
+ const result = await github.graphql(query, {
+ owner, name: repo, number,
+ });
+ return result.repository.issue.parent?.number ?? null;
+ }
+
+ async function linkChild(parent_number, child_number) {
+ const sub_issue_id = await getIssueId(child_number);
+ await github.request(
+ 'POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
+ { owner, repo, issue_number: parent_number, sub_issue_id },
+ );
+ }
+
+ async function unlinkChild(parent_number, child_number) {
+ const sub_issue_id = await getIssueId(child_number);
+ await github.request(
+ 'DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue',
+ { owner, repo, issue_number: parent_number, sub_issue_id },
+ );
+ }
+
+ if (action === 'sub-issue' || action === 'unsub-issue') {
+ if (!refs.length) {
+ core.warning(`No #N refs in '/${action}'; skipping.`);
+ return;
+ }
+ for (const n of refs) {
+ if (n === issue_number) {
+ core.warning(
+ `Refusing to self-link #${n}; skipping.`,
+ );
+ continue;
+ }
+ try {
+ if (action === 'sub-issue') {
+ await linkChild(issue_number, n);
+ core.info(
+ `Linked #${n} as sub-issue of #${issue_number}`,
+ );
+ } else {
+ await unlinkChild(issue_number, n);
+ core.info(
+ `Unlinked #${n} from sub-issues of #${issue_number}`,
+ );
+ }
+ } catch (e) {
+ core.warning(
+ `${action} #${n} on #${issue_number} failed: ${e.message}`,
+ );
+ }
+ }
+ return;
+ }
+
+ if (action === 'parent-issue') {
+ if (refs.length !== 1) {
+ core.warning(
+ `/parent-issue expects exactly one #N; skipping.`,
+ );
+ return;
+ }
+ const parent_number = refs[0];
+ if (parent_number === issue_number) {
+ core.warning(
+ `Refusing to set #${issue_number} as its own parent;
skipping.`,
+ );
+ return;
+ }
+ try {
+ await linkChild(parent_number, issue_number);
+ core.info(
+ `Linked #${issue_number} as sub-issue of #${parent_number}`,
+ );
+ } catch (e) {
+ core.warning(
+ `parent-issue #${parent_number} on #${issue_number} ` +
+ `failed: ${e.message}`,
+ );
+ }
+ return;
+ }
+
+ if (action === 'unparent-issue') {
+ if (refs.length > 1) {
+ core.warning(
+ `/unparent-issue accepts at most one #N; skipping.`,
+ );
+ return;
+ }
+ let parent_number = refs[0];
+ if (parent_number === undefined) {
+ try {
+ parent_number = await getParentNumber(issue_number);
+ } catch (e) {
+ core.warning(
+ `parent lookup for #${issue_number} failed: ${e.message}`,
+ );
+ return;
+ }
+ if (parent_number == null) {
+ core.warning(
+ `#${issue_number} has no parent; skipping.`,
+ );
+ return;
+ }
+ }
+ try {
+ await unlinkChild(parent_number, issue_number);
+ core.info(
+ `Unlinked #${issue_number} from parent #${parent_number}`,
+ );
+ } catch (e) {
+ core.warning(
+ `unparent-issue on #${issue_number} (parent
#${parent_number}) ` +
+ `failed: ${e.message}`,
+ );
+ }
+ return;
+ }