Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 197 additions & 1 deletion .github/workflows/comment-commands.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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;
}
Loading