Skip to content

Commit 34e2f9d

Browse files
committed
feat: manage sub-issues through "slash commands" in issue comments
related: #325 This new GitHub Actions workflow listens for issue comments and processes commands to add or remove sub-issues using the Javascript client. It includes error handling and posts feedback to the issue for auditability as well as if any errors occur during execution. Acceptable input formats (and multiple space-delimited arguments can be provided): ``` /add-sub-issue #1 /add-sub-issue 1 /add-sub-issue #1 ``` :information_source: Be mindful of underlying constraints enforced in GH regarding sub-issues: - An issue can only be a sub-issue to 0 or 1 issues - Trying to add an issue as a sub-issue when it is already assigned as a sub-issue results in error Also, in this commit, the ability to assign sub-issues is open to a set of users defined in the workflow yaml as a JSON string array within the job-level `if` conditional. The current collection identifies all epic owners and technical leaders for Notebooks 2.0. Please note the workflow YAML file has been named generically to potentially house other "slash commands" in the future although the current implementation is only focused on `/add-sub-issue` and `/remove-sub-issue`. Signed-off-by: Andy Stoneberg <[email protected]>
1 parent 526ef9d commit 34e2f9d

File tree

1 file changed

+243
-0
lines changed

1 file changed

+243
-0
lines changed

.github/workflows/slash-commands.yaml

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
name: Slash Command Handler
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
7+
permissions:
8+
issues: write
9+
10+
jobs:
11+
handle-slash-command:
12+
if: |
13+
github.event.issue.pull_request == null
14+
&& contains('["thesuperzapper", "ederign", "andyatmiami", "paulovmr", "jenny_s51", "harshad16", "thaorell", "kimwnasptd"]', github.event.comment.user.login)
15+
&& (
16+
contains(github.event.comment.body, '/add-sub-issue')
17+
|| contains(github.event.comment.body, '/remove-sub-issue')
18+
)
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- name: Handle slash commands
23+
id: handle-commands
24+
uses: actions/github-script@v7
25+
with:
26+
script: |
27+
const parseIssueNumber = (input) => {
28+
if (!input) return null;
29+
30+
// Handle plain number
31+
if (/^\d+$/.test(input)) {
32+
return input;
33+
}
34+
35+
// Handle #number format
36+
const hashMatch = input.match(/^#(\d+)$/);
37+
if (hashMatch) {
38+
return hashMatch[1];
39+
}
40+
41+
// Handle URL format
42+
const urlMatch = input.match(/\/issues\/(\d+)$/);
43+
if (urlMatch) {
44+
return urlMatch[1];
45+
}
46+
47+
throw new Error(`Could not parse issue number from input: '${input}'`);
48+
};
49+
50+
const getIssueNodeId = async (owner, repo, issueNumber) => {
51+
const response = await github.graphql(`
52+
query {
53+
repository(owner: "${owner}", name: "${repo}") {
54+
issue(number: ${issueNumber}) {
55+
id
56+
title
57+
}
58+
}
59+
}
60+
`);
61+
return {
62+
id: response.repository.issue.id,
63+
title: response.repository.issue.title
64+
};
65+
};
66+
67+
const performSubIssueMutation = async (action, parentIssueNodeId, childIssueNodeId) => {
68+
const mutationField = `${action}SubIssue`;
69+
70+
const mutation = `
71+
mutation {
72+
${mutationField}(input: {
73+
issueId: "${parentIssueNodeId}",
74+
subIssueId: "${childIssueNodeId}"
75+
}) {
76+
clientMutationId
77+
issue {
78+
id
79+
title
80+
}
81+
subIssue {
82+
id
83+
title
84+
}
85+
}
86+
}
87+
`;
88+
89+
try {
90+
const response = await github.graphql(mutation);
91+
return response;
92+
} catch (error) {
93+
throw new Error(error.message);
94+
}
95+
};
96+
97+
const collectSubIssueOperations = async (line, action, owner, repo) => {
98+
const commandPrefix = `/${action}-sub-issue`;
99+
if (!line.startsWith(commandPrefix)) return [];
100+
101+
const args = line.replace(commandPrefix, '').trim().split(/\s+/);
102+
const operations = [];
103+
104+
for (const issue of args) {
105+
const childIssueNumber = parseIssueNumber(issue);
106+
const childIssue = await getIssueNodeId(owner, repo, childIssueNumber);
107+
operations.push({
108+
action,
109+
issueNumber: childIssueNumber,
110+
title: childIssue.title,
111+
nodeId: childIssue.id
112+
});
113+
}
114+
115+
return operations;
116+
};
117+
118+
const formatOperationsList = (operations, action) => {
119+
if (operations.length === 0) return [];
120+
121+
return [
122+
`### ${action} Sub-issues:`,
123+
...operations.map(op => `- #${op.issueNumber}`),
124+
''
125+
];
126+
};
127+
128+
try {
129+
const { owner, repo } = context.repo;
130+
const parentIssueNumber = context.payload.issue.number;
131+
const commentBody = context.payload.comment.body;
132+
133+
// Get parent issue node ID and title
134+
const parentIssue = await getIssueNodeId(owner, repo, parentIssueNumber);
135+
136+
// Collect all operations first
137+
const lines = commentBody.split('\n');
138+
const operations = [];
139+
140+
for (const line of lines) {
141+
operations.push(...await collectSubIssueOperations(line, 'add', owner, repo));
142+
operations.push(...await collectSubIssueOperations(line, 'remove', owner, repo));
143+
}
144+
145+
if (operations.length === 0) {
146+
return; // No valid operations found
147+
}
148+
149+
// Create preview comment
150+
const previewBodyParts = [
151+
':mag: **Sub-issue Operation Preview**',
152+
'',
153+
`The following operations will be performed on issue #${parentIssueNumber} (${parentIssue.title}) at the request of @${context.payload.comment.user.login}:`,
154+
''
155+
];
156+
157+
// Group operations by action for display
158+
const addOperations = operations.filter(op => op.action === 'add');
159+
const removeOperations = operations.filter(op => op.action === 'remove');
160+
161+
previewBodyParts.push(
162+
...formatOperationsList(addOperations, 'Adding'),
163+
...formatOperationsList(removeOperations, 'Removing')
164+
);
165+
166+
previewBodyParts.push('_This is a preview of the changes. The actual operations will be executed in the background._');
167+
168+
// Post preview comment
169+
const previewComment = await github.rest.issues.createComment({
170+
owner,
171+
repo,
172+
issue_number: parentIssueNumber,
173+
body: previewBodyParts.join('\n')
174+
});
175+
176+
// Execute operations in original order
177+
for (const op of operations) {
178+
await performSubIssueMutation(op.action, parentIssue.id, op.nodeId);
179+
}
180+
181+
// Post success comment
182+
await github.rest.issues.createComment({
183+
owner,
184+
repo,
185+
issue_number: parentIssueNumber,
186+
body: [
187+
':white_check_mark: **GitHub Action Succeeded**',
188+
'',
189+
`All [sub-issue operations](${previewComment.data.html_url}) requested by @${context.payload.comment.user.login} have been completed successfully.`,
190+
''
191+
].join('\n')
192+
});
193+
194+
} catch (error) {
195+
core.setOutput('error_message', error.message);
196+
core.setFailed(error.message);
197+
}
198+
199+
- name: Post error comment if failure
200+
if: failure()
201+
uses: actions/github-script@v7
202+
with:
203+
script: |
204+
try {
205+
const commentUrl = context.payload.comment.html_url;
206+
const runId = context.runId;
207+
const { owner, repo } = context.repo;
208+
const errorMessage = `${{ steps.handle-commands.outputs.error_message }}`;
209+
210+
const errorBodyParts = [
211+
':x: **GitHub Action Failed**',
212+
'',
213+
`The workflow encountered an error while processing [your comment](${commentUrl}) to manage sub-issues.`,
214+
'',
215+
`:point_right: [View the run](https://github.com/${owner}/${repo}/actions/runs/${runId})`,
216+
''
217+
];
218+
219+
if (errorMessage && errorMessage !== '') {
220+
errorBodyParts.push(
221+
'<details>',
222+
'<summary>Error details</summary>',
223+
'',
224+
'```',
225+
errorMessage,
226+
'```',
227+
'',
228+
'</details>',
229+
''
230+
);
231+
}
232+
233+
errorBodyParts.push('Please check the logs and try again, or open a bug report if the issue persists.');
234+
235+
await github.rest.issues.createComment({
236+
owner,
237+
repo,
238+
issue_number: context.payload.issue.number,
239+
body: errorBodyParts.join('\n')
240+
});
241+
} catch (error) {
242+
core.setFailed(`Failed to post error comment: ${error.message}`);
243+
}

0 commit comments

Comments
 (0)