Skip to content

Commit 794359a

Browse files
feat(bot): add bot_ci merge blocking check (#2016)
* feat(github): add bot_ci merge blocking check - Add Mergeable: bot_ci check that monitors bot_ci status - Block merge when bot_ci is triggered but not passed (in_progress or failed) - Allow merge when bot_ci is not triggered - Add comprehensive tests for all scenarios Co-Authored-By: yujonglee <[email protected]> * feat(bot): add bot_ci merge blocking check - Add Mergeable: bot_ci check that monitors bot_ci status - Block merge when bot_ci is triggered but not passed (in_progress or failed) - Allow merge when bot_ci is not triggered - Add comprehensive tests for all scenarios - Revert changes to apps/github (moved to apps/bot) Co-Authored-By: yujonglee <[email protected]> * refactor(bot): split index.ts by functionality - Extract bot_ci merge blocking logic to mergeable.ts - Keep index.ts clean with just app setup and handler registration Co-Authored-By: yujonglee <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 0aa7f7d commit 794359a

File tree

7 files changed

+560
-11
lines changed

7 files changed

+560
-11
lines changed

apps/bot/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ApplicationFunctionOptions, Probot } from "probot";
22

3+
import { registerMergeableHandlers } from "./mergeable.js";
4+
35
export default (app: Probot, { getRouter }: ApplicationFunctionOptions) => {
46
if (getRouter) {
57
const router = getRouter("/");
@@ -9,6 +11,8 @@ export default (app: Probot, { getRouter }: ApplicationFunctionOptions) => {
911
});
1012
}
1113

14+
registerMergeableHandlers(app);
15+
1216
app.on("issues.opened", async (context) => {
1317
const issueComment = context.issue({
1418
body: "Thanks for opening this issue!",

apps/bot/src/mergeable.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { Probot } from "probot";
2+
3+
const BOT_CI_CHECK_NAME = "bot_ci";
4+
const MERGEABLE_CHECK_NAME = "Mergeable: bot_ci";
5+
6+
interface CheckRun {
7+
id: number;
8+
name: string;
9+
status: string;
10+
conclusion: string | null;
11+
}
12+
13+
type ProbotContext = Parameters<Parameters<Probot["on"]>[1]>[0];
14+
15+
async function getBotCiCheckRun(
16+
context: ProbotContext,
17+
owner: string,
18+
repo: string,
19+
ref: string,
20+
): Promise<CheckRun | null> {
21+
const { data } = await context.octokit.checks.listForRef({
22+
owner,
23+
repo,
24+
ref,
25+
check_name: BOT_CI_CHECK_NAME,
26+
});
27+
28+
if (data.check_runs.length === 0) {
29+
return null;
30+
}
31+
32+
const checkRun = data.check_runs[0];
33+
return {
34+
id: checkRun.id,
35+
name: checkRun.name,
36+
status: checkRun.status,
37+
conclusion: checkRun.conclusion,
38+
};
39+
}
40+
41+
async function createOrUpdateMergeableCheck(
42+
context: ProbotContext,
43+
owner: string,
44+
repo: string,
45+
headSha: string,
46+
botCiCheck: CheckRun | null,
47+
): Promise<void> {
48+
const existingChecks = await context.octokit.checks.listForRef({
49+
owner,
50+
repo,
51+
ref: headSha,
52+
check_name: MERGEABLE_CHECK_NAME,
53+
});
54+
55+
let status: "queued" | "in_progress" | "completed";
56+
let conclusion:
57+
| "success"
58+
| "failure"
59+
| "neutral"
60+
| "cancelled"
61+
| "skipped"
62+
| "timed_out"
63+
| "action_required"
64+
| undefined;
65+
let title: string;
66+
let summary: string;
67+
68+
if (botCiCheck === null) {
69+
status = "completed";
70+
conclusion = "success";
71+
title = "bot_ci not triggered";
72+
summary =
73+
"The bot_ci check was not triggered for this PR, so merging is allowed.";
74+
} else if (botCiCheck.status !== "completed") {
75+
status = "in_progress";
76+
conclusion = undefined;
77+
title = "Waiting for bot_ci to complete";
78+
summary = `The bot_ci check is currently ${botCiCheck.status}. Merging is blocked until it completes successfully.`;
79+
} else if (botCiCheck.conclusion === "success") {
80+
status = "completed";
81+
conclusion = "success";
82+
title = "bot_ci passed";
83+
summary = "The bot_ci check has passed. Merging is allowed.";
84+
} else {
85+
status = "completed";
86+
conclusion = "failure";
87+
title = `bot_ci ${botCiCheck.conclusion || "failed"}`;
88+
summary = `The bot_ci check has ${botCiCheck.conclusion || "failed"}. Merging is blocked.`;
89+
}
90+
91+
if (existingChecks.data.check_runs.length > 0) {
92+
const existingCheck = existingChecks.data.check_runs[0];
93+
await context.octokit.checks.update({
94+
owner,
95+
repo,
96+
check_run_id: existingCheck.id,
97+
status,
98+
conclusion,
99+
output: {
100+
title,
101+
summary,
102+
},
103+
});
104+
} else {
105+
await context.octokit.checks.create({
106+
owner,
107+
repo,
108+
name: MERGEABLE_CHECK_NAME,
109+
head_sha: headSha,
110+
status,
111+
conclusion,
112+
output: {
113+
title,
114+
summary,
115+
},
116+
});
117+
}
118+
}
119+
120+
async function handleBotCiCheck(
121+
context: ProbotContext,
122+
owner: string,
123+
repo: string,
124+
headSha: string,
125+
): Promise<void> {
126+
const botCiCheck = await getBotCiCheckRun(context, owner, repo, headSha);
127+
await createOrUpdateMergeableCheck(context, owner, repo, headSha, botCiCheck);
128+
}
129+
130+
export function registerMergeableHandlers(app: Probot): void {
131+
app.on(
132+
["check_run.created", "check_run.completed", "check_run.rerequested"],
133+
async (context) => {
134+
const checkRun = context.payload.check_run;
135+
136+
if (checkRun.name !== BOT_CI_CHECK_NAME) {
137+
return;
138+
}
139+
140+
const pullRequests = checkRun.pull_requests;
141+
if (pullRequests.length === 0) {
142+
context.log.info("No pull requests associated with this check run");
143+
return;
144+
}
145+
146+
const owner = context.payload.repository.owner.login;
147+
const repo = context.payload.repository.name;
148+
const headSha = checkRun.head_sha;
149+
150+
context.log.info(
151+
`bot_ci check ${context.payload.action} for ${owner}/${repo}@${headSha}`,
152+
);
153+
154+
try {
155+
await handleBotCiCheck(context, owner, repo, headSha);
156+
} catch (error) {
157+
context.log.error(`Failed to handle bot_ci check: ${error}`);
158+
}
159+
},
160+
);
161+
162+
app.on(
163+
[
164+
"pull_request.opened",
165+
"pull_request.synchronize",
166+
"pull_request.reopened",
167+
],
168+
async (context) => {
169+
const pr = context.payload.pull_request;
170+
const owner = context.payload.repository.owner.login;
171+
const repo = context.payload.repository.name;
172+
const headSha = pr.head.sha;
173+
174+
context.log.info(
175+
`PR ${context.payload.action} for ${owner}/${repo}#${pr.number}`,
176+
);
177+
178+
try {
179+
await handleBotCiCheck(context, owner, repo, headSha);
180+
} catch (error) {
181+
context.log.error(`Failed to handle PR event: ${error}`);
182+
}
183+
},
184+
);
185+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"action": "completed",
3+
"check_run": {
4+
"id": 1,
5+
"name": "bot_ci",
6+
"head_sha": "abc123",
7+
"status": "completed",
8+
"conclusion": "failure",
9+
"pull_requests": [
10+
{
11+
"number": 123
12+
}
13+
]
14+
},
15+
"repository": {
16+
"id": 1,
17+
"node_id": "MDEwOlJlcG9zaXRvcnkx",
18+
"name": "testing-things",
19+
"full_name": "hiimbex/testing-things",
20+
"owner": {
21+
"login": "hiimbex"
22+
}
23+
},
24+
"installation": {
25+
"id": 2
26+
}
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"action": "completed",
3+
"check_run": {
4+
"id": 1,
5+
"name": "bot_ci",
6+
"head_sha": "abc123",
7+
"status": "completed",
8+
"conclusion": "success",
9+
"pull_requests": [
10+
{
11+
"number": 123
12+
}
13+
]
14+
},
15+
"repository": {
16+
"id": 1,
17+
"node_id": "MDEwOlJlcG9zaXRvcnkx",
18+
"name": "testing-things",
19+
"full_name": "hiimbex/testing-things",
20+
"owner": {
21+
"login": "hiimbex"
22+
}
23+
},
24+
"installation": {
25+
"id": 2
26+
}
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"action": "created",
3+
"check_run": {
4+
"id": 1,
5+
"name": "bot_ci",
6+
"head_sha": "abc123",
7+
"status": "in_progress",
8+
"conclusion": null,
9+
"pull_requests": [
10+
{
11+
"number": 123
12+
}
13+
]
14+
},
15+
"repository": {
16+
"id": 1,
17+
"node_id": "MDEwOlJlcG9zaXRvcnkx",
18+
"name": "testing-things",
19+
"full_name": "hiimbex/testing-things",
20+
"owner": {
21+
"login": "hiimbex"
22+
}
23+
},
24+
"installation": {
25+
"id": 2
26+
}
27+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"action": "opened",
3+
"number": 123,
4+
"pull_request": {
5+
"number": 123,
6+
"html_url": "https://github.com/example/repo/pull/123",
7+
"created_at": "2024-01-01T00:00:00Z",
8+
"head": {
9+
"sha": "abc123"
10+
}
11+
},
12+
"repository": {
13+
"id": 1,
14+
"node_id": "MDEwOlJlcG9zaXRvcnkx",
15+
"name": "testing-things",
16+
"full_name": "hiimbex/testing-things",
17+
"owner": {
18+
"login": "hiimbex"
19+
}
20+
},
21+
"installation": {
22+
"id": 2
23+
}
24+
}

0 commit comments

Comments
 (0)