-
Notifications
You must be signed in to change notification settings - Fork 54
chore: create /add-sub-issue
and /remove-sub-issue
commands
#369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
google-oss-prow
merged 1 commit into
kubeflow:main
from
andyatmiami:feat/sub-issue-ghas
Jul 24, 2025
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}`); | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems hard to maintain this code as a giant string.
Can we move the code to an external JS file under, possibly,
.github/scripts
folder and call it here?At least, we'd have a few IDE capabilities.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer to get @thesuperzapper to weigh in on his preferences here... I don't really see the external files pattern used elsewhere in
kubeflow
repositories - and don't really want to deviate unless we get an established community member to speak in favor of the change.I totally appreciate and understand the motivation behind the ask, however!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's fine to leave this inline, as the script is specific to the context of
actions/github-script@v7
and there is only one usage of this in the kubeflow org (and even notebooks project) right now.