Skip to content

Commit 662c8cb

Browse files
authored
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: Ma77Ball#55 ### Was this PR authored or co-authored using generative AI tooling? Co-Authored with Claude Opus 4.7
1 parent 07be263 commit 662c8cb

1 file changed

Lines changed: 197 additions & 1 deletion

File tree

.github/workflows/comment-commands.yml

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17-
# /take, /untake, /request-review, and /unrequest-review comment commands.
17+
# /take, /untake, /request-review, /unrequest-review, /sub-issue,
18+
# /unsub-issue, /parent-issue, and /unparent-issue comment commands.
1819
#
1920
# Triage state is no longer materialized as a label — it is the search
2021
# filter `is:issue is:open no:assignee`. Anyone can self-claim an issue
@@ -25,6 +26,14 @@
2526
# via `/request-review @user [@user ...]` and `/unrequest-review @user
2627
# [@user ...]`. We avoid the `/review` namespace so it stays free for
2728
# future use (e.g. self-review).
29+
#
30+
# Sub-issue linking can be driven from either end of the relationship:
31+
# `/sub-issue #N [#M ...]` on a parent links those issues as children;
32+
# `/parent-issue #N` on a child sets #N as its parent. Unlinking mirrors
33+
# this: `/unsub-issue #N [#M ...]` from the parent, `/unparent-issue`
34+
# from the child (omit the number to auto-detect via GraphQL, or pass
35+
# `/unparent-issue #N` to be explicit). Cross-repo links are not
36+
# supported; references like `owner/repo#N` are ignored.
2837
name: Comment commands
2938
on:
3039
issue_comment:
@@ -165,3 +174,190 @@ jobs:
165174
`${action} on #${pull_number} failed: ${e.message}`,
166175
);
167176
}
177+
178+
sub-issue:
179+
# The sub-issue REST endpoints key off the issue's database `id`, so
180+
# each #N reference needs a lookup before link/unlink.
181+
if: >-
182+
github.event_name == 'issue_comment'
183+
&& github.event.action == 'created'
184+
&& github.event.issue.pull_request == null
185+
&& github.event.comment.user.type != 'Bot'
186+
&& (startsWith(github.event.comment.body, '/sub-issue')
187+
|| startsWith(github.event.comment.body, '/unsub-issue')
188+
|| startsWith(github.event.comment.body, '/parent-issue')
189+
|| startsWith(github.event.comment.body, '/unparent-issue'))
190+
runs-on: ubuntu-latest
191+
steps:
192+
- uses: actions/github-script@v8
193+
with:
194+
github-token: ${{ secrets.GITHUB_TOKEN }}
195+
script: |
196+
const body = (context.payload.comment.body || '').trim();
197+
const issue_number = context.payload.issue.number;
198+
const commenter = context.payload.comment.user.login;
199+
const { owner, repo } = context.repo;
200+
201+
// Longest alternatives first so `unsub-issue` isn't shadowed
202+
// by `sub-issue`.
203+
const match = body.match(
204+
/^\/(unsub-issue|unparent-issue|sub-issue|parent-issue)\b(.*)$/s,
205+
);
206+
if (!match) {
207+
core.info(`Comment does not match exact command; skipping.`);
208+
return;
209+
}
210+
const action = match[1];
211+
const rest = match[2];
212+
core.info(
213+
`${action} candidate: ${commenter} on issue #${issue_number}; ` +
214+
`body=${JSON.stringify(body)}`,
215+
);
216+
217+
// Accept `#N` or bare `N`; cross-repo `owner/repo#N` is not
218+
// supported by the sub-issue endpoint.
219+
const refs = [];
220+
for (const token of rest.split(/\s+/)) {
221+
if (!token) continue;
222+
if (token.includes('/')) {
223+
core.warning(`Ignoring cross-repo reference '${token}'.`);
224+
continue;
225+
}
226+
const m = token.match(/^#?(\d+)$/);
227+
if (m) refs.push(Number(m[1]));
228+
}
229+
230+
async function getIssueId(number) {
231+
const { data } = await github.rest.issues.get({
232+
owner, repo, issue_number: number,
233+
});
234+
return data.id;
235+
}
236+
237+
async function getParentNumber(number) {
238+
const query = `
239+
query($owner:String!, $name:String!, $number:Int!) {
240+
repository(owner:$owner, name:$name) {
241+
issue(number:$number) { parent { number } }
242+
}
243+
}`;
244+
const result = await github.graphql(query, {
245+
owner, name: repo, number,
246+
});
247+
return result.repository.issue.parent?.number ?? null;
248+
}
249+
250+
async function linkChild(parent_number, child_number) {
251+
const sub_issue_id = await getIssueId(child_number);
252+
await github.request(
253+
'POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
254+
{ owner, repo, issue_number: parent_number, sub_issue_id },
255+
);
256+
}
257+
258+
async function unlinkChild(parent_number, child_number) {
259+
const sub_issue_id = await getIssueId(child_number);
260+
await github.request(
261+
'DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue',
262+
{ owner, repo, issue_number: parent_number, sub_issue_id },
263+
);
264+
}
265+
266+
if (action === 'sub-issue' || action === 'unsub-issue') {
267+
if (!refs.length) {
268+
core.warning(`No #N refs in '/${action}'; skipping.`);
269+
return;
270+
}
271+
for (const n of refs) {
272+
if (n === issue_number) {
273+
core.warning(
274+
`Refusing to self-link #${n}; skipping.`,
275+
);
276+
continue;
277+
}
278+
try {
279+
if (action === 'sub-issue') {
280+
await linkChild(issue_number, n);
281+
core.info(
282+
`Linked #${n} as sub-issue of #${issue_number}`,
283+
);
284+
} else {
285+
await unlinkChild(issue_number, n);
286+
core.info(
287+
`Unlinked #${n} from sub-issues of #${issue_number}`,
288+
);
289+
}
290+
} catch (e) {
291+
core.warning(
292+
`${action} #${n} on #${issue_number} failed: ${e.message}`,
293+
);
294+
}
295+
}
296+
return;
297+
}
298+
299+
if (action === 'parent-issue') {
300+
if (refs.length !== 1) {
301+
core.warning(
302+
`/parent-issue expects exactly one #N; skipping.`,
303+
);
304+
return;
305+
}
306+
const parent_number = refs[0];
307+
if (parent_number === issue_number) {
308+
core.warning(
309+
`Refusing to set #${issue_number} as its own parent; skipping.`,
310+
);
311+
return;
312+
}
313+
try {
314+
await linkChild(parent_number, issue_number);
315+
core.info(
316+
`Linked #${issue_number} as sub-issue of #${parent_number}`,
317+
);
318+
} catch (e) {
319+
core.warning(
320+
`parent-issue #${parent_number} on #${issue_number} ` +
321+
`failed: ${e.message}`,
322+
);
323+
}
324+
return;
325+
}
326+
327+
if (action === 'unparent-issue') {
328+
if (refs.length > 1) {
329+
core.warning(
330+
`/unparent-issue accepts at most one #N; skipping.`,
331+
);
332+
return;
333+
}
334+
let parent_number = refs[0];
335+
if (parent_number === undefined) {
336+
try {
337+
parent_number = await getParentNumber(issue_number);
338+
} catch (e) {
339+
core.warning(
340+
`parent lookup for #${issue_number} failed: ${e.message}`,
341+
);
342+
return;
343+
}
344+
if (parent_number == null) {
345+
core.warning(
346+
`#${issue_number} has no parent; skipping.`,
347+
);
348+
return;
349+
}
350+
}
351+
try {
352+
await unlinkChild(parent_number, issue_number);
353+
core.info(
354+
`Unlinked #${issue_number} from parent #${parent_number}`,
355+
);
356+
} catch (e) {
357+
core.warning(
358+
`unparent-issue on #${issue_number} (parent #${parent_number}) ` +
359+
`failed: ${e.message}`,
360+
);
361+
}
362+
return;
363+
}

0 commit comments

Comments
 (0)