-
Notifications
You must be signed in to change notification settings - Fork 560
Create "snack" issue from PR labeled "snack-it". #4304
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
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
2f7142b
Create "snack" issue from PR labeled "snack-it".
htahir1 ebd8b9b
Update GH_PAT_TOKEN to GH_PAT_TOKEN_FOR_SNACK_IT secret
htahir1 8146c51
Add trigger for manual workflow_dispatch with PR number
htahir1 805fd67
Update pull request event types in workflow
htahir1 600cd3c
Update label check logic and enhance logging messages
htahir1 d81728f
Add check for PAT token configuration when adding issue
htahir1 e147124
Update snack-it workflow with issue generation and linking
htahir1 41f36df
trigger action
htahir1 c1563b1
Refactor snack-it workflow to remove PAT token configuration check fo…
htahir1 3a21d5f
Update pull request permissions to write access
htahir1 ca76ff8
Refactor variable declaration and output processing
htahir1 b11d285
Create issue from PR and assign PR author
htahir1 797ab41
Update workflow to create issue only for labeled PRs
htahir1 9ad49a4
Improve snack-it workflow security and robustness
strickvl 5a33903
Harden snack-it workflow and improve idempotence
strickvl 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,367 @@ | ||
| --- | ||
| name: Snack-it - Create Issue from PR | ||
| # This workflow creates a "snack" issue when a PR is labeled with "snack-it" | ||
| # The issue inherits all PR labels, gets tagged with "snack", and is added to the ZenML Roadmap project | ||
| # The PR branch is linked to the issue for easy tracking | ||
| # The PR author is assigned to the issue | ||
| # | ||
| # Can also be triggered manually via workflow_dispatch with a PR number for testing. | ||
| # Note: even when triggered manually, the target PR must have the "snack-it" label | ||
| # unless override_label_check is set to true. | ||
| # | ||
| # REQUIREMENTS: | ||
| # - GH_PAT_TOKEN_FOR_SNACK_IT: Personal Access Token with repo, project, and org:read scopes | ||
| # (needed for adding issues to organization projects) | ||
| on: | ||
| pull_request: | ||
| types: [labeled] | ||
| workflow_dispatch: | ||
| inputs: | ||
| pr_number: | ||
| description: PR number to create snack issue from | ||
| required: true | ||
| type: number | ||
| override_label_check: | ||
| description: Run snack-it even if the PR does not have the snack-it label | ||
| required: false | ||
| type: boolean | ||
| default: false | ||
| permissions: | ||
| issues: write # creating issues + comments | ||
| pull-requests: read # reading PR data | ||
| contents: read # reading repo contents if needed | ||
| jobs: | ||
| create-snack-issue: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Check if snack-it label is present | ||
| id: check-label | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| github-token: ${{ github.token }} | ||
| script: | | ||
| let hasSnackItLabel = false; | ||
| if (context.eventName === 'workflow_dispatch') { | ||
| const prNumber = parseInt(context.payload.inputs.pr_number, 10); | ||
| const overrideLabelCheck = context.payload.inputs.override_label_check === 'true'; | ||
| console.log('Manual trigger detected for PR #' + prNumber + ' - checking labels'); | ||
| if (overrideLabelCheck) { | ||
| console.log('override_label_check is true - skipping label check and forcing hasSnackItLabel = true'); | ||
| hasSnackItLabel = true; | ||
| } else { | ||
| try { | ||
| const prData = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: prNumber | ||
| }); | ||
| const labels = prData.data.labels || []; | ||
| hasSnackItLabel = labels.some(label => label.name === 'snack-it'); | ||
| console.log('PR labels:', labels.map(l => l.name).join(', ')); | ||
| console.log('Has snack-it label:', hasSnackItLabel); | ||
| } catch (error) { | ||
| console.log(`Error fetching PR #${prNumber} to check labels: ${error.message}`); | ||
| hasSnackItLabel = false; | ||
| } | ||
| } | ||
| } else if (context.payload.pull_request) { | ||
| // Check if PR has snack-it label | ||
| const labels = context.payload.pull_request.labels || []; | ||
| hasSnackItLabel = labels.some(label => label.name === 'snack-it'); | ||
| console.log('PR labels:', labels.map(l => l.name).join(', ')); | ||
| console.log('Has snack-it label:', hasSnackItLabel); | ||
| console.log('Event action:', context.payload.action); | ||
| if (context.payload.label) { | ||
| console.log('Label from event:', context.payload.label.name); | ||
| } | ||
| } | ||
| core.setOutput('snack_it', hasSnackItLabel ? 'true' : 'false'); | ||
| if (hasSnackItLabel) { | ||
| console.log('✅ Will create snack issue'); | ||
| } else { | ||
| console.log('⏭️ Skipping - no snack-it label'); | ||
| } | ||
| - name: Create issue from PR | ||
| if: steps.check-label.outputs.snack_it == 'true' | ||
| id: create-issue | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} | ||
| with: | ||
| github-token: ${{ github.token }} | ||
| script: | | ||
| // Get PR details (handle both workflow_dispatch and pull_request events) | ||
| let pr; | ||
| if (context.eventName === 'workflow_dispatch') { | ||
| try { | ||
| const prNumber = parseInt(process.env.PR_NUMBER, 10); | ||
| const prData = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: prNumber | ||
| }); | ||
| pr = prData.data; | ||
| } catch (e) { | ||
| core.setFailed(`Could not load PR #${process.env.PR_NUMBER} in this repo: ${e.message}`); | ||
| return; | ||
| } | ||
| } else { | ||
| pr = context.payload.pull_request; | ||
| } | ||
| // Check for existing snack issue for this PR | ||
| const owner = context.repo.owner; | ||
| const repo = context.repo.repo; | ||
| const query = `repo:${owner}/${repo} type:issue label:snack "PR #${pr.number}"`; | ||
| const searchResult = await github.rest.search.issuesAndPullRequests({ q: query }); | ||
| const existing = searchResult.data.items[0]; | ||
| if (existing) { | ||
| console.log(`✓ Existing snack issue #${existing.number} found for PR #${pr.number}, not creating a new one.`); | ||
| core.setOutput('issue_number', existing.number); | ||
| core.setOutput('issue_node_id', existing.node_id); | ||
| core.setOutput('pr_number', pr.number); | ||
| core.setOutput('issue_reused', 'true'); | ||
| return existing.number; | ||
| } | ||
| // Create simple issue from PR | ||
| const issueTitle = pr.title; | ||
| const issueBody = `This issue tracks the work completed in PR #${pr.number}.`; | ||
| console.log('Creating issue with title:', issueTitle); | ||
| // Create the issue | ||
| const issue = await github.rest.issues.create({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| title: issueTitle, | ||
| body: issueBody, | ||
| labels: ['snack'] | ||
| }); | ||
| console.log(`✅ Created issue #${issue.data.number}`); | ||
| // Assign the issue to the PR author | ||
| const prAuthor = pr.user.login; | ||
| try { | ||
| await github.rest.issues.addAssignees({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issue.data.number, | ||
| assignees: [prAuthor] | ||
| }); | ||
| console.log(`Assigned issue to @${prAuthor}`); | ||
| } catch (assignError) { | ||
| console.log(`⚠️ Could not assign to @${prAuthor}: ${assignError.message}`); | ||
| console.log('User may not have write access to the repository'); | ||
| } | ||
| // Get all labels from the PR | ||
| const prLabels = pr.labels.map(label => label.name); | ||
| // Add all PR labels to the issue (excluding 'snack-it' which triggered this) | ||
| const labelsToAdd = prLabels.filter(label => label !== 'snack-it'); | ||
| if (labelsToAdd.length > 0) { | ||
| await github.rest.issues.addLabels({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issue.data.number, | ||
| labels: labelsToAdd | ||
| }); | ||
| console.log(`Added labels: ${labelsToAdd.join(', ')}`); | ||
| } | ||
| // Store issue data for next step and PR comment | ||
| core.setOutput('issue_number', issue.data.number); | ||
| core.setOutput('issue_node_id', issue.data.node_id); | ||
| core.setOutput('pr_number', pr.number); | ||
| core.setOutput('issue_reused', 'false'); | ||
| console.log(`✅ Created issue #${issue.data.number}`); | ||
| return issue.data.number; | ||
| - name: Add issue to project and set status | ||
| if: steps.check-label.outputs.snack_it == 'true' | ||
| continue-on-error: true | ||
| id: add-to-project | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| ISSUE_NUMBER: ${{ steps.create-issue.outputs.issue_number }} | ||
| ISSUE_NODE_ID: ${{ steps.create-issue.outputs.issue_node_id }} | ||
| PR_NUMBER: ${{ steps.create-issue.outputs.pr_number }} | ||
| with: | ||
| github-token: ${{ secrets.GH_PAT_TOKEN_FOR_SNACK_IT }} | ||
| script: | | ||
| // Use the issue created in the previous step (avoid race condition) | ||
| const issueNumber = parseInt(process.env.ISSUE_NUMBER); | ||
| const issueNodeId = process.env.ISSUE_NODE_ID; | ||
| const prNumber = parseInt(process.env.PR_NUMBER); | ||
| console.log(`Working with issue #${issueNumber}`); | ||
| try { | ||
| // GraphQL query to add item to project | ||
| // Project number from URL: https://github.com/orgs/zenml-io/projects/1 | ||
| const projectNumber = 1; | ||
| const orgName = 'zenml-io'; | ||
|
|
||
| // Get full PR details for later use | ||
| const pr = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: prNumber | ||
| }); | ||
|
|
||
| // First, get the project ID | ||
| const projectQuery = ` | ||
| query($org: String!, $number: Int!) { | ||
| organization(login: $org) { | ||
| projectV2(number: $number) { | ||
| id | ||
| fields(first: 50) { | ||
| nodes { | ||
| ... on ProjectV2SingleSelectField { | ||
| id | ||
| name | ||
| options { | ||
| id | ||
| name | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| const projectData = await github.graphql(projectQuery, { | ||
| org: orgName, | ||
| number: projectNumber | ||
| }); | ||
| const project = projectData.organization.projectV2; | ||
| console.log(`Found project ID: ${project.id}`); | ||
| // Find the Status field and "In Review" option | ||
| const statusField = project.fields.nodes.find(field => field.name === 'Status'); | ||
| if (!statusField) { | ||
| console.log('Status field not found in project'); | ||
| console.log('Available fields:', project.fields.nodes.map(f => f.name)); | ||
| } | ||
| const inReviewOption = statusField?.options?.find(opt => | ||
| opt.name === 'In Review' || opt.name === 'In review' | ||
| ); | ||
| if (!inReviewOption) { | ||
| console.log('In Review option not found'); | ||
| console.log('Available options:', statusField?.options?.map(o => o.name)); | ||
| } | ||
| // Add the issue to the project | ||
| const addToProjectMutation = ` | ||
| mutation($projectId: ID!, $contentId: ID!) { | ||
| addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { | ||
| item { | ||
| id | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| const addResult = await github.graphql(addToProjectMutation, { | ||
| projectId: project.id, | ||
| contentId: issueNodeId | ||
| }); | ||
| const itemId = addResult.addProjectV2ItemById.item.id; | ||
| console.log(`Added issue #${issueNumber} to project with item ID: ${itemId}`); | ||
| // Update the status to "In Review" if we found the field and option | ||
| if (statusField && inReviewOption) { | ||
| const updateStatusMutation = ` | ||
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { | ||
| updateProjectV2ItemFieldValue( | ||
| input: { | ||
| projectId: $projectId | ||
| itemId: $itemId | ||
| fieldId: $fieldId | ||
| value: { singleSelectOptionId: $optionId } | ||
| } | ||
| ) { | ||
| projectV2Item { | ||
| id | ||
| } | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| await github.graphql(updateStatusMutation, { | ||
| projectId: project.id, | ||
| itemId: itemId, | ||
| fieldId: statusField.id, | ||
| optionId: inReviewOption.id | ||
| }); | ||
|
|
||
| console.log(`Set status to "In Review"`); | ||
| } else { | ||
| console.log(`Note: Could not set status automatically. Please set manually in the project.`); | ||
| } | ||
|
|
||
| // Link the PR branch to the issue only when the PR comes from the same repository | ||
| const prBranch = pr.data.head.ref; | ||
| const prRepoFullName = pr.data.head.repo.full_name; | ||
| const baseRepoFullName = `${context.repo.owner}/${context.repo.repo}`; | ||
| if (prRepoFullName !== baseRepoFullName) { | ||
| console.log(`Skipping branch link for fork PR from ${prRepoFullName}; base repo is ${baseRepoFullName}.`); | ||
| } else { | ||
| console.log(`Linking branch ${prBranch} in ${baseRepoFullName} to issue #${issueNumber}`); | ||
| try { | ||
| // Create a development link between the issue and the branch | ||
| const linkBranchMutation = ` | ||
| mutation($issueId: ID!, $repositoryId: ID!, $branch: String!) { | ||
| createLinkedBranch(input: { | ||
| issueId: $issueId | ||
| repositoryId: $repositoryId | ||
| name: $branch | ||
| }) { | ||
| linkedBranch { | ||
| id | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| // Get repository ID | ||
| const repoData = await github.rest.repos.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo | ||
| }); | ||
| await github.graphql(linkBranchMutation, { | ||
| issueId: issueNodeId, | ||
| repositoryId: repoData.data.node_id, | ||
| branch: prBranch | ||
| }); | ||
| console.log(`✅ Linked branch ${prBranch} to issue #${issueNumber}`); | ||
| } catch (branchError) { | ||
| console.log(`Note: Could not link branch automatically: ${branchError.message}`); | ||
| console.log(`Branch can be linked manually in the issue sidebar.`); | ||
| } | ||
| } | ||
|
|
||
| console.log(`✅ Successfully added issue #${issueNumber} to ZenML Roadmap project`); | ||
| core.setOutput('project_added', 'true'); | ||
|
|
||
| } catch (error) { | ||
| console.error(`❌ Error adding issue to project: ${error.message}`); | ||
| console.error(`This might be due to insufficient permissions on the GitHub token.`); | ||
| console.error(`Please ensure GH_PAT_TOKEN_FOR_SNACK_IT secret has 'project' and 'org:read' scopes.`); | ||
| console.error(`You can manually add issue #${issueNumber} to the project at:`); | ||
| console.error(`https://github.com/orgs/zenml-io/projects/1`); | ||
|
|
||
| core.setOutput('project_added', 'false'); | ||
|
|
||
| // Don't fail the workflow, just log the error | ||
| // The issue was still created successfully | ||
| } | ||
| - name: Comment on PR | ||
| if: steps.check-label.outputs.snack_it == 'true' && steps.create-issue.outputs.issue_reused | ||
| == 'false' | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| github-token: ${{ github.token }} | ||
| script: |- | ||
| const issueNumber = '${{ steps.create-issue.outputs.issue_number }}'; | ||
| const prNumber = '${{ steps.create-issue.outputs.pr_number }}'; | ||
| const projectAdded = '${{ steps.add-to-project.outputs.project_added }}' === 'true'; | ||
| let message = `🍿 Snack issue created: #${issueNumber}\n\n`; | ||
| if (projectAdded) { | ||
| message += `This issue has been added to the [ZenML Roadmap](https://github.com/orgs/zenml-io/projects/1) project and set to "In Review".`; | ||
| } else { | ||
| message += `⚠️ The issue could not be automatically added to the project board. Please add it manually to the [ZenML Roadmap](https://github.com/orgs/zenml-io/projects/1).`; | ||
| } | ||
| await github.rest.issues.createComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: parseInt(prNumber), | ||
| body: message | ||
| }); | ||
| console.log(`Commented on PR #${prNumber}`); | ||
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.
If we include
opened, I'd maybe include something like this to ensure we're not creating duplicates? I think this would be the right place?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.
There are a bunch of different ways you can check to see that we only proceed when the snack-it label is added.