Skip to content

Commit 59dd323

Browse files
authored
add github action for creating linear tickets for unconnected PRs (RooCodeInc#3021)
* add github action for creating linear tickets for unconnected PRs * changset * only load fetch if not present * omit fetch * add error handling * fix gql query * only run for opened PRs * break out into actions * fix folders * checkout first * remove the actions * add sync * remove sync
1 parent 5439426 commit 59dd323

File tree

2 files changed

+182
-0
lines changed

2 files changed

+182
-0
lines changed

.changeset/wise-fishes-explain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": patch
3+
---
4+
5+
Github workflow for creating a linear ticket from PRs

.github/workflows/pull-request.yml

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
name: Create Linear Issue on Pull Request
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
branches: [main]
7+
types: [opened]
8+
9+
permissions:
10+
pull-requests: write
11+
12+
jobs:
13+
create-linear-issue-on-pull-request:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Check for existing Linear link
17+
id: check-linear
18+
uses: actions/github-script@v6
19+
with:
20+
result-encoding: string
21+
script: |
22+
const pr = context.payload.pull_request;
23+
// 1) PR body
24+
if (/https?:\/\/linear\.app/.test(pr.body||"")) {
25+
return "true";
26+
}
27+
// 2) Any linked GitHub issues?
28+
const res = await github.graphql(
29+
`query($owner:String!,$repo:String!,$prNumber:Int!){
30+
repository(owner:$owner,name:$repo){
31+
pullRequest(number:$prNumber){
32+
closingIssuesReferences(first:10){
33+
nodes{number}
34+
}
35+
}
36+
}
37+
}`,
38+
{
39+
owner: context.repo.owner,
40+
repo: context.repo.repo,
41+
prNumber: pr.number
42+
}
43+
);
44+
for (const {number} of res.repository.pullRequest.closingIssuesReferences.nodes) {
45+
const comments = await github.rest.issues.listComments({
46+
owner: context.repo.owner,
47+
repo: context.repo.repo,
48+
issue_number: number
49+
});
50+
if (comments.data.some(c=>/https?:\/\/linear\.app/.test(c.body))) {
51+
return "true";
52+
}
53+
}
54+
return "false";
55+
56+
- name: Find or create Linear issue via GraphQL
57+
if: steps.check-linear.outputs.result == 'false'
58+
id: linear
59+
uses: actions/github-script@v6
60+
env:
61+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
62+
with:
63+
result-encoding: string
64+
script: |
65+
const API = 'https://api.linear.app/graphql';
66+
const apiKey = process.env.LINEAR_API_KEY;
67+
68+
// Check if API key exists
69+
if (!apiKey) {
70+
core.setFailed('LINEAR_API_KEY is not set. Please add it to your repository secrets.');
71+
core.setOutput('error', 'true');
72+
core.setOutput('error-message', 'LINEAR_API_KEY is not set. Please add it to your repository secrets.');
73+
return;
74+
}
75+
76+
// Helper to call Linear with error handling
77+
async function gql(q, v) {
78+
try {
79+
const r = await fetch(API, {
80+
method:'POST',
81+
headers:{
82+
'Content-Type':'application/json',
83+
'Authorization': apiKey
84+
},
85+
body: JSON.stringify({ query: q, variables: v })
86+
});
87+
88+
if (!r.ok) {
89+
throw new Error(`Linear API responded with status ${r.status}: ${await r.text()}`);
90+
}
91+
92+
const json = await r.json();
93+
94+
// Check for GraphQL errors
95+
if (json.errors && json.errors.length > 0) {
96+
const errorMessages = json.errors.map(e => e.message).join(', ');
97+
throw new Error(`Linear GraphQL errors: ${errorMessages}`);
98+
}
99+
100+
return json.data;
101+
} catch (error) {
102+
core.error(`Error calling Linear API: ${error.message}`);
103+
throw error;
104+
}
105+
}
106+
107+
try {
108+
// 1) Set team ID
109+
const teamId = "19b9c1b2-5f58-498c-b1bf-23ee8f52a677"
110+
111+
// 2) Look for existing issue by PR URL
112+
const pr = context.payload.pull_request;
113+
const searchData = await gql(
114+
`query($team:ID!,$q:String!){
115+
issues(filter: { team: { id: { eq: $team } } attachments: { some: { url: { eq: $q } } } }){nodes{id,url}}
116+
}`,
117+
{ team: teamId, q: pr.html_url }
118+
);
119+
let issue = searchData.issues.nodes[0];
120+
121+
// 3) Create if missing
122+
if (!issue) {
123+
const createData = await gql(
124+
`mutation($input:IssueCreateInput!){
125+
issueCreate(input:$input){issue{id,url}}
126+
}`,
127+
{
128+
input: {
129+
teamId,
130+
title: `[GITHUB] ${pr.title}`,
131+
description: `${pr.body||''}\n\n${pr.html_url}`,
132+
stateId: "4d9bcba2-6712-47e3-b577-6ec1ee023dc2",
133+
labelIds: ["504e7d60-5037-483f-a9b8-7e298bdf116f"]
134+
}
135+
}
136+
);
137+
issue = createData.issueCreate.issue;
138+
}
139+
140+
// Set output for next steps
141+
core.setOutput('linear-issue-url', issue.url);
142+
core.setOutput('error', 'false');
143+
} catch (error) {
144+
core.setOutput('error', 'true');
145+
core.setOutput('error-message', error.message);
146+
core.setFailed(`Failed to create or find Linear issue: ${error.message}`);
147+
}
148+
149+
- name: Comment PR with Linear link
150+
if: steps.check-linear.outputs.result == 'false'
151+
uses: actions/github-script@v6
152+
with:
153+
script: |
154+
const pr = context.payload.pull_request;
155+
const url = `${{ steps.linear.outputs.linear-issue-url }}`;
156+
const body = `🔗 Linear issue created: ${url}`;
157+
// Fetch existing comments
158+
const { data: comments } = await github.rest.issues.listComments({
159+
...context.repo,
160+
issue_number: pr.number
161+
});
162+
const botComment = comments.find(c =>
163+
c.user.type === "Bot" && c.body.startsWith("🔗 Linear issue created:")
164+
);
165+
if (botComment) {
166+
await github.rest.issues.updateComment({
167+
...context.repo,
168+
comment_id: botComment.id,
169+
body
170+
});
171+
} else {
172+
await github.rest.issues.createComment({
173+
...context.repo,
174+
issue_number: pr.number,
175+
body
176+
});
177+
}

0 commit comments

Comments
 (0)