Skip to content

Commit 808c62e

Browse files
mvvmmask-bonk[bot]
andauthored
chore: add CODEOWNERS check action and auto-triage Bonk job (#30743)
* chore: add CODEOWNERS check action and auto-triage Bonk job * chore: fix prettier formatting in check-codeowner action * chore: add explicit permissions to check-codeowner job * Update .github/actions/check-codeowner/index.ts Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com> * fix: prettier-compatible regex and literal newline in check-codeowner action --------- Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
1 parent 4933aca commit 808c62e

5 files changed

Lines changed: 260 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: "Check CODEOWNERS"
2+
description: "Check whether the event actor is listed in CODEOWNERS, including team membership."
3+
4+
inputs:
5+
GH_ORG_TOKEN:
6+
description: "Token with read:org scope for resolving team membership"
7+
required: true
8+
9+
outputs:
10+
is-codeowner:
11+
description: "true if the actor is a CODEOWNER, false otherwise"
12+
13+
runs:
14+
using: "node24"
15+
main: "index.js"

.github/actions/check-codeowner/index.js

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// NOTE: This is the source file!
2+
// ~> Run `pnpm run build` to produce `index.js`
3+
4+
import * as core from "@actions/core";
5+
import * as github from "@actions/github";
6+
7+
type Octokit = ReturnType<typeof github.getOctokit>;
8+
9+
function getActor(): string {
10+
const { eventName, payload } = github.context;
11+
12+
switch (eventName) {
13+
case "pull_request_target":
14+
return payload.pull_request?.user?.login ?? "";
15+
case "issue_comment":
16+
case "pull_request_review_comment":
17+
return payload.comment?.user?.login ?? "";
18+
case "pull_request_review":
19+
return payload.review?.user?.login ?? "";
20+
default:
21+
return "";
22+
}
23+
}
24+
25+
async function isTeamMember(
26+
octokit: Octokit,
27+
org: string,
28+
teamSlug: string,
29+
username: string,
30+
): Promise<boolean> {
31+
try {
32+
const { data } = await octokit.rest.teams.getMembershipForUserInOrg({
33+
org,
34+
team_slug: teamSlug,
35+
username,
36+
});
37+
return data.state === "active";
38+
} catch {
39+
return false;
40+
}
41+
}
42+
43+
async function isCodeowner(octokit: Octokit, actor: string): Promise<boolean> {
44+
const { repo, owner } = github.context.repo;
45+
46+
// Fetch CODEOWNERS from the base branch via API — never from a checkout,
47+
// so a PR branch cannot tamper with it.
48+
const { data } = await octokit.rest.repos.getContent({
49+
owner,
50+
repo,
51+
path: ".github/CODEOWNERS",
52+
});
53+
54+
if (!("content" in data)) {
55+
throw new Error("CODEOWNERS is not a file");
56+
}
57+
58+
const content = Buffer.from(data.content, "base64").toString("utf-8");
59+
60+
// Collect all unique owner tokens from the file, ignoring comments and blank lines.
61+
const ownerPattern = new RegExp(
62+
"@([a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)?)",
63+
"g",
64+
);
65+
const owners = Array.from(
66+
new Set(
67+
content
68+
.split("\n")
69+
.filter((line) => line.trim() && !line.trim().startsWith("#"))
70+
.flatMap((line) =>
71+
Array.from(line.matchAll(ownerPattern)).map((m) => m[1]),
72+
),
73+
),
74+
);
75+
76+
for (const ownerEntry of owners) {
77+
if (ownerEntry.includes("/")) {
78+
// Team reference: org/team-slug
79+
const [org, teamSlug] = ownerEntry.split("/");
80+
if (await isTeamMember(octokit, org, teamSlug, actor)) {
81+
return true;
82+
}
83+
} else {
84+
// Individual user
85+
if (ownerEntry.toLowerCase() === actor.toLowerCase()) {
86+
return true;
87+
}
88+
}
89+
}
90+
91+
return false;
92+
}
93+
94+
(async function () {
95+
try {
96+
const token = core.getInput("GH_ORG_TOKEN", { required: true });
97+
const octokit = github.getOctokit(token);
98+
99+
const actor = getActor();
100+
101+
if (!actor) {
102+
core.info(`Unsupported event: ${github.context.eventName}. Skipping.`);
103+
core.setOutput("is-codeowner", "false");
104+
return;
105+
}
106+
107+
core.info(`Checking CODEOWNERS for actor: ${actor}`);
108+
109+
const result = await isCodeowner(octokit, actor);
110+
111+
core.info(`${actor} is${result ? "" : " not"} a CODEOWNER`);
112+
core.setOutput("is-codeowner", String(result));
113+
} catch (error) {
114+
core.setFailed(error instanceof Error ? error.message : String(error));
115+
}
116+
})();
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"private": true,
3+
"version": "0.0.0",
4+
"name": "check-codeowner",
5+
"scripts": {
6+
"build": "esbuild index.ts --bundle --format=cjs --platform=node --minify --outfile=index.js"
7+
},
8+
"devDependencies": {
9+
"@actions/core": "3.0.0",
10+
"@actions/github": "9.0.0",
11+
"esbuild": "0.14.39"
12+
}
13+
}

.github/workflows/bonk.yml

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,40 @@ on:
77
types: [created]
88
pull_request_review:
99
types: [submitted]
10+
pull_request_target:
11+
types: [opened]
1012

1113
concurrency:
1214
# intentionally hardcoded so that all Bonk workflows coexist peacefully
1315
group: bonk-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
1416
cancel-in-progress: false
1517

1618
jobs:
19+
check-codeowner:
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
outputs:
24+
is-codeowner: ${{ steps.check.outputs.is-codeowner }}
25+
steps:
26+
- name: Checkout repository
27+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
28+
with:
29+
fetch-depth: 1
30+
31+
- name: Check CODEOWNERS
32+
id: check
33+
uses: ./.github/actions/check-codeowner
34+
with:
35+
GH_ORG_TOKEN: ${{ secrets.GH_ORG_TOKEN }}
36+
1737
bonk:
18-
if: github.event.sender.type != 'Bot' && contains(github.event.comment.body, '/bonk') && !contains(github.event.comment.body, '/bigbonk')
38+
needs: check-codeowner
39+
if: |
40+
needs.check-codeowner.outputs.is-codeowner == 'true' &&
41+
github.event.sender.type != 'Bot' &&
42+
contains(github.event.comment.body, '/bonk') &&
43+
!contains(github.event.comment.body, '/bigbonk')
1944
runs-on: ubuntu-latest
2045
timeout-minutes: 30
2146
permissions:
@@ -63,3 +88,39 @@ jobs:
6388
agent: docs
6489
mentions: "/bonk"
6590
permissions: CODEOWNERS
91+
92+
bonk-auto-triage:
93+
needs: check-codeowner
94+
# pull_request_target is used instead of pull_request so secrets are available,
95+
# but we do NOT check out or execute the PR's code — Bonk reads context via the
96+
# GitHub API only.
97+
if: |
98+
needs.check-codeowner.outputs.is-codeowner == 'true' &&
99+
github.event_name == 'pull_request_target' &&
100+
github.event.sender.type != 'Bot'
101+
runs-on: ubuntu-latest
102+
timeout-minutes: 30
103+
permissions:
104+
id-token: write
105+
contents: read
106+
issues: write
107+
pull-requests: write
108+
steps:
109+
- name: Run Lil Bonk (auto-triage)
110+
uses: ask-bonk/ask-bonk/github@main
111+
env:
112+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
113+
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
114+
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
115+
with:
116+
model: "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6"
117+
agent: docs
118+
permissions: CODEOWNERS
119+
token_permissions: NO_PUSH
120+
prompt: |
121+
Triage this pull request. Review the title, description, and diff.
122+
Apply appropriate labels, summarize what the PR changes, and flag
123+
any issues that need attention from a maintainer (e.g. missing
124+
description, broken links, incorrect frontmatter, style guide
125+
violations). You may suggest specific code changes as inline review
126+
comments, but do not push commits or open new PRs.

0 commit comments

Comments
 (0)