@reneturcios/greenlight 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/dist/automate.d.ts +184 -0
- package/dist/automate.js +637 -0
- package/dist/automate.js.map +1 -0
- package/dist/claims.d.ts +20 -0
- package/dist/claims.js +112 -0
- package/dist/claims.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +337 -0
- package/dist/cli.js.map +1 -0
- package/dist/friction-index.d.ts +1 -0
- package/dist/friction-index.js +152 -0
- package/dist/friction-index.js.map +1 -0
- package/dist/friction.d.ts +28 -0
- package/dist/friction.js +54 -0
- package/dist/friction.js.map +1 -0
- package/dist/gates.d.ts +51 -0
- package/dist/gates.js +198 -0
- package/dist/gates.js.map +1 -0
- package/dist/github.d.ts +155 -0
- package/dist/github.js +531 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +173 -0
- package/dist/index.js.map +1 -0
- package/dist/linkstrength.d.ts +27 -0
- package/dist/linkstrength.js +25 -0
- package/dist/linkstrength.js.map +1 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +64 -0
- package/dist/verdict.js +220 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +39 -3
- package/server.json +28 -0
package/dist/automate.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contribution automation for Greenlight v0.2.0.
|
|
3
|
+
*
|
|
4
|
+
* This automates the pipeline the author ran by hand for four real PRs
|
|
5
|
+
* (shadcn-labs/pdfcn #92 through #95): verdict first, then a claim
|
|
6
|
+
* comment, a fork branch, and finally opening the PR, with DCO awareness
|
|
7
|
+
* throughout. Safety is a feature: every write command defaults to a
|
|
8
|
+
* dry run that prints exactly what would happen. Nothing is posted or
|
|
9
|
+
* created without --yes (CLI) or execute: true (MCP), and write commands
|
|
10
|
+
* refuse to run at all without a token.
|
|
11
|
+
*
|
|
12
|
+
* Pure logic (branch slugs, comment text, PREP.md rendering) is separated
|
|
13
|
+
* from IO through the injectable GitHubClient interface, so tests run
|
|
14
|
+
* fully offline with a fake client. The live client is createRestClient.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { scoreIssue } from "./verdict.js";
|
|
19
|
+
import { parseGates, gateLabel } from "./gates.js";
|
|
20
|
+
import { githubToken, apiGet, apiPost, ApiNotFoundError } from "./github.js";
|
|
21
|
+
/** Thrown when an automation command refuses to proceed. CLI exits 1 on this. */
|
|
22
|
+
export class AutomationRefusal extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "AutomationRefusal";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Live REST implementation of GitHubClient. */
|
|
29
|
+
export function createRestClient() {
|
|
30
|
+
return {
|
|
31
|
+
async getRepo(owner, repo) {
|
|
32
|
+
const r = (await apiGet(`/repos/${owner}/${repo}`));
|
|
33
|
+
return { defaultBranch: String(r.default_branch ?? "main") };
|
|
34
|
+
},
|
|
35
|
+
async getViewer() {
|
|
36
|
+
const r = (await apiGet("/user"));
|
|
37
|
+
const login = String(r.login ?? "");
|
|
38
|
+
if (!login)
|
|
39
|
+
throw new Error("could not determine the GitHub user from /user");
|
|
40
|
+
return login;
|
|
41
|
+
},
|
|
42
|
+
async forkExists(viewer, repo) {
|
|
43
|
+
try {
|
|
44
|
+
const r = (await apiGet(`/repos/${viewer}/${repo}`));
|
|
45
|
+
return r.fork === true;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof ApiNotFoundError)
|
|
49
|
+
return false;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async createFork(owner, repo) {
|
|
54
|
+
await apiPost(`/repos/${owner}/${repo}/forks`, {});
|
|
55
|
+
},
|
|
56
|
+
async getBranchSha(viewer, repo, branch) {
|
|
57
|
+
try {
|
|
58
|
+
const r = (await apiGet(`/repos/${viewer}/${repo}/git/ref/heads/${branch}`));
|
|
59
|
+
return String(r.object?.sha ?? "");
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err instanceof ApiNotFoundError)
|
|
63
|
+
return undefined;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async createBranch(viewer, repo, branch, sha) {
|
|
68
|
+
await apiPost(`/repos/${viewer}/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha });
|
|
69
|
+
},
|
|
70
|
+
async compareCommits(owner, repo, base, headOwner, headBranch) {
|
|
71
|
+
const head = headOwner === owner ? headBranch : `${headOwner}:${headBranch}`;
|
|
72
|
+
const r = (await apiGet(`/repos/${owner}/${repo}/compare/${base}...${head}`));
|
|
73
|
+
return {
|
|
74
|
+
aheadBy: Number(r.ahead_by ?? 0),
|
|
75
|
+
commits: (r.commits ?? []).map((c) => ({
|
|
76
|
+
sha: String(c.sha ?? ""),
|
|
77
|
+
message: String(c.commit?.message ?? ""),
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
async listClosedPullHeadRefs(owner, repo) {
|
|
82
|
+
const r = (await apiGet(`/repos/${owner}/${repo}/pulls?state=closed&per_page=30`));
|
|
83
|
+
return (r ?? []).map((p) => String(p.head?.ref ?? "")).filter((ref) => ref.length > 0);
|
|
84
|
+
},
|
|
85
|
+
async repoRootNames(owner, repo) {
|
|
86
|
+
const r = (await apiGet(`/repos/${owner}/${repo}/contents/`));
|
|
87
|
+
return (r ?? []).map((e) => String(e.name ?? "")).filter((n) => n.length > 0);
|
|
88
|
+
},
|
|
89
|
+
async fetchRootFile(owner, repo, path) {
|
|
90
|
+
try {
|
|
91
|
+
const r = (await apiGet(`/repos/${owner}/${repo}/contents/${path}`));
|
|
92
|
+
if (typeof r.content !== "string")
|
|
93
|
+
return undefined;
|
|
94
|
+
return Buffer.from(r.content, r.encoding === "base64" ? "base64" : "utf8").toString("utf8");
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof ApiNotFoundError)
|
|
98
|
+
return undefined;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
async listRecentCommitMessages(owner, repo, branch, count) {
|
|
103
|
+
const r = (await apiGet(`/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&per_page=${count}`));
|
|
104
|
+
return (r ?? []).map((c) => String(c.commit?.message ?? "").split("\n")[0] ?? "");
|
|
105
|
+
},
|
|
106
|
+
async postIssueComment(owner, repo, number, body) {
|
|
107
|
+
const r = (await apiPost(`/repos/${owner}/${repo}/issues/${number}/comments`, { body }));
|
|
108
|
+
return String(r.html_url ?? "");
|
|
109
|
+
},
|
|
110
|
+
async createPullRequest(owner, repo, opts) {
|
|
111
|
+
const r = (await apiPost(`/repos/${owner}/${repo}/pulls`, opts));
|
|
112
|
+
return String(r.html_url ?? "");
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Write commands need a token. Dry runs do not. */
|
|
117
|
+
export function requireWriteToken() {
|
|
118
|
+
if (!githubToken()) {
|
|
119
|
+
throw new Error("Write operations require a token: set GITHUB_TOKEN (or GH_TOKEN) to a GitHub personal access token, then re-run with --yes.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function gateDetected(gates, kind) {
|
|
123
|
+
return gates.some((g) => g.kind === kind && g.detected);
|
|
124
|
+
}
|
|
125
|
+
/** Slugify an issue title: strip leading labels, lowercase, dashes, 40-char cap. */
|
|
126
|
+
export function titleSlug(title) {
|
|
127
|
+
return (title
|
|
128
|
+
.replace(/^(?:block|feature|feat|fix|component|docs?|chore|refactor|test|bug):\s*/i, "")
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
131
|
+
.replace(/-+/g, "-")
|
|
132
|
+
.replace(/^-+|-+$/g, "")
|
|
133
|
+
.slice(0, 40)
|
|
134
|
+
.replace(/-+$/, "") || "work");
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Branch name from an issue title: strip leading labels like "block:" or
|
|
138
|
+
* "feat:", slugify, cap at 40 chars, append the issue number.
|
|
139
|
+
* "block: Resume" -> "feat/resume-61"
|
|
140
|
+
*/
|
|
141
|
+
export function branchSlugFor(title, number) {
|
|
142
|
+
return `feat/${titleSlug(title)}-${number}`;
|
|
143
|
+
}
|
|
144
|
+
const PATTERN_THRESHOLD = 3;
|
|
145
|
+
/**
|
|
146
|
+
* Learn a dominant branch naming pattern from recent closed PR head refs.
|
|
147
|
+
* Returns undefined when no prefix or suffix reaches the 3-branch
|
|
148
|
+
* threshold, so naming falls back to the default.
|
|
149
|
+
*/
|
|
150
|
+
export function detectBranchPattern(refs) {
|
|
151
|
+
if (refs.length === 0)
|
|
152
|
+
return undefined;
|
|
153
|
+
const split = refs.map((r) => {
|
|
154
|
+
const i = r.indexOf("/");
|
|
155
|
+
return i < 0 ? { prefix: "", rest: r } : { prefix: r.slice(0, i), rest: r.slice(i + 1) };
|
|
156
|
+
});
|
|
157
|
+
const prefixCounts = new Map();
|
|
158
|
+
for (const s of split) {
|
|
159
|
+
if (s.prefix.length > 0)
|
|
160
|
+
prefixCounts.set(s.prefix, (prefixCounts.get(s.prefix) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
const dominantPrefix = [...prefixCounts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
163
|
+
const suffixCounts = new Map();
|
|
164
|
+
for (const s of split) {
|
|
165
|
+
const tokens = s.rest.split("-");
|
|
166
|
+
const last = tokens.length >= 2 ? tokens[tokens.length - 1] : "";
|
|
167
|
+
if (/^[a-z]{2,}$/.test(last))
|
|
168
|
+
suffixCounts.set(last, (suffixCounts.get(last) ?? 0) + 1);
|
|
169
|
+
}
|
|
170
|
+
const dominantSuffix = [...suffixCounts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
171
|
+
const prefix = dominantPrefix && dominantPrefix[1] >= PATTERN_THRESHOLD ? dominantPrefix[0] : undefined;
|
|
172
|
+
const suffix = dominantSuffix && dominantSuffix[1] >= PATTERN_THRESHOLD ? dominantSuffix[0] : undefined;
|
|
173
|
+
if (!prefix && !suffix)
|
|
174
|
+
return undefined;
|
|
175
|
+
return { prefix: prefix ?? "feat", suffix: suffix ?? null };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Suggest a branch name, learning the repo's convention from recent closed
|
|
179
|
+
* PR head refs. A dominant suffix token (e.g. "-block" from
|
|
180
|
+
* feat/business-letter-block) replaces the issue number; a dominant
|
|
181
|
+
* prefix style replaces "feat/". Falls back to feat/<slug>-<number>.
|
|
182
|
+
*/
|
|
183
|
+
export function suggestBranchName(title, number, refs) {
|
|
184
|
+
const defaultName = branchSlugFor(title, number);
|
|
185
|
+
const pattern = detectBranchPattern(refs);
|
|
186
|
+
if (!pattern)
|
|
187
|
+
return { branch: defaultName, defaultName, note: "" };
|
|
188
|
+
const slug = titleSlug(title);
|
|
189
|
+
let rest;
|
|
190
|
+
if (pattern.suffix) {
|
|
191
|
+
const tokens = slug.split("-");
|
|
192
|
+
rest = tokens[tokens.length - 1] === pattern.suffix ? slug : `${slug}-${pattern.suffix}`;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
rest = `${slug}-${number}`;
|
|
196
|
+
}
|
|
197
|
+
const branch = `${pattern.prefix}/${rest}`;
|
|
198
|
+
if (branch === defaultName)
|
|
199
|
+
return { branch: defaultName, defaultName, note: "" };
|
|
200
|
+
const note = `repo convention from recent PRs: ${pattern.prefix}/<slug>${pattern.suffix ? `-${pattern.suffix}` : ""}`;
|
|
201
|
+
return { branch, defaultName, note };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Claim comment text shaped by the repo's gates. Assignment-gated repos get
|
|
205
|
+
* an assignment request and a promise not to start work early; every comment
|
|
206
|
+
* notes the comment-first convention; DCO repos get a sign-off commitment.
|
|
207
|
+
*/
|
|
208
|
+
export function buildClaimComment(snapshot, gates, viewer) {
|
|
209
|
+
const who = viewer ? `Hi, I'm ${viewer}. ` : "Hi. ";
|
|
210
|
+
const assignment = gateDetected(gates, "assignment_required");
|
|
211
|
+
const dco = gateDetected(gates, "dco_required");
|
|
212
|
+
if (assignment) {
|
|
213
|
+
return (`${who}I'd like to work on this: ${snapshot.title}.\n\n` +
|
|
214
|
+
`I see this repo asks contributors to be assigned before submitting a PR. ` +
|
|
215
|
+
`Could you assign this issue to me? I won't start work until it's assigned.\n\n` +
|
|
216
|
+
`I'm commenting first per the repo's contribution guidelines, and I'll follow ` +
|
|
217
|
+
`the rest of the process before opening a PR.`);
|
|
218
|
+
}
|
|
219
|
+
const dcoLine = dco
|
|
220
|
+
? " I'll sign off every commit with `git commit -s` (DCO) and open a PR against the issue."
|
|
221
|
+
: " I'll open a PR against the issue once the work is ready.";
|
|
222
|
+
return (`${who}I'd like to take this on: ${snapshot.title}.\n\n` +
|
|
223
|
+
`I'm commenting first per the repo's contribution guidelines, and I'll follow ` +
|
|
224
|
+
`the contribution process.${dcoLine}`);
|
|
225
|
+
}
|
|
226
|
+
/** Required actions checklist rendered from detected gates. */
|
|
227
|
+
export function gateActions(gates) {
|
|
228
|
+
const actions = [];
|
|
229
|
+
for (const g of gates) {
|
|
230
|
+
if (!g.detected)
|
|
231
|
+
continue;
|
|
232
|
+
switch (g.kind) {
|
|
233
|
+
case "dco_required":
|
|
234
|
+
actions.push("Sign every commit with `git commit -s` (DCO sign-off required).");
|
|
235
|
+
break;
|
|
236
|
+
case "assignment_required":
|
|
237
|
+
actions.push("Get assigned on the issue before opening a PR; `greenlight claim` asks for assignment.");
|
|
238
|
+
break;
|
|
239
|
+
case "claim_first":
|
|
240
|
+
actions.push("Post a claim comment on the issue before starting work; `greenlight claim` posts one that satisfies the comment-first ritual.");
|
|
241
|
+
break;
|
|
242
|
+
case "vouch_required":
|
|
243
|
+
actions.push("A maintainer must vouch for you first; this cannot be automated.");
|
|
244
|
+
break;
|
|
245
|
+
case "cla_required":
|
|
246
|
+
actions.push("Sign the Contributor License Agreement before opening a PR.");
|
|
247
|
+
break;
|
|
248
|
+
case "discussion_first":
|
|
249
|
+
actions.push("Start a discussion on the issue before writing code.");
|
|
250
|
+
break;
|
|
251
|
+
case "collaborators_only":
|
|
252
|
+
actions.push("Only collaborators can open PRs in this repo.");
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return actions;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* claim: verdict first, then post a claim comment shaped by the repo gates.
|
|
260
|
+
* Refuses on TAKEN or STALE verdicts unless forced, and refuses outright
|
|
261
|
+
* on vouch-gated repos (a vouch cannot be automated). Dry run by default.
|
|
262
|
+
*/
|
|
263
|
+
export async function claimIssue(client, snapshot, opts) {
|
|
264
|
+
const result = scoreIssue(snapshot);
|
|
265
|
+
const ref = `${snapshot.owner}/${snapshot.repo}#${snapshot.number}`;
|
|
266
|
+
if ((result.verdict === "TAKEN" || result.verdict === "STALE") && !opts.force) {
|
|
267
|
+
throw new AutomationRefusal(`refusing to claim ${ref}: verdict is ${result.verdict} (${result.reasons[0] ?? "no reason"}). Pass --force to claim anyway.`);
|
|
268
|
+
}
|
|
269
|
+
const gates = parseGates(snapshot.contributing);
|
|
270
|
+
if (gateDetected(gates, "vouch_required")) {
|
|
271
|
+
throw new AutomationRefusal(`refusing to claim ${ref}: this repo requires a maintainer vouch before outside PRs, and a vouch cannot be automated. Request a vouch manually first.`);
|
|
272
|
+
}
|
|
273
|
+
let viewer;
|
|
274
|
+
try {
|
|
275
|
+
viewer = await client.getViewer();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* tokenless dry run: no name available */
|
|
279
|
+
}
|
|
280
|
+
const commentBody = buildClaimComment(snapshot, gates, viewer);
|
|
281
|
+
if (!opts.yes) {
|
|
282
|
+
return { verdict: result, commentBody, commentUrl: null, executed: false, viewer };
|
|
283
|
+
}
|
|
284
|
+
requireWriteToken();
|
|
285
|
+
const commentUrl = await client.postIssueComment(snapshot.owner, snapshot.repo, snapshot.number, commentBody);
|
|
286
|
+
return { verdict: result, commentBody, commentUrl, executed: true, viewer };
|
|
287
|
+
}
|
|
288
|
+
function verdictLine(result) {
|
|
289
|
+
return `[${result.verdict}] ${result.reasons[0] ?? "no reason"}`;
|
|
290
|
+
}
|
|
291
|
+
/** Pure renderer for the PREP.md checklist file. */
|
|
292
|
+
export function renderPrepDoc(args) {
|
|
293
|
+
const { snapshot, verdict, gates, branch, branchDefault, branchNote, viewer, issueUrl, conventions } = args;
|
|
294
|
+
const actions = gateActions(gates);
|
|
295
|
+
const lines = [
|
|
296
|
+
`# Greenlight prep: ${snapshot.owner}/${snapshot.repo}#${snapshot.number}`,
|
|
297
|
+
``,
|
|
298
|
+
`## Verdict`,
|
|
299
|
+
``,
|
|
300
|
+
verdictLine(verdict),
|
|
301
|
+
...verdict.reasons.slice(1).map((r) => `- ${r}`),
|
|
302
|
+
``,
|
|
303
|
+
`## Gates`,
|
|
304
|
+
``,
|
|
305
|
+
];
|
|
306
|
+
if (actions.length === 0) {
|
|
307
|
+
lines.push("No blocking gates detected.");
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
lines.push(...actions.map((a) => `- ${a}`));
|
|
311
|
+
}
|
|
312
|
+
lines.push(``, `## Repo conventions`, ``);
|
|
313
|
+
if (conventions) {
|
|
314
|
+
if (conventions.houseRuleFiles.length > 0) {
|
|
315
|
+
lines.push(`- House rules: ${conventions.houseRuleFiles.join(", ")}`);
|
|
316
|
+
}
|
|
317
|
+
if (conventions.verifyCommands.length > 0) {
|
|
318
|
+
lines.push(`- Verify command from house rules: ${conventions.verifyCommands.map((c) => `\`${c}\``).join(", ")}`);
|
|
319
|
+
}
|
|
320
|
+
if (conventions.changesDir) {
|
|
321
|
+
lines.push(`- Add a ${conventions.changesDirName ?? ".changes"} entry (this repo assembles its changelog from ${conventions.changesDirName ?? ".changes"}/)`);
|
|
322
|
+
}
|
|
323
|
+
if (conventions.packageScripts.length > 0) {
|
|
324
|
+
lines.push(`- Run before pushing: ${conventions.packageScripts.map((s) => `\`pnpm ${s}\``).join(", ")}`);
|
|
325
|
+
}
|
|
326
|
+
if (conventions.conventionalCommits) {
|
|
327
|
+
lines.push(`- Commits follow Conventional Commits${conventions.exampleCommit ? `, e.g. "${conventions.exampleCommit}"` : ""}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
lines.push("No repo conventions detected (no house-rules files, package.json, or commit history found).");
|
|
332
|
+
}
|
|
333
|
+
lines.push(``, `## Issue`, ``, snapshot.title, ``);
|
|
334
|
+
if (snapshot.body)
|
|
335
|
+
lines.push(snapshot.body.trim().slice(0, 2000), ``);
|
|
336
|
+
if (branchNote) {
|
|
337
|
+
lines.push(`## Branch`, ``, `${branch} (${branchNote}; the default name would be ${branchDefault})`, ``);
|
|
338
|
+
}
|
|
339
|
+
lines.push(`## Next commands`, ``, `1. Claim the issue (posts the comment above):`, ` greenlight claim ${issueUrl} --yes`, `2. Clone your fork and check out the branch:`, ` git clone git@github.com:${viewer}/${snapshot.repo}.git && cd ${snapshot.repo} && git checkout ${branch}`, `3. Do the work, commit with DCO sign-off where required, then open the PR:`, ` greenlight pr ${issueUrl} --branch ${branch} --yes`, ``);
|
|
340
|
+
return lines.join("\n");
|
|
341
|
+
}
|
|
342
|
+
/** Script names worth surfacing when found in the root package.json. */
|
|
343
|
+
const INTERESTING_SCRIPTS = ["test", "verify", "lint", "typecheck", "build"];
|
|
344
|
+
/** Conventional Commits subject prefix: feat(scope): ..., fix: ..., etc. */
|
|
345
|
+
const CONVENTIONAL_COMMIT_RES = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: /;
|
|
346
|
+
/**
|
|
347
|
+
* Extract backticked run commands quoted in house-rules text, e.g. the
|
|
348
|
+
* `pnpm verify` reticle's AGENTS.md insists on. Returns them deduplicated,
|
|
349
|
+
* in order of appearance, capped at five.
|
|
350
|
+
*/
|
|
351
|
+
export function extractVerifyCommands(text) {
|
|
352
|
+
const out = [];
|
|
353
|
+
const re = /`((?:pnpm|npm|yarn|bun|make)\s+[a-zA-Z0-9:._-]+)`/g;
|
|
354
|
+
let m;
|
|
355
|
+
while ((m = re.exec(text)) !== null && out.length < 5) {
|
|
356
|
+
if (!out.includes(m[1]))
|
|
357
|
+
out.push(m[1]);
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Learn the repo's contribution rituals for the PREP.md checklist:
|
|
363
|
+
* which house-rules files exist, the verify/test command they quote,
|
|
364
|
+
* whether the repo assembles its changelog from a .changes directory,
|
|
365
|
+
* which root package.json scripts exist, and whether recent commits
|
|
366
|
+
* follow Conventional Commits. Every probe is best-effort: a failing
|
|
367
|
+
* probe is skipped, never fatal.
|
|
368
|
+
*/
|
|
369
|
+
export async function gatherRepoConventions(client, snapshot) {
|
|
370
|
+
const { owner, repo } = snapshot;
|
|
371
|
+
const houseRuleFiles = (snapshot.houseRules ?? []).map((h) => h.file);
|
|
372
|
+
const verifyCommands = extractVerifyCommands((snapshot.houseRules ?? []).map((h) => h.text).join("\n"));
|
|
373
|
+
let changesDir = false;
|
|
374
|
+
let changesDirName;
|
|
375
|
+
try {
|
|
376
|
+
const names = await client.repoRootNames(owner, repo);
|
|
377
|
+
const found = names.find((n) => n === ".changes" || n === ".changeset");
|
|
378
|
+
if (found) {
|
|
379
|
+
changesDir = true;
|
|
380
|
+
changesDirName = found;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch { /* root listing failed; skip */ }
|
|
384
|
+
let packageScripts = [];
|
|
385
|
+
try {
|
|
386
|
+
const pkg = await client.fetchRootFile(owner, repo, "package.json");
|
|
387
|
+
if (pkg) {
|
|
388
|
+
const scripts = Object.keys(JSON.parse(pkg).scripts ?? {});
|
|
389
|
+
packageScripts = INTERESTING_SCRIPTS.filter((s) => scripts.includes(s));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch { /* no package.json or unparsable; skip */ }
|
|
393
|
+
let conventionalCommits = false;
|
|
394
|
+
let exampleCommit;
|
|
395
|
+
try {
|
|
396
|
+
const { defaultBranch } = await client.getRepo(owner, repo);
|
|
397
|
+
const messages = await client.listRecentCommitMessages(owner, repo, defaultBranch, 20);
|
|
398
|
+
const subjects = messages.map((m) => m.trim()).filter((m) => m.length > 0);
|
|
399
|
+
const matching = subjects.filter((m) => CONVENTIONAL_COMMIT_RES.test(m));
|
|
400
|
+
if (subjects.length > 0 && matching.length / subjects.length >= 0.6) {
|
|
401
|
+
conventionalCommits = true;
|
|
402
|
+
exampleCommit = matching[0].length > 60 ? matching[0].slice(0, 60) + "..." : matching[0];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch { /* commit history unavailable; skip */ }
|
|
406
|
+
if (houseRuleFiles.length === 0 &&
|
|
407
|
+
verifyCommands.length === 0 &&
|
|
408
|
+
!changesDir &&
|
|
409
|
+
packageScripts.length === 0 &&
|
|
410
|
+
!conventionalCommits) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
houseRuleFiles,
|
|
415
|
+
verifyCommands,
|
|
416
|
+
changesDir,
|
|
417
|
+
...(changesDirName ? { changesDirName } : {}),
|
|
418
|
+
packageScripts,
|
|
419
|
+
conventionalCommits,
|
|
420
|
+
...(exampleCommit ? { exampleCommit } : {}),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* prep: verdict, fork check (create with --yes), branch naming and creation,
|
|
425
|
+
* and a local PREP.md checklist. Never clones; reports the clone command.
|
|
426
|
+
* Writes the PREP.md file in both dry-run and execute modes.
|
|
427
|
+
*/
|
|
428
|
+
export async function prepIssue(client, snapshot, opts) {
|
|
429
|
+
const result = scoreIssue(snapshot);
|
|
430
|
+
const gates = parseGates(snapshot.contributing);
|
|
431
|
+
const { owner, repo, number } = snapshot;
|
|
432
|
+
// Learn the repo's branch convention from recent closed PRs (best effort).
|
|
433
|
+
const defaultBranchName = branchSlugFor(snapshot.title, number);
|
|
434
|
+
let suggestion = {
|
|
435
|
+
branch: defaultBranchName,
|
|
436
|
+
defaultName: defaultBranchName,
|
|
437
|
+
note: "",
|
|
438
|
+
};
|
|
439
|
+
try {
|
|
440
|
+
const refs = await client.listClosedPullHeadRefs(owner, repo);
|
|
441
|
+
suggestion = suggestBranchName(snapshot.title, number, refs);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
/* branch naming falls back to the default when the PR list cannot be read */
|
|
445
|
+
}
|
|
446
|
+
const branch = suggestion.branch;
|
|
447
|
+
const prepDir = opts.dir ?? join("greenlight-prep", `${repo}-${number}`);
|
|
448
|
+
const prepFile = join(prepDir, "PREP.md");
|
|
449
|
+
let viewer;
|
|
450
|
+
try {
|
|
451
|
+
viewer = await client.getViewer();
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
/* tokenless dry run: fork/branch checks need a viewer, so we report instead */
|
|
455
|
+
}
|
|
456
|
+
let forkReady = false;
|
|
457
|
+
let forkCreated = false;
|
|
458
|
+
let branchReady = false;
|
|
459
|
+
let branchCreated = false;
|
|
460
|
+
if (viewer) {
|
|
461
|
+
forkReady = await client.forkExists(viewer, repo);
|
|
462
|
+
if (!forkReady && opts.yes) {
|
|
463
|
+
requireWriteToken();
|
|
464
|
+
await client.createFork(owner, repo);
|
|
465
|
+
forkCreated = true;
|
|
466
|
+
forkReady = true;
|
|
467
|
+
}
|
|
468
|
+
if (forkReady) {
|
|
469
|
+
const sha = await client.getBranchSha(viewer, repo, branch);
|
|
470
|
+
branchReady = sha !== undefined;
|
|
471
|
+
if (!branchReady && opts.yes) {
|
|
472
|
+
requireWriteToken();
|
|
473
|
+
const { defaultBranch } = await client.getRepo(owner, repo);
|
|
474
|
+
const baseSha = await client.getBranchSha(viewer, repo, defaultBranch);
|
|
475
|
+
if (!baseSha) {
|
|
476
|
+
throw new AutomationRefusal(`fork ${viewer}/${repo} has no ${defaultBranch} branch ref; clone the fork and push the default branch first.`);
|
|
477
|
+
}
|
|
478
|
+
await client.createBranch(viewer, repo, branch, baseSha);
|
|
479
|
+
branchCreated = true;
|
|
480
|
+
branchReady = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const nextCommands = [
|
|
485
|
+
`greenlight claim ${snapshot.sourceUrl} --yes`,
|
|
486
|
+
viewer
|
|
487
|
+
? `git clone git@github.com:${viewer}/${repo}.git && cd ${repo} && git checkout ${branch}`
|
|
488
|
+
: `# set GITHUB_TOKEN, then: greenlight prep ${snapshot.sourceUrl} --yes`,
|
|
489
|
+
`greenlight pr ${snapshot.sourceUrl} --branch ${branch} --yes`,
|
|
490
|
+
];
|
|
491
|
+
// Learn the repo's contribution rituals (best-effort; never fatal).
|
|
492
|
+
let conventions;
|
|
493
|
+
try {
|
|
494
|
+
conventions = await gatherRepoConventions(client, snapshot);
|
|
495
|
+
}
|
|
496
|
+
catch { /* conventions are advisory; skip */ }
|
|
497
|
+
mkdirSync(prepDir, { recursive: true });
|
|
498
|
+
writeFileSync(prepFile, renderPrepDoc({
|
|
499
|
+
snapshot,
|
|
500
|
+
verdict: result,
|
|
501
|
+
gates,
|
|
502
|
+
branch,
|
|
503
|
+
branchDefault: suggestion.defaultName,
|
|
504
|
+
branchNote: suggestion.note || undefined,
|
|
505
|
+
viewer: viewer ?? "<you>",
|
|
506
|
+
issueUrl: snapshot.sourceUrl,
|
|
507
|
+
conventions,
|
|
508
|
+
}), "utf8");
|
|
509
|
+
return {
|
|
510
|
+
verdict: result,
|
|
511
|
+
branch,
|
|
512
|
+
branchDefault: suggestion.defaultName,
|
|
513
|
+
branchPatternNote: suggestion.note,
|
|
514
|
+
forkReady,
|
|
515
|
+
forkCreated,
|
|
516
|
+
branchReady,
|
|
517
|
+
branchCreated,
|
|
518
|
+
prepFile,
|
|
519
|
+
nextCommands,
|
|
520
|
+
conventions,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function firstLine(message) {
|
|
524
|
+
return message.split("\n")[0] ?? "";
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* pr: preconditions, DCO verification, then open the PR. Preconditions:
|
|
528
|
+
* the branch must exist on the fork and be at least one commit ahead of
|
|
529
|
+
* upstream's default branch. When the repo requires DCO, every ahead
|
|
530
|
+
* commit must carry a Signed-off-by line; the first unsigned commit is a
|
|
531
|
+
* hard refusal (this exact failure mode burned real review time). Dry run
|
|
532
|
+
* prints the composed PR without posting it.
|
|
533
|
+
*/
|
|
534
|
+
export async function openPr(client, snapshot, opts) {
|
|
535
|
+
const result = scoreIssue(snapshot);
|
|
536
|
+
const gates = parseGates(snapshot.contributing);
|
|
537
|
+
const { owner, repo, number } = snapshot;
|
|
538
|
+
const checks = [];
|
|
539
|
+
let viewer;
|
|
540
|
+
try {
|
|
541
|
+
viewer = await client.getViewer();
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
/* tokenless dry run */
|
|
545
|
+
}
|
|
546
|
+
const headOwner = viewer ?? "<you>";
|
|
547
|
+
const head = `${headOwner}:${opts.branch}`;
|
|
548
|
+
if (!viewer) {
|
|
549
|
+
return {
|
|
550
|
+
verdict: result,
|
|
551
|
+
branch: opts.branch,
|
|
552
|
+
head,
|
|
553
|
+
base: "<upstream default>",
|
|
554
|
+
title: opts.title ?? snapshot.title,
|
|
555
|
+
body: composePrBody(snapshot, result, gates),
|
|
556
|
+
checks: ["branch not verified (no token); set GITHUB_TOKEN to verify"],
|
|
557
|
+
prUrl: null,
|
|
558
|
+
executed: false,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const branchSha = await client.getBranchSha(viewer, repo, opts.branch);
|
|
562
|
+
if (!branchSha) {
|
|
563
|
+
throw new AutomationRefusal(`branch ${opts.branch} does not exist on ${viewer}/${repo}; run greenlight prep first.`);
|
|
564
|
+
}
|
|
565
|
+
checks.push(`branch exists on ${viewer}/${repo}: ${branchSha.slice(0, 7)}`);
|
|
566
|
+
const { defaultBranch } = await client.getRepo(owner, repo);
|
|
567
|
+
const cmp = await client.compareCommits(owner, repo, defaultBranch, viewer, opts.branch);
|
|
568
|
+
if (cmp.aheadBy < 1) {
|
|
569
|
+
throw new AutomationRefusal(`branch ${opts.branch} is not ahead of ${owner}/${repo}@${defaultBranch}; nothing to open a PR for.`);
|
|
570
|
+
}
|
|
571
|
+
checks.push(`branch is ${cmp.aheadBy} commit(s) ahead of ${defaultBranch}`);
|
|
572
|
+
if (gateDetected(gates, "dco_required")) {
|
|
573
|
+
const unsigned = cmp.commits.filter((c) => !/Signed-off-by:/i.test(c.message));
|
|
574
|
+
if (unsigned.length > 0) {
|
|
575
|
+
throw new AutomationRefusal(`DCO sign-off required, but ${unsigned.length} commit(s) lack a Signed-off-by line:\n` +
|
|
576
|
+
unsigned.map((c) => ` - ${c.sha.slice(0, 7)} ${firstLine(c.message)}`).join("\n") +
|
|
577
|
+
`\nAmend with git commit --amend -s or rebase with --signoff before opening the PR.`);
|
|
578
|
+
}
|
|
579
|
+
checks.push(`DCO: all ${cmp.commits.length} commit(s) carry a Signed-off-by line`);
|
|
580
|
+
}
|
|
581
|
+
const latestSubject = cmp.commits.length > 0 ? firstLine(cmp.commits[cmp.commits.length - 1].message) : "";
|
|
582
|
+
const title = opts.title ?? (latestSubject || snapshot.title);
|
|
583
|
+
const body = composePrBody(snapshot, result, gates);
|
|
584
|
+
if (!opts.yes) {
|
|
585
|
+
return {
|
|
586
|
+
verdict: result,
|
|
587
|
+
branch: opts.branch,
|
|
588
|
+
head,
|
|
589
|
+
base: defaultBranch,
|
|
590
|
+
title,
|
|
591
|
+
body,
|
|
592
|
+
checks,
|
|
593
|
+
prUrl: null,
|
|
594
|
+
executed: false,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
requireWriteToken();
|
|
598
|
+
const prUrl = await client.createPullRequest(owner, repo, {
|
|
599
|
+
title,
|
|
600
|
+
body,
|
|
601
|
+
head,
|
|
602
|
+
base: defaultBranch,
|
|
603
|
+
});
|
|
604
|
+
return {
|
|
605
|
+
verdict: result,
|
|
606
|
+
branch: opts.branch,
|
|
607
|
+
head,
|
|
608
|
+
base: defaultBranch,
|
|
609
|
+
title,
|
|
610
|
+
body,
|
|
611
|
+
checks,
|
|
612
|
+
prUrl,
|
|
613
|
+
executed: true,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/** PR body template: closes keyword, summary, and the checks at open time. */
|
|
617
|
+
export function composePrBody(snapshot, result, gates) {
|
|
618
|
+
const detected = gates.filter((g) => g.detected);
|
|
619
|
+
const lines = [
|
|
620
|
+
`Closes #${snapshot.number}.`,
|
|
621
|
+
``,
|
|
622
|
+
`## Summary`,
|
|
623
|
+
``,
|
|
624
|
+
snapshot.title,
|
|
625
|
+
``,
|
|
626
|
+
`## Checks`,
|
|
627
|
+
``,
|
|
628
|
+
`- Verdict at open: ${verdictLine(result)}`,
|
|
629
|
+
];
|
|
630
|
+
for (const g of detected)
|
|
631
|
+
lines.push(`- Gate: ${gateLabel(g.kind)}`);
|
|
632
|
+
if (detected.length === 0)
|
|
633
|
+
lines.push("- Gates: none detected");
|
|
634
|
+
lines.push(``);
|
|
635
|
+
return lines.join("\n");
|
|
636
|
+
}
|
|
637
|
+
//# sourceMappingURL=automate.js.map
|