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
367 changes: 367 additions & 0 deletions .github/workflows/snack-it.yml
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)
Copy link
Contributor

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?

// Before creating the issue
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);
  return existing.number;
}

Copy link
Contributor

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.

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}`);
Loading