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-5148-07be263ab901daad87e8e382c573234364107d6d
in repository https://gitbox.apache.org/repos/asf/texera.git

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

Reply via email to