@bompus/house-rules 0.0.0-stage → 0.11.0
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/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +347 -2
- package/THIRD_PARTY_NOTICES.md +188 -0
- package/compose.mjs +119 -0
- package/composition.mjs +353 -0
- package/config-view.mjs +194 -0
- package/config.mjs +320 -0
- package/docs/assets/banner-dark.png +0 -0
- package/docs/assets/banner-light.png +0 -0
- package/docs/assets/overview.png +0 -0
- package/docs/configuration.md +193 -0
- package/docs/npm-release.md +45 -0
- package/docs/skill-names.md +50 -0
- package/examples/person/house-rules.json +15 -0
- package/examples/person/rules/10-tooling.md +8 -0
- package/examples/person/rules/20-reporting.md +9 -0
- package/examples/project/AGENTS.md +18 -0
- package/package.json +40 -4
- package/rule-references.mjs +91 -0
- package/rules/core.md +431 -0
- package/rules/modifiers/coded-offers.md +40 -0
- package/rules/modifiers/effort-estimates.md +41 -0
- package/rules/modifiers/land-when-done.md +12 -0
- package/rules/modifiers/low-quota-handoff.md +19 -0
- package/rules/modifiers/multi-agent.md +34 -0
- package/rules/modifiers/no-attribution.md +10 -0
- package/rules/modifiers/plan-files.md +50 -0
- package/rules/modifiers/question-cards.md +48 -0
- package/rules/modifiers/release-batching.md +13 -0
- package/rules/modifiers/scratch-on-disk.md +15 -0
- package/rules/modifiers/shared-host-load.md +48 -0
- package/rules/modifiers/solo-operator.md +13 -0
- package/rules/modifiers/squash-landing.md +22 -0
- package/rules/modifiers/swarmail.md +12 -0
- package/rules/references/github-actions.md +45 -0
- package/rules/references/landing.md +39 -0
- package/rules/references/multi-agent.md +47 -0
- package/rules/references/release-batching.md +43 -0
- package/rules/references/squash-landing.md +44 -0
- package/setup.mjs +375 -0
- package/skills/hr-agent-guidance-audit/LICENSE +21 -0
- package/skills/hr-agent-guidance-audit/SKILL.md +86 -0
- package/skills/hr-agent-guidance-audit/agents/openai.yaml +2 -0
- package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +60 -0
- package/skills/hr-agent-guidance-refresh/LICENSE +21 -0
- package/skills/hr-agent-guidance-refresh/SKILL.md +68 -0
- package/skills/hr-agent-guidance-refresh/agents/openai.yaml +2 -0
- package/skills/hr-agent-guidance-refresh/references/verification.md +47 -0
- package/skills/hr-api-exposure-check/SKILL.md +159 -0
- package/skills/hr-api-exposure-check/agents/openai.yaml +3 -0
- package/skills/hr-audit-choices/SKILL.md +67 -0
- package/skills/hr-audit-choices/agents/openai.yaml +2 -0
- package/skills/hr-benchmarking/NOTICE.md +36 -0
- package/skills/hr-benchmarking/SKILL.md +316 -0
- package/skills/hr-benchmarking/agents/openai.yaml +2 -0
- package/skills/hr-benchmarking/references/javascript.md +17 -0
- package/skills/hr-benchmarking/references/priority-findings-20261006.md +115 -0
- package/skills/hr-benchmarking/references/tools.md +160 -0
- package/skills/hr-benchmarking/scripts/process-attribution.ps1 +20 -0
- package/skills/hr-benchmarking/scripts/process-attribution.py +107 -0
- package/skills/hr-better-accessibility/LICENSE +22 -0
- package/skills/hr-better-accessibility/NOTICE.md +46 -0
- package/skills/hr-better-accessibility/SKILL.md +119 -0
- package/skills/hr-better-accessibility/agents/openai.yaml +3 -0
- package/skills/hr-better-accessibility/focus-and-keyboard.md +130 -0
- package/skills/hr-better-accessibility/forms.md +82 -0
- package/skills/hr-better-accessibility/hit-areas.md +98 -0
- package/skills/hr-better-accessibility/motion-and-zoom.md +81 -0
- package/skills/hr-better-accessibility/screen-readers.md +101 -0
- package/skills/hr-better-accessibility/semantics-and-aria.md +84 -0
- package/skills/hr-better-colors/LICENSE +21 -0
- package/skills/hr-better-colors/NOTICE.md +37 -0
- package/skills/hr-better-colors/SKILL.md +114 -0
- package/skills/hr-better-colors/agents/openai.yaml +3 -0
- package/skills/hr-better-colors/color-formats.md +90 -0
- package/skills/hr-better-colors/color-usage.md +140 -0
- package/skills/hr-better-colors/contrast.md +80 -0
- package/skills/hr-better-colors/palette-generation.md +104 -0
- package/skills/hr-better-colors/palette-structure.md +76 -0
- package/skills/hr-better-colors/token-naming.md +97 -0
- package/skills/hr-better-layout/LICENSE +22 -0
- package/skills/hr-better-layout/NOTICE.md +48 -0
- package/skills/hr-better-layout/SKILL.md +92 -0
- package/skills/hr-better-layout/agents/openai.yaml +3 -0
- package/skills/hr-better-layout/grouping-and-alignment.md +144 -0
- package/skills/hr-better-layout/spacing-and-adaptivity.md +215 -0
- package/skills/hr-better-typography/LICENSE +22 -0
- package/skills/hr-better-typography/NOTICE.md +46 -0
- package/skills/hr-better-typography/SKILL.md +172 -0
- package/skills/hr-better-typography/agents/openai.yaml +3 -0
- package/skills/hr-better-typography/choosing-fonts.md +69 -0
- package/skills/hr-better-typography/css-cheat-sheet.md +65 -0
- package/skills/hr-better-typography/details-and-accessibility.md +119 -0
- package/skills/hr-better-typography/spacing-and-sizing.md +131 -0
- package/skills/hr-better-typography/variable-fonts-and-opentype.md +105 -0
- package/skills/hr-better-typography/wrapping-and-punctuation.md +57 -0
- package/skills/hr-better-writing/LICENSE +22 -0
- package/skills/hr-better-writing/NOTICE.md +46 -0
- package/skills/hr-better-writing/SKILL.md +122 -0
- package/skills/hr-better-writing/agents/openai.yaml +3 -0
- package/skills/hr-change-impact/SKILL.md +113 -0
- package/skills/hr-change-impact/agents/openai.yaml +5 -0
- package/skills/hr-code-review/LICENSE +21 -0
- package/skills/hr-code-review/NOTICE.md +26 -0
- package/skills/hr-code-review/SKILL.md +147 -0
- package/skills/hr-code-review/agents/openai.yaml +3 -0
- package/skills/hr-code-review/references/evidence.md +57 -0
- package/skills/hr-design-exploration/SKILL.md +82 -0
- package/skills/hr-design-exploration/agents/openai.yaml +4 -0
- package/skills/hr-diagnosing-bugs/NOTICE.md +45 -0
- package/skills/hr-diagnosing-bugs/SKILL.md +108 -0
- package/skills/hr-diagnosing-bugs/agents/openai.yaml +3 -0
- package/skills/hr-diagnosing-bugs/references/reporting.md +72 -0
- package/skills/hr-explain-code/NOTICE.md +37 -0
- package/skills/hr-explain-code/SKILL.md +110 -0
- package/skills/hr-explain-code/agents/openai.yaml +5 -0
- package/skills/hr-explain-code/references/helper-brief.md +39 -0
- package/skills/hr-extract-shared-steps/SKILL.md +134 -0
- package/skills/hr-extract-shared-steps/agents/openai.yaml +3 -0
- package/skills/hr-handoff/SKILL.md +91 -0
- package/skills/hr-handoff/agents/openai.yaml +5 -0
- package/skills/hr-handoff/assets/ledger.example.json +10 -0
- package/skills/hr-handoff/assets/receipt.example.json +76 -0
- package/skills/hr-handoff/assets/source.example.json +20 -0
- package/skills/hr-handoff/references/reconciliation.md +105 -0
- package/skills/hr-handoff/scripts/check-reconciliation.mjs +282 -0
- package/skills/hr-house-rules-setup/SKILL.md +100 -0
- package/skills/hr-house-rules-setup/agents/openai.yaml +5 -0
- package/skills/hr-issue-tracker-setup/SKILL.md +49 -0
- package/skills/hr-issue-tracker-setup/agents/openai.yaml +5 -0
- package/skills/hr-lean-plan/SKILL.md +142 -0
- package/skills/hr-lean-plan/agents/openai.yaml +5 -0
- package/skills/hr-maintainability-review/SKILL.md +176 -0
- package/skills/hr-maintainability-review/agents/openai.yaml +5 -0
- package/skills/hr-manual-qa/NOTICE.md +36 -0
- package/skills/hr-manual-qa/SKILL.md +37 -0
- package/skills/hr-manual-qa/agents/openai.yaml +3 -0
- package/skills/hr-navigation-retro/LICENSE +21 -0
- package/skills/hr-navigation-retro/NOTICE.md +5 -0
- package/skills/hr-navigation-retro/SKILL.md +86 -0
- package/skills/hr-navigation-retro/agents/openai.yaml +4 -0
- package/skills/hr-navigation-retro/references/inputs.md +67 -0
- package/skills/hr-navigation-retro/scripts/retro.py +552 -0
- package/skills/hr-navigation-retro/scripts/transcripts.py +205 -0
- package/skills/hr-ordering-tests/SKILL.md +51 -0
- package/skills/hr-ordering-tests/agents/openai.yaml +2 -0
- package/skills/hr-plain-prose/SKILL.md +261 -0
- package/skills/hr-plain-prose/agents/openai.yaml +3 -0
- package/skills/hr-plain-prose/scripts/check.mjs +331 -0
- package/skills/hr-pr-followup/LICENSE +21 -0
- package/skills/hr-pr-followup/NOTICE.md +9 -0
- package/skills/hr-pr-followup/SKILL.md +71 -0
- package/skills/hr-pr-followup/agents/openai.yaml +2 -0
- package/skills/hr-pr-followup/references/review-helpers.md +90 -0
- package/skills/hr-pr-followup/scripts/check-pr-review.ts +510 -0
- package/skills/hr-pr-followup/scripts/resolve-pr-thread.ts +219 -0
- package/skills/hr-progress-report/SKILL.md +49 -0
- package/skills/hr-progress-report/agents/openai.yaml +5 -0
- package/skills/hr-read-reddit/SKILL.md +44 -0
- package/skills/hr-read-reddit/agents/openai.yaml +2 -0
- package/skills/hr-read-reddit/scripts/reddit.ts +252 -0
- package/skills/hr-read-x-links/SKILL.md +42 -0
- package/skills/hr-read-x-links/agents/openai.yaml +2 -0
- package/skills/hr-read-x-links/scripts/x-post.ts +264 -0
- package/skills/hr-split-to-prs/LICENSE +21 -0
- package/skills/hr-split-to-prs/NOTICE.md +9 -0
- package/skills/hr-split-to-prs/SKILL.md +61 -0
- package/skills/hr-split-to-prs/agents/openai.yaml +2 -0
- package/skills/hr-stock-ui-audit/SKILL.md +132 -0
- package/skills/hr-stock-ui-audit/agents/openai.yaml +3 -0
- package/skills/hr-stock-ui-audit/scripts/scan.mjs +619 -0
- package/skills/hr-tdd/LICENSE +21 -0
- package/skills/hr-tdd/NOTICE.md +10 -0
- package/skills/hr-tdd/SKILL.md +40 -0
- package/skills/hr-tdd/agents/openai.yaml +3 -0
- package/skills/hr-tdd/mocking.md +39 -0
- package/skills/hr-tdd/tests.md +77 -0
- package/skills/hr-test-audit/SKILL.md +146 -0
- package/skills/hr-test-audit/agents/openai.yaml +3 -0
- package/skills/hr-test-audit/scripts/floor-guard.ts +137 -0
- package/skills/hr-what-next/SKILL.md +61 -0
- package/skills/hr-what-next/agents/openai.yaml +4 -0
- package/skills/hr-what-next/references/reporting.md +68 -0
- package/skills/hr-writing-for-agents/NOTICE.md +46 -0
- package/skills/hr-writing-for-agents/SKILL-MECHANICS.md +83 -0
- package/skills/hr-writing-for-agents/SKILL.md +108 -0
- package/skills/hr-writing-for-agents/agents/openai.yaml +3 -0
- package/skills/hr-writing-pr/NOTICE.md +48 -0
- package/skills/hr-writing-pr/SKILL.md +104 -0
- package/skills/hr-writing-pr/agents/openai.yaml +2 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Refuses to land a pull request while review feedback is open: an unresolved review thread, a
|
|
3
|
+
// review bot (CodeRabbit) whose check is still running, or a bot review whose body carries findings
|
|
4
|
+
// GitHub cannot show as threads ("Outside diff range comments", "Nitpick comments", "Duplicate comments") with no human
|
|
5
|
+
// explicit acknowledgement for that review and the current head. Run only when the repository
|
|
6
|
+
// selects this gate. It also refuses when CodeRabbit's status on the
|
|
7
|
+
// head commit says anything but "Review completed": CodeRabbit marks a skipped review (for example
|
|
8
|
+
// on a non-default base) as success with "Review skipped: …". When the head commit has
|
|
9
|
+
// `.coderabbit.yaml` but no CodeRabbit check, it refuses too. Missing or incomplete reviews
|
|
10
|
+
// require inspection of the review integration and the repository's selected review policy.
|
|
11
|
+
// bun scripts/check-pr-review.ts <pr> [--repo <owner>/<name>] [--wait [--timeout <minutes>]]
|
|
12
|
+
// Exit 1 while feedback is open, 2 when gh fails. Without --repo, gh resolves the repository from
|
|
13
|
+
// the current checkout. --wait polls every 30 s while CodeRabbit's review is still running, and for
|
|
14
|
+
// up to 3 minutes while the head commit has no CodeRabbit check yet (CodeRabbit posts it a little
|
|
15
|
+
// after a push), then reports as usual; after --timeout minutes (default 30) it reports what is
|
|
16
|
+
// still open.
|
|
17
|
+
//
|
|
18
|
+
// A review-body acknowledgement is one PR conversation comment by the authenticated human
|
|
19
|
+
// viewer, who must have WRITE, MAINTAIN or ADMIN permission. Use this exact format:
|
|
20
|
+
// Review: <exact review permalink>
|
|
21
|
+
// Head: <current full head SHA>
|
|
22
|
+
// Reason: <why each finding is fixed or does not apply>
|
|
23
|
+
// It must be posted after that review and its latest body edit. A changed head needs a new
|
|
24
|
+
// acknowledgement. Minimized comments and edits by another account do not count. The check
|
|
25
|
+
// validates association and nonblank reason; the agent or maintainer judges the explanation.
|
|
26
|
+
// Missing permission evidence cannot clear body findings. Generic later replies never clear them.
|
|
27
|
+
// Connections are paginated; partial/malformed API results and head changes exit 2.
|
|
28
|
+
// Only the first thread comment is fetched for its permalink: resolution is a thread-level flag.
|
|
29
|
+
// The reader makes a final head check, not an atomic snapshot of changing review feedback.
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
import { parseArgs } from "node:util";
|
|
32
|
+
|
|
33
|
+
const REVIEW_BOTS = /coderabbit/i;
|
|
34
|
+
const REVIEW_BOT_CONFIG = ".coderabbit.yaml";
|
|
35
|
+
const BODY_FINDINGS = /Outside diff range comments|Nitpick comments|Duplicate comments/;
|
|
36
|
+
const REVIEWED = /^Review completed/;
|
|
37
|
+
const FOLLOW_UP = "check the review integration and repository review policy";
|
|
38
|
+
const RUNNING = "review still running: ";
|
|
39
|
+
const NO_CHECK = "no CodeRabbit check on the head commit";
|
|
40
|
+
const POLL_MS = 30_000;
|
|
41
|
+
const NO_CHECK_GRACE_MS = 180_000;
|
|
42
|
+
|
|
43
|
+
type Author = { __typename: string; login: string } | null;
|
|
44
|
+
type Context = {
|
|
45
|
+
context?: string;
|
|
46
|
+
state?: string;
|
|
47
|
+
description?: string | null;
|
|
48
|
+
name?: string;
|
|
49
|
+
status?: string;
|
|
50
|
+
conclusion?: string | null;
|
|
51
|
+
};
|
|
52
|
+
export type ReviewState = {
|
|
53
|
+
headRefOid: string;
|
|
54
|
+
viewer: Author;
|
|
55
|
+
viewerPermission: string | null;
|
|
56
|
+
reviewThreads: { nodes: { isResolved: boolean; comments: { nodes: { url: string }[] } }[] };
|
|
57
|
+
reviews: {
|
|
58
|
+
nodes: {
|
|
59
|
+
author: Author;
|
|
60
|
+
body: string;
|
|
61
|
+
submittedAt: string | null;
|
|
62
|
+
lastEditedAt: string | null;
|
|
63
|
+
url: string;
|
|
64
|
+
}[];
|
|
65
|
+
};
|
|
66
|
+
comments: {
|
|
67
|
+
nodes: {
|
|
68
|
+
author: Author;
|
|
69
|
+
editor: Author;
|
|
70
|
+
body: string;
|
|
71
|
+
createdAt: string;
|
|
72
|
+
lastEditedAt: string | null;
|
|
73
|
+
isMinimized: boolean;
|
|
74
|
+
}[];
|
|
75
|
+
};
|
|
76
|
+
commits: {
|
|
77
|
+
nodes: {
|
|
78
|
+
commit: {
|
|
79
|
+
tree: { entries: { name: string }[] };
|
|
80
|
+
statusCheckRollup: {
|
|
81
|
+
contexts: {
|
|
82
|
+
nodes: {
|
|
83
|
+
context?: string;
|
|
84
|
+
state?: string;
|
|
85
|
+
description?: string | null;
|
|
86
|
+
name?: string;
|
|
87
|
+
status?: string;
|
|
88
|
+
conclusion?: string | null;
|
|
89
|
+
}[];
|
|
90
|
+
};
|
|
91
|
+
} | null;
|
|
92
|
+
};
|
|
93
|
+
}[];
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const PAGE_INFO = "pageInfo{hasNextPage endCursor}";
|
|
98
|
+
const CONNECTIONS = {
|
|
99
|
+
reviewThreads: `reviewThreads(first:100,after:$reviewThreadsCursor){${PAGE_INFO} nodes{isResolved comments(first:1){nodes{url}}}}`,
|
|
100
|
+
reviews: `reviews(first:100,after:$reviewsCursor){${PAGE_INFO} nodes{author{__typename login} body submittedAt lastEditedAt url}}`,
|
|
101
|
+
comments: `comments(first:100,after:$commentsCursor){${PAGE_INFO} nodes{author{__typename login} editor{__typename login} body createdAt lastEditedAt isMinimized}}`,
|
|
102
|
+
contexts: `statusCheckRollup{contexts(first:100,after:$contextsCursor){${PAGE_INFO} nodes{... on StatusContext{context state description} ... on CheckRun{name status conclusion}}}}`,
|
|
103
|
+
};
|
|
104
|
+
type Connection = keyof typeof CONNECTIONS;
|
|
105
|
+
type Page = { nodes: unknown[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } };
|
|
106
|
+
|
|
107
|
+
export function record(value: unknown): Record<string, unknown> {
|
|
108
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
109
|
+
throw new Error("malformed API object");
|
|
110
|
+
}
|
|
111
|
+
return value as Record<string, unknown>;
|
|
112
|
+
}
|
|
113
|
+
export function text(value: unknown): value is string {
|
|
114
|
+
return typeof value === "string";
|
|
115
|
+
}
|
|
116
|
+
export function date(value: unknown): boolean {
|
|
117
|
+
return text(value) && Number.isFinite(Date.parse(value));
|
|
118
|
+
}
|
|
119
|
+
export function author(value: unknown): boolean {
|
|
120
|
+
if (value === null) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
const a = record(value);
|
|
124
|
+
return (
|
|
125
|
+
text(a.__typename) &&
|
|
126
|
+
["Bot", "EnterpriseUserAccount", "Mannequin", "Organization", "User"].includes(a.__typename) &&
|
|
127
|
+
text(a.login)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
function validNode(key: Connection, value: unknown): boolean {
|
|
131
|
+
const n = record(value);
|
|
132
|
+
switch (key) {
|
|
133
|
+
case "reviewThreads": {
|
|
134
|
+
const comments = record(n.comments).nodes;
|
|
135
|
+
return (
|
|
136
|
+
typeof n.isResolved === "boolean" &&
|
|
137
|
+
Array.isArray(comments) &&
|
|
138
|
+
comments.every((c: unknown) => text(record(c).url))
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
case "reviews":
|
|
142
|
+
return (
|
|
143
|
+
author(n.author) &&
|
|
144
|
+
text(n.body) &&
|
|
145
|
+
text(n.url) &&
|
|
146
|
+
(n.submittedAt === null || date(n.submittedAt)) &&
|
|
147
|
+
(n.lastEditedAt === null || date(n.lastEditedAt))
|
|
148
|
+
);
|
|
149
|
+
case "comments":
|
|
150
|
+
return (
|
|
151
|
+
author(n.author) &&
|
|
152
|
+
author(n.editor) &&
|
|
153
|
+
text(n.body) &&
|
|
154
|
+
date(n.createdAt) &&
|
|
155
|
+
(n.lastEditedAt === null || date(n.lastEditedAt)) &&
|
|
156
|
+
typeof n.isMinimized === "boolean"
|
|
157
|
+
);
|
|
158
|
+
case "contexts":
|
|
159
|
+
return (
|
|
160
|
+
(text(n.context) &&
|
|
161
|
+
text(n.state) &&
|
|
162
|
+
["EXPECTED", "ERROR", "FAILURE", "PENDING", "SUCCESS"].includes(n.state) &&
|
|
163
|
+
(n.description === null || text(n.description))) ||
|
|
164
|
+
(text(n.name) &&
|
|
165
|
+
text(n.status) &&
|
|
166
|
+
["QUEUED", "IN_PROGRESS", "COMPLETED", "WAITING", "PENDING", "REQUESTED"].includes(
|
|
167
|
+
n.status,
|
|
168
|
+
) &&
|
|
169
|
+
(n.conclusion === null ||
|
|
170
|
+
(text(n.conclusion) &&
|
|
171
|
+
[
|
|
172
|
+
"ACTION_REQUIRED",
|
|
173
|
+
"CANCELLED",
|
|
174
|
+
"FAILURE",
|
|
175
|
+
"NEUTRAL",
|
|
176
|
+
"SKIPPED",
|
|
177
|
+
"STALE",
|
|
178
|
+
"STARTUP_FAILURE",
|
|
179
|
+
"SUCCESS",
|
|
180
|
+
"TIMED_OUT",
|
|
181
|
+
].includes(n.conclusion))))
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function page(key: Connection, value: unknown): Page {
|
|
186
|
+
const p = record(value);
|
|
187
|
+
const info = record(p.pageInfo);
|
|
188
|
+
if (
|
|
189
|
+
!Array.isArray(p.nodes) ||
|
|
190
|
+
!p.nodes.every((n: unknown) => validNode(key, n)) ||
|
|
191
|
+
typeof info.hasNextPage !== "boolean" ||
|
|
192
|
+
!(info.endCursor === null || text(info.endCursor))
|
|
193
|
+
) {
|
|
194
|
+
throw new Error(`malformed ${key} page`);
|
|
195
|
+
}
|
|
196
|
+
return p as unknown as Page;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function requestReviewPage(
|
|
200
|
+
owner: string,
|
|
201
|
+
name: string,
|
|
202
|
+
n: number,
|
|
203
|
+
pending: Connection[],
|
|
204
|
+
cursors: Partial<Record<Connection, string>>,
|
|
205
|
+
): Record<string, unknown> {
|
|
206
|
+
const declarations = pending.map((k) => `$${k}Cursor:String`).join(",");
|
|
207
|
+
const fields = pending
|
|
208
|
+
.filter((k) => k !== "contexts")
|
|
209
|
+
.map((k) => CONNECTIONS[k])
|
|
210
|
+
.join("\n");
|
|
211
|
+
const query = `query($owner:String!,$name:String!,$n:Int!${declarations ? "," + declarations : ""}){viewer{__typename login} repository(owner:$owner,name:$name){viewerPermission pullRequest(number:$n){headRefOid ${fields} commits(last:1){nodes{commit{oid tree{entries{name}} ${pending.includes("contexts") ? CONNECTIONS.contexts : ""}}}}}}}`;
|
|
212
|
+
const variables: Record<string, string | number> = { owner, name, n };
|
|
213
|
+
for (const key of pending) {
|
|
214
|
+
if (cursors[key]) {
|
|
215
|
+
variables[`${key}Cursor`] = cursors[key]!;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return githubGraphQL(query, variables);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Shared transport: rejects command failures, partial errors and malformed envelopes. */
|
|
222
|
+
export function githubGraphQL(
|
|
223
|
+
query: string,
|
|
224
|
+
variables: Record<string, string | number>,
|
|
225
|
+
hostname?: string,
|
|
226
|
+
): Record<string, unknown> {
|
|
227
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
228
|
+
if (hostname) {
|
|
229
|
+
args.push("--hostname", hostname);
|
|
230
|
+
}
|
|
231
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
232
|
+
const placeholder =
|
|
233
|
+
(key === "owner" && value === "{owner}") || (key === "name" && value === "{repo}");
|
|
234
|
+
args.push(typeof value === "number" || placeholder ? "-F" : "-f", `${key}=${value}`);
|
|
235
|
+
}
|
|
236
|
+
const run = spawnSync("gh", args, { encoding: "utf8" });
|
|
237
|
+
if (run.status !== 0) {
|
|
238
|
+
throw new Error("gh api graphql failed");
|
|
239
|
+
}
|
|
240
|
+
let decoded: unknown;
|
|
241
|
+
try {
|
|
242
|
+
decoded = JSON.parse(run.stdout);
|
|
243
|
+
} catch {
|
|
244
|
+
throw new Error("invalid GraphQL JSON");
|
|
245
|
+
}
|
|
246
|
+
const response = record(decoded);
|
|
247
|
+
if (
|
|
248
|
+
response.errors !== undefined &&
|
|
249
|
+
(!Array.isArray(response.errors) || response.errors.length > 0)
|
|
250
|
+
) {
|
|
251
|
+
throw new Error("GraphQL returned errors; coverage is incomplete");
|
|
252
|
+
}
|
|
253
|
+
return record(response.data);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Reads complete connections; rejects partial responses and head changes during pagination. */
|
|
257
|
+
function readReviewState(owner: string, name: string, n: number): ReviewState {
|
|
258
|
+
const collected: Record<Connection, unknown[]> = {
|
|
259
|
+
reviewThreads: [],
|
|
260
|
+
reviews: [],
|
|
261
|
+
comments: [],
|
|
262
|
+
contexts: [],
|
|
263
|
+
};
|
|
264
|
+
const cursors: Partial<Record<Connection, string>> = {};
|
|
265
|
+
const seen: Record<Connection, Set<string>> = {
|
|
266
|
+
reviewThreads: new Set(),
|
|
267
|
+
reviews: new Set(),
|
|
268
|
+
comments: new Set(),
|
|
269
|
+
contexts: new Set(),
|
|
270
|
+
};
|
|
271
|
+
let pending = Object.keys(CONNECTIONS) as Connection[];
|
|
272
|
+
let expectedHead: string | undefined;
|
|
273
|
+
let viewer: Author = null;
|
|
274
|
+
let viewerPermission: string | null = null;
|
|
275
|
+
let tree: ReviewState["commits"]["nodes"][number]["commit"]["tree"];
|
|
276
|
+
for (;;) {
|
|
277
|
+
const data = requestReviewPage(owner, name, n, pending, cursors);
|
|
278
|
+
const repo = record(data.repository);
|
|
279
|
+
const pr = record(repo.pullRequest);
|
|
280
|
+
if (
|
|
281
|
+
!text(pr.headRefOid) ||
|
|
282
|
+
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(pr.headRefOid) ||
|
|
283
|
+
!author(data.viewer) ||
|
|
284
|
+
!(repo.viewerPermission === null || text(repo.viewerPermission))
|
|
285
|
+
) {
|
|
286
|
+
throw new Error("malformed PR metadata");
|
|
287
|
+
}
|
|
288
|
+
if (expectedHead && expectedHead !== pr.headRefOid) {
|
|
289
|
+
throw new Error("PR head changed during feedback read; rerun");
|
|
290
|
+
}
|
|
291
|
+
expectedHead = pr.headRefOid;
|
|
292
|
+
viewer = data.viewer as Author;
|
|
293
|
+
viewerPermission = repo.viewerPermission as string | null;
|
|
294
|
+
const commits = record(pr.commits).nodes;
|
|
295
|
+
if (!Array.isArray(commits) || commits.length !== 1) {
|
|
296
|
+
throw new Error("missing head commit");
|
|
297
|
+
}
|
|
298
|
+
const commit = record(record(commits[0]).commit);
|
|
299
|
+
const entries = record(commit.tree).entries;
|
|
300
|
+
if (
|
|
301
|
+
commit.oid !== expectedHead ||
|
|
302
|
+
!Array.isArray(entries) ||
|
|
303
|
+
!entries.every((e: unknown) => text(record(e).name))
|
|
304
|
+
) {
|
|
305
|
+
throw new Error("malformed or inconsistent head commit");
|
|
306
|
+
}
|
|
307
|
+
tree = { entries: entries as { name: string }[] };
|
|
308
|
+
if (pending.length === 0) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
const next: Connection[] = [];
|
|
312
|
+
for (const key of pending) {
|
|
313
|
+
const value =
|
|
314
|
+
key === "contexts"
|
|
315
|
+
? commit.statusCheckRollup === null
|
|
316
|
+
? null
|
|
317
|
+
: record(commit.statusCheckRollup).contexts
|
|
318
|
+
: pr[key];
|
|
319
|
+
if (key === "contexts" && value === null) {
|
|
320
|
+
if (cursors.contexts) {
|
|
321
|
+
throw new Error("status rollup disappeared during pagination");
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const p = page(key, value);
|
|
326
|
+
collected[key].push(...p.nodes);
|
|
327
|
+
if (p.pageInfo.hasNextPage) {
|
|
328
|
+
const cursor = p.pageInfo.endCursor;
|
|
329
|
+
if (!cursor || seen[key].has(cursor)) {
|
|
330
|
+
throw new Error(`non-advancing ${key} cursor`);
|
|
331
|
+
}
|
|
332
|
+
seen[key].add(cursor);
|
|
333
|
+
cursors[key] = cursor;
|
|
334
|
+
next.push(key);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
pending = next;
|
|
338
|
+
}
|
|
339
|
+
const commit = {
|
|
340
|
+
tree: tree!,
|
|
341
|
+
statusCheckRollup: { contexts: { nodes: collected.contexts as Context[] } },
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
headRefOid: expectedHead!,
|
|
345
|
+
viewer,
|
|
346
|
+
viewerPermission,
|
|
347
|
+
reviewThreads: { nodes: collected.reviewThreads as ReviewState["reviewThreads"]["nodes"] },
|
|
348
|
+
reviews: { nodes: collected.reviews as ReviewState["reviews"]["nodes"] },
|
|
349
|
+
comments: { nodes: collected.comments as ReviewState["comments"]["nodes"] },
|
|
350
|
+
commits: { nodes: [{ commit }] },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const isBot = (author: Author) => author?.__typename === "Bot";
|
|
355
|
+
|
|
356
|
+
/** Every reason the pull request is not ready to land; empty when it is. */
|
|
357
|
+
export function openFeedback(pr: ReviewState): string[] {
|
|
358
|
+
const open: string[] = [];
|
|
359
|
+
for (const thread of pr.reviewThreads.nodes) {
|
|
360
|
+
if (!thread.isResolved) {
|
|
361
|
+
open.push(`unresolved thread: ${thread.comments.nodes[0]?.url ?? "(no comments)"}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const head = pr.commits.nodes[0]?.commit;
|
|
365
|
+
const contexts = head?.statusCheckRollup?.contexts.nodes ?? [];
|
|
366
|
+
const hasConfig = head?.tree.entries.some((e) => e.name === REVIEW_BOT_CONFIG) ?? false;
|
|
367
|
+
if (hasConfig && !contexts.some((ctx) => REVIEW_BOTS.test(ctx.context ?? ctx.name ?? ""))) {
|
|
368
|
+
open.push(`${NO_CHECK}: ${FOLLOW_UP}`);
|
|
369
|
+
}
|
|
370
|
+
for (const ctx of contexts) {
|
|
371
|
+
const name = ctx.context ?? ctx.name ?? "";
|
|
372
|
+
const running =
|
|
373
|
+
ctx.state === "PENDING" || (ctx.status !== undefined && ctx.status !== "COMPLETED");
|
|
374
|
+
if (!REVIEW_BOTS.test(name)) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (running) {
|
|
378
|
+
open.push(`${RUNNING}${name}`);
|
|
379
|
+
} else if (ctx.name !== undefined && ctx.conclusion !== "SUCCESS") {
|
|
380
|
+
open.push(
|
|
381
|
+
`${name} review concluded ${ctx.conclusion ?? "(missing conclusion)"}: ${FOLLOW_UP}`,
|
|
382
|
+
);
|
|
383
|
+
} else if (
|
|
384
|
+
ctx.context !== undefined &&
|
|
385
|
+
(ctx.state !== "SUCCESS" || !REVIEWED.test(ctx.description ?? ""))
|
|
386
|
+
) {
|
|
387
|
+
open.push(
|
|
388
|
+
`${name} did not review the head commit ("${ctx.description ?? ""}"): ${FOLLOW_UP}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const review of pr.reviews.nodes) {
|
|
393
|
+
if (isBot(review.author) && BODY_FINDINGS.test(review.body)) {
|
|
394
|
+
const reviewedAt = Math.max(
|
|
395
|
+
Date.parse(review.submittedAt ?? ""),
|
|
396
|
+
Date.parse(review.lastEditedAt ?? review.submittedAt ?? ""),
|
|
397
|
+
);
|
|
398
|
+
const authorized =
|
|
399
|
+
pr.viewer?.__typename === "User" &&
|
|
400
|
+
["WRITE", "MAINTAIN", "ADMIN"].includes(pr.viewerPermission ?? "");
|
|
401
|
+
const answered =
|
|
402
|
+
authorized &&
|
|
403
|
+
pr.comments.nodes.some((comment) => {
|
|
404
|
+
if (
|
|
405
|
+
comment.author?.__typename !== "User" ||
|
|
406
|
+
comment.author.login !== pr.viewer?.login ||
|
|
407
|
+
comment.isMinimized !== false ||
|
|
408
|
+
Date.parse(comment.createdAt) <= reviewedAt ||
|
|
409
|
+
!Number.isFinite(reviewedAt)
|
|
410
|
+
) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
comment.lastEditedAt &&
|
|
415
|
+
(comment.editor?.__typename !== "User" || comment.editor.login !== pr.viewer.login)
|
|
416
|
+
) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
const ack =
|
|
420
|
+
/^Review: (\S+)\r?\nHead: ((?:[a-f0-9]{40}|[a-f0-9]{64}))\r?\nReason: ([\s\S]+)$/.exec(
|
|
421
|
+
comment.body.trim(),
|
|
422
|
+
);
|
|
423
|
+
return ack?.[1] === review.url && ack[2] === pr.headRefOid && Boolean(ack[3]?.trim());
|
|
424
|
+
});
|
|
425
|
+
if (!answered) {
|
|
426
|
+
open.push(`unanswered findings in a review body: ${review.url}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return open;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const reviewRunning = (open: string[]) => open.some((line) => line.startsWith(RUNNING));
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Re-reads the pull request while a review is still running, or briefly while its check has not
|
|
437
|
+
* appeared, until the review finishes or time runs out.
|
|
438
|
+
*/
|
|
439
|
+
export async function waitForReview(
|
|
440
|
+
read: () => ReviewState,
|
|
441
|
+
{
|
|
442
|
+
timeoutMs,
|
|
443
|
+
pollMs = POLL_MS,
|
|
444
|
+
sleep = Bun.sleep,
|
|
445
|
+
now = Date.now,
|
|
446
|
+
}: {
|
|
447
|
+
timeoutMs: number;
|
|
448
|
+
pollMs?: number;
|
|
449
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
450
|
+
now?: () => number;
|
|
451
|
+
},
|
|
452
|
+
): Promise<string[]> {
|
|
453
|
+
const start = now();
|
|
454
|
+
const pending = (open: string[]) =>
|
|
455
|
+
reviewRunning(open) ||
|
|
456
|
+
(now() - start < NO_CHECK_GRACE_MS && open.some((line) => line.startsWith(NO_CHECK)));
|
|
457
|
+
let open = openFeedback(read());
|
|
458
|
+
while (pending(open) && now() - start < timeoutMs) {
|
|
459
|
+
await sleep(pollMs);
|
|
460
|
+
open = openFeedback(read());
|
|
461
|
+
}
|
|
462
|
+
return open;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (import.meta.main) {
|
|
466
|
+
const { values, positionals } = parseArgs({
|
|
467
|
+
allowPositionals: true,
|
|
468
|
+
options: {
|
|
469
|
+
repo: { type: "string" },
|
|
470
|
+
wait: { type: "boolean" },
|
|
471
|
+
timeout: { type: "string", default: "30" },
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
if (values.repo && !/^[^/\s]+\/[^/\s]+$/.test(values.repo)) {
|
|
475
|
+
console.error("--repo must be owner/name");
|
|
476
|
+
process.exit(2);
|
|
477
|
+
}
|
|
478
|
+
const pr = Number(positionals[0]);
|
|
479
|
+
const waitMinutes = Number(values.timeout);
|
|
480
|
+
if (!Number.isInteger(pr) || pr < 1 || !(waitMinutes > 0)) {
|
|
481
|
+
console.error(
|
|
482
|
+
"usage: check-pr-review.ts <pr> [--repo <owner>/<name>] [--wait [--timeout <minutes>]]",
|
|
483
|
+
);
|
|
484
|
+
process.exit(2);
|
|
485
|
+
}
|
|
486
|
+
const [owner, name] = values.repo ? values.repo.split("/") : ["{owner}", "{repo}"];
|
|
487
|
+
let open: string[];
|
|
488
|
+
try {
|
|
489
|
+
const read = () => readReviewState(owner!, name!, pr);
|
|
490
|
+
open = values.wait
|
|
491
|
+
? await waitForReview(read, { timeoutMs: waitMinutes * 60_000 })
|
|
492
|
+
: openFeedback(read());
|
|
493
|
+
} catch (error) {
|
|
494
|
+
console.error(
|
|
495
|
+
`cannot verify PR ${pr} review feedback: ${error instanceof Error ? error.message : "API read failed"}`,
|
|
496
|
+
);
|
|
497
|
+
process.exit(2);
|
|
498
|
+
}
|
|
499
|
+
if (open.length > 0) {
|
|
500
|
+
console.error(`PR ${pr} has open review feedback; fix or answer each, then resolve threads:`);
|
|
501
|
+
for (const line of open) {
|
|
502
|
+
console.error(` ${line}`);
|
|
503
|
+
}
|
|
504
|
+
if (reviewRunning(open) && !values.wait) {
|
|
505
|
+
console.error(" rerun with --wait to poll until the review finishes");
|
|
506
|
+
}
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
console.log(`review feedback: none open on PR ${pr}`);
|
|
510
|
+
}
|