Skip to content

Commit b94ee38

Browse files
feat(github): terminate Devin sessions when PR is closed/merged (#2014)
* feat(github): terminate Devin sessions when PR is closed/merged - Add PR closed webhook handler that finds and terminates linked Devin sessions - Implement smart pagination for session lookup based on PR creation date - Add tests for the webhook handler with mocked Devin API responses Co-Authored-By: yujonglee <[email protected]> * fix(github): remove early-exit assumption and paginate through all sessions - Remove prCreatedAt parameter and early-exit logic that assumed descending order - Add per-page sorting by created_at descending as suggested in review - Continue paginating until match found or all pages exhausted - Add tests for multi-page pagination and out-of-order sessions Co-Authored-By: yujonglee <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent ccb2a27 commit b94ee38

File tree

7 files changed

+455
-0
lines changed

7 files changed

+455
-0
lines changed

apps/github/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@hypr/github",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "tsc",
7+
"typecheck": "tsc --noEmit",
8+
"start": "probot run ./lib/index.js",
9+
"test": "vitest run"
10+
},
11+
"dependencies": {
12+
"probot": "^13.4.5"
13+
},
14+
"devDependencies": {
15+
"@types/node": "^20.0.0",
16+
"nock": "^14.0.5",
17+
"smee-client": "^2.0.0",
18+
"vitest": "^2.0.0",
19+
"typescript": "^5.8.3"
20+
},
21+
"engines": {
22+
"node": ">= 18"
23+
},
24+
"type": "module"
25+
}

apps/github/src/index.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { ApplicationFunctionOptions, Probot } from "probot";
2+
3+
interface DevinSession {
4+
session_id: string;
5+
status: string;
6+
title: string;
7+
created_at: string;
8+
updated_at: string;
9+
snapshot_id: string | null;
10+
playbook_id: string | null;
11+
pull_request: {
12+
url: string;
13+
} | null;
14+
}
15+
16+
interface ListSessionsResponse {
17+
sessions: DevinSession[];
18+
}
19+
20+
async function listDevinSessions(
21+
apiKey: string,
22+
limit: number = 100,
23+
offset: number = 0,
24+
): Promise<ListSessionsResponse> {
25+
const url = new URL("https://api.devin.ai/v1/sessions");
26+
url.searchParams.set("limit", limit.toString());
27+
url.searchParams.set("offset", offset.toString());
28+
29+
const response = await fetch(url.toString(), {
30+
method: "GET",
31+
headers: {
32+
Authorization: `Bearer ${apiKey}`,
33+
},
34+
});
35+
36+
if (!response.ok) {
37+
throw new Error(
38+
`Failed to list sessions: ${response.status} ${response.statusText}`,
39+
);
40+
}
41+
42+
return response.json() as Promise<ListSessionsResponse>;
43+
}
44+
45+
async function terminateDevinSession(
46+
apiKey: string,
47+
sessionId: string,
48+
): Promise<void> {
49+
const response = await fetch(
50+
`https://api.devin.ai/v1/sessions/${sessionId}`,
51+
{
52+
method: "DELETE",
53+
headers: {
54+
Authorization: `Bearer ${apiKey}`,
55+
},
56+
},
57+
);
58+
59+
if (!response.ok) {
60+
throw new Error(
61+
`Failed to terminate session: ${response.status} ${response.statusText}`,
62+
);
63+
}
64+
}
65+
66+
async function findDevinSessionForPR(
67+
apiKey: string,
68+
prUrl: string,
69+
): Promise<DevinSession | null> {
70+
const limit = 100;
71+
let offset = 0;
72+
const maxIterations = 50;
73+
74+
for (let i = 0; i < maxIterations; i++) {
75+
const { sessions } = await listDevinSessions(apiKey, limit, offset);
76+
77+
if (sessions.length === 0) {
78+
break;
79+
}
80+
81+
const sorted = [...sessions].sort(
82+
(a, b) =>
83+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
84+
);
85+
86+
const match = sorted.find((s) => s.pull_request?.url === prUrl);
87+
if (match) {
88+
return match;
89+
}
90+
91+
if (sessions.length < limit) {
92+
break;
93+
}
94+
95+
offset += limit;
96+
}
97+
98+
return null;
99+
}
100+
101+
export default (app: Probot, { getRouter }: ApplicationFunctionOptions) => {
102+
if (getRouter) {
103+
const router = getRouter("/");
104+
105+
router.get("/health", (_req, res) => {
106+
res.send("OK");
107+
});
108+
}
109+
110+
app.on("pull_request.closed", async (context) => {
111+
const apiKey = process.env.DEVIN_API_KEY;
112+
if (!apiKey) {
113+
context.log.warn("DEVIN_API_KEY not set, skipping session termination");
114+
return;
115+
}
116+
117+
const pr = context.payload.pull_request;
118+
const prUrl = pr.html_url;
119+
120+
context.log.info(`PR closed: ${prUrl} (merged: ${pr.merged})`);
121+
122+
try {
123+
const session = await findDevinSessionForPR(apiKey, prUrl);
124+
125+
if (!session) {
126+
context.log.info(`No Devin session found for PR: ${prUrl}`);
127+
return;
128+
}
129+
130+
if (session.status !== "running") {
131+
context.log.info(
132+
`Devin session ${session.session_id} is not running (status: ${session.status}), skipping termination`,
133+
);
134+
return;
135+
}
136+
137+
context.log.info(
138+
`Terminating Devin session ${session.session_id} for PR: ${prUrl}`,
139+
);
140+
await terminateDevinSession(apiKey, session.session_id);
141+
context.log.info(
142+
`Successfully terminated Devin session ${session.session_id}`,
143+
);
144+
} catch (error) {
145+
context.log.error(
146+
`Failed to terminate Devin session for PR ${prUrl}: ${error}`,
147+
);
148+
}
149+
});
150+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"action": "closed",
3+
"number": 123,
4+
"pull_request": {
5+
"html_url": "https://github.com/example/repo/pull/123",
6+
"created_at": "2024-01-01T00:00:00Z",
7+
"merged": true
8+
},
9+
"repository": {
10+
"id": 1,
11+
"node_id": "MDEwOlJlcG9zaXRvcnkx",
12+
"name": "testing-things",
13+
"full_name": "hiimbex/testing-things",
14+
"owner": {
15+
"login": "hiimbex"
16+
}
17+
},
18+
"installation": {
19+
"id": 2
20+
}
21+
}

0 commit comments

Comments
 (0)