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 https://github.com/kubeflow/notebooks/issues/1
```

ℹ️ 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 <astonebe@redhat.com>
This commit is contained in:
Andy Stoneberg 2025-05-21 10:55:16 -04:00
parent 616d1a8c38
commit e418374776
1 changed files with 243 additions and 0 deletions

243
.github/workflows/slash-commands.yaml vendored Normal file
View File

@ -0,0 +1,243 @@
name: Slash Command Handler
on:
issue_comment:
types: [created]
permissions:
issues: write
jobs:
handle-slash-command:
if: |
github.event.issue.pull_request == null
&& contains('["thesuperzapper", "ederign", "andyatmiami", "paulovmr", "jenny_s51", "harshad16", "thaorell", "kimwnasptd"]', github.event.comment.user.login)
&& (
contains(github.event.comment.body, '/add-sub-issue')
|| contains(github.event.comment.body, '/remove-sub-issue')
)
runs-on: ubuntu-latest
steps:
- name: Handle slash commands
id: handle-commands
uses: actions/github-script@v7
with:
script: |
const parseIssueNumber = (input) => {
if (!input) return null;
// Handle plain number
if (/^\d+$/.test(input)) {
return input;
}
// Handle #number format
const hashMatch = input.match(/^#(\d+)$/);
if (hashMatch) {
return hashMatch[1];
}
// Handle URL format
const urlMatch = input.match(/\/issues\/(\d+)$/);
if (urlMatch) {
return urlMatch[1];
}
throw new Error(`Could not parse issue number from input: '${input}'`);
};
const getIssueNodeId = async (owner, repo, issueNumber) => {
const response = await github.graphql(`
query {
repository(owner: "${owner}", name: "${repo}") {
issue(number: ${issueNumber}) {
id
title
}
}
}
`);
return {
id: response.repository.issue.id,
title: response.repository.issue.title
};
};
const performSubIssueMutation = async (action, parentIssueNodeId, childIssueNodeId) => {
const mutationField = `${action}SubIssue`;
const mutation = `
mutation {
${mutationField}(input: {
issueId: "${parentIssueNodeId}",
subIssueId: "${childIssueNodeId}"
}) {
clientMutationId
issue {
id
title
}
subIssue {
id
title
}
}
}
`;
try {
const response = await github.graphql(mutation);
return response;
} catch (error) {
throw new Error(error.message);
}
};
const collectSubIssueOperations = async (line, action, owner, repo) => {
const commandPrefix = `/${action}-sub-issue`;
if (!line.startsWith(commandPrefix)) return [];
const args = line.replace(commandPrefix, '').trim().split(/\s+/);
const operations = [];
for (const issue of args) {
const childIssueNumber = parseIssueNumber(issue);
const childIssue = await getIssueNodeId(owner, repo, childIssueNumber);
operations.push({
action,
issueNumber: childIssueNumber,
title: childIssue.title,
nodeId: childIssue.id
});
}
return operations;
};
const formatOperationsList = (operations, action) => {
if (operations.length === 0) return [];
return [
`### ${action} Sub-issues:`,
...operations.map(op => `- #${op.issueNumber}`),
''
];
};
try {
const { owner, repo } = context.repo;
const parentIssueNumber = context.payload.issue.number;
const commentBody = context.payload.comment.body;
// Get parent issue node ID and title
const parentIssue = await getIssueNodeId(owner, repo, parentIssueNumber);
// Collect all operations first
const lines = commentBody.split('\n');
const operations = [];
for (const line of lines) {
operations.push(...await collectSubIssueOperations(line, 'add', owner, repo));
operations.push(...await collectSubIssueOperations(line, 'remove', owner, repo));
}
if (operations.length === 0) {
return; // No valid operations found
}
// Create preview comment
const previewBodyParts = [
':mag: **Sub-issue Operation Preview**',
'',
`The following operations will be performed on issue #${parentIssueNumber} (${parentIssue.title}) at the request of @${context.payload.comment.user.login}:`,
''
];
// Group operations by action for display
const addOperations = operations.filter(op => op.action === 'add');
const removeOperations = operations.filter(op => op.action === 'remove');
previewBodyParts.push(
...formatOperationsList(addOperations, 'Adding'),
...formatOperationsList(removeOperations, 'Removing')
);
previewBodyParts.push('_This is a preview of the changes. The actual operations will be executed in the background._');
// Post preview comment
const previewComment = await github.rest.issues.createComment({
owner,
repo,
issue_number: parentIssueNumber,
body: previewBodyParts.join('\n')
});
// Execute operations in original order
for (const op of operations) {
await performSubIssueMutation(op.action, parentIssue.id, op.nodeId);
}
// Post success comment
await github.rest.issues.createComment({
owner,
repo,
issue_number: parentIssueNumber,
body: [
':white_check_mark: **GitHub Action Succeeded**',
'',
`All [sub-issue operations](${previewComment.data.html_url}) requested by @${context.payload.comment.user.login} have been completed successfully.`,
''
].join('\n')
});
} catch (error) {
core.setOutput('error_message', error.message);
core.setFailed(error.message);
}
- name: Post error comment if failure
if: failure()
uses: actions/github-script@v7
with:
script: |
try {
const commentUrl = context.payload.comment.html_url;
const runId = context.runId;
const { owner, repo } = context.repo;
const errorMessage = `${{ steps.handle-commands.outputs.error_message }}`;
const errorBodyParts = [
':x: **GitHub Action Failed**',
'',
`The workflow encountered an error while processing [your comment](${commentUrl}) to manage sub-issues.`,
'',
`:point_right: [View the run](https://github.com/${owner}/${repo}/actions/runs/${runId})`,
''
];
if (errorMessage && errorMessage !== '') {
errorBodyParts.push(
'<details>',
'<summary>Error details</summary>',
'',
'```',
errorMessage,
'```',
'',
'</details>',
''
);
}
errorBodyParts.push('Please check the logs and try again, or open a bug report if the issue persists.');
await github.rest.issues.createComment({
owner,
repo,
issue_number: context.payload.issue.number,
body: errorBodyParts.join('\n')
});
} catch (error) {
core.setFailed(`Failed to post error comment: ${error.message}`);
}