@davidvornholt/standards 0.10.2 → 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.
Files changed (43) hide show
  1. package/README.md +6 -1
  2. package/package.json +37 -1
  3. package/src/cli.ts +68 -11
  4. package/src/github-commands.ts +2 -0
  5. package/src/github-label-identity.ts +9 -0
  6. package/src/github-labels.ts +123 -0
  7. package/src/github-live-drift.ts +8 -0
  8. package/src/github-paginate.ts +37 -0
  9. package/src/github-settings-merge.ts +167 -0
  10. package/src/github-settings-parse.ts +82 -7
  11. package/src/github-settings.ts +5 -123
  12. package/src/poller-approval.ts +80 -0
  13. package/src/poller-claim.ts +154 -0
  14. package/src/poller-codex.ts +93 -0
  15. package/src/poller-commands.ts +78 -0
  16. package/src/poller-config.ts +188 -0
  17. package/src/poller-fix-outcome.ts +36 -0
  18. package/src/poller-fix-output.ts +135 -0
  19. package/src/poller-fix-publication.ts +187 -0
  20. package/src/poller-fix-run.ts +175 -0
  21. package/src/poller-fix-validation.ts +64 -0
  22. package/src/poller-github-publication.ts +23 -0
  23. package/src/poller-github-pulls.ts +189 -0
  24. package/src/poller-github-write.ts +87 -0
  25. package/src/poller-github.ts +190 -0
  26. package/src/poller-job-shared.ts +132 -0
  27. package/src/poller-outcome.ts +136 -0
  28. package/src/poller-output-integrity.ts +107 -0
  29. package/src/poller-prompts.ts +70 -0
  30. package/src/poller-protected-paths.ts +51 -0
  31. package/src/poller-protocol.ts +137 -0
  32. package/src/poller-review-artifacts.ts +119 -0
  33. package/src/poller-review-execution.ts +108 -0
  34. package/src/poller-review-output.ts +189 -0
  35. package/src/poller-review-publication.ts +153 -0
  36. package/src/poller-review-run.ts +160 -0
  37. package/src/poller-review-state.ts +126 -0
  38. package/src/poller-review-validation.ts +66 -0
  39. package/src/poller-schedule.ts +121 -0
  40. package/src/poller-tick.ts +144 -0
  41. package/src/poller-trust.ts +96 -0
  42. package/src/poller-units.ts +82 -0
  43. package/src/poller-workspace.ts +199 -0
@@ -0,0 +1,175 @@
1
+ // One fix job: a maintainer-approved issue becomes a verified draft PR, a
2
+ // precise question, or an explicit failure — never silence. Every transition
3
+ // is written back to GitHub so the next tick (or a human) resumes from there.
4
+
5
+ import { join } from 'node:path';
6
+ import { issueRevision } from './poller-approval';
7
+ import { acquireClaim } from './poller-claim';
8
+ import { runCodex } from './poller-codex';
9
+ import { handleNonFixedOutcome } from './poller-fix-outcome';
10
+ import { readSealedFixOutput } from './poller-fix-output';
11
+ import {
12
+ type FixPublication,
13
+ finishFixedJob,
14
+ publishFixedOutput,
15
+ validateFixClaim,
16
+ } from './poller-fix-publication';
17
+ import { getIssue, type IssueItem } from './poller-github';
18
+ import { addLabels } from './poller-github-write';
19
+ import {
20
+ failJob,
21
+ type JobDeps,
22
+ type JobLabels,
23
+ type JobResult,
24
+ jobPreamble,
25
+ } from './poller-job-shared';
26
+ import { readFixOutcome } from './poller-outcome';
27
+ import { fixPrompt } from './poller-prompts';
28
+ import {
29
+ APPROVED_FOR_FIX,
30
+ branchNameForIssue,
31
+ FIX_FAILED,
32
+ FIX_IN_PROGRESS,
33
+ } from './poller-protocol';
34
+ import {
35
+ createWorktree,
36
+ ensureCacheClone,
37
+ localBranchExists,
38
+ type Workspace,
39
+ } from './poller-workspace';
40
+
41
+ const FIX_LABELS: JobLabels = {
42
+ approved: APPROVED_FOR_FIX,
43
+ inProgress: FIX_IN_PROGRESS,
44
+ failed: FIX_FAILED,
45
+ };
46
+
47
+ type FixJob = FixPublication & {
48
+ readonly workspace: Workspace;
49
+ };
50
+
51
+ const hasInvalidLocalOutput = (
52
+ sealed: ReturnType<typeof readSealedFixOutput>,
53
+ cloneDir: string,
54
+ branch: string,
55
+ ): boolean => sealed === null && localBranchExists(cloneDir, branch);
56
+
57
+ export const runFixJob = async (
58
+ deps: JobDeps,
59
+ issue: IssueItem,
60
+ defaultBranch: string,
61
+ allowCodex = true,
62
+ ): Promise<JobResult> => {
63
+ const { config, token, repo } = deps;
64
+ const currentIssue = await getIssue(token, repo, issue.number);
65
+ const preamble = await jobPreamble(
66
+ deps,
67
+ currentIssue,
68
+ FIX_LABELS,
69
+ issueRevision(currentIssue),
70
+ );
71
+ if (preamble.kind === 'rejected') {
72
+ return { lines: [`#${issue.number}: approval rejected`], ranCodex: false };
73
+ }
74
+ if (preamble.kind === 'waiting') {
75
+ return {
76
+ lines: [`#${issue.number}: waiting on an answer`],
77
+ ranCodex: false,
78
+ };
79
+ }
80
+ const cacheClone = ensureCacheClone(config.cacheDir, repo, token);
81
+ const branch = branchNameForIssue(issue.number, preamble.approval.id);
82
+ const sealed = readSealedFixOutput(cacheClone, branch);
83
+ if (hasInvalidLocalOutput(sealed, cacheClone, branch)) {
84
+ throw new Error(
85
+ `refusing to overwrite ${branch}: it is not valid sealed output for this approval`,
86
+ );
87
+ }
88
+ if (sealed === null && !allowCodex) {
89
+ return {
90
+ lines: [`#${issue.number}: waiting for run capacity`],
91
+ ranCodex: false,
92
+ };
93
+ }
94
+ await addLabels(token, repo, issue.number, [FIX_IN_PROGRESS]);
95
+ const claim = await acquireClaim(
96
+ { token, repo, issueNumber: issue.number },
97
+ preamble.approval,
98
+ FIX_IN_PROGRESS,
99
+ );
100
+ if (claim === null) {
101
+ return {
102
+ lines: [`#${issue.number}: another poller owns the claim`],
103
+ ranCodex: false,
104
+ };
105
+ }
106
+ const resumableJob = {
107
+ deps,
108
+ issue: currentIssue,
109
+ defaultBranch,
110
+ claim,
111
+ branch,
112
+ cloneDir: cacheClone,
113
+ };
114
+ if (sealed !== null) {
115
+ if (
116
+ sealed.issueNumber !== issue.number ||
117
+ sealed.approvalId !== claim.approval.id
118
+ ) {
119
+ throw new Error(`sealed output on ${branch} has invalid ownership`);
120
+ }
121
+ return {
122
+ lines: [await publishFixedOutput(resumableJob, FIX_LABELS, sealed, null)],
123
+ ranCodex: false,
124
+ };
125
+ }
126
+ const workspace = createWorktree(
127
+ cacheClone,
128
+ defaultBranch,
129
+ branch,
130
+ join(
131
+ config.cacheDir,
132
+ 'work',
133
+ `${repo.replace('/', '--')}-issue-${issue.number}`,
134
+ ),
135
+ );
136
+ const job: FixJob = {
137
+ ...resumableJob,
138
+ issue: currentIssue,
139
+ workspace,
140
+ };
141
+ try {
142
+ const run = runCodex(
143
+ workspace.dir,
144
+ fixPrompt({
145
+ repo,
146
+ issueNumber: issue.number,
147
+ title: currentIssue.title,
148
+ body: currentIssue.body,
149
+ answers: preamble.answers,
150
+ }),
151
+ config,
152
+ );
153
+ await validateFixClaim(job);
154
+ const outcome = run.succeeded ? await readFixOutcome(workspace.dir) : null;
155
+ if (outcome === null) {
156
+ await failJob(
157
+ deps,
158
+ FIX_LABELS,
159
+ issue.number,
160
+ run.failure ?? 'run wrote no valid outcome file',
161
+ );
162
+ return {
163
+ lines: [`#${issue.number}: failed (no valid outcome)`],
164
+ ranCodex: true,
165
+ };
166
+ }
167
+ const nonFixed = await handleNonFixedOutcome(job, FIX_LABELS, outcome);
168
+ return {
169
+ lines: [nonFixed ?? (await finishFixedJob(job, FIX_LABELS, outcome))],
170
+ ranCodex: true,
171
+ };
172
+ } finally {
173
+ workspace.cleanup();
174
+ }
175
+ };
@@ -0,0 +1,64 @@
1
+ import { join } from 'node:path';
2
+ import type { SealedFixOutput } from './poller-fix-output';
3
+ import type { JobDeps } from './poller-job-shared';
4
+ import {
5
+ changedPathsBetween,
6
+ commitCountBetween,
7
+ createValidationWorktree,
8
+ isAncestor,
9
+ } from './poller-output-integrity';
10
+ import {
11
+ changedWorkspaceQualityManifests,
12
+ lockedPathsOf,
13
+ } from './poller-protected-paths';
14
+ import { forbiddenDiffPaths } from './poller-protocol';
15
+
16
+ export const validateSealedFixOutput = async (
17
+ job: {
18
+ readonly deps: JobDeps;
19
+ readonly issueNumber: number;
20
+ readonly defaultBranch: string;
21
+ readonly approvalId: string;
22
+ readonly cloneDir: string;
23
+ },
24
+ output: SealedFixOutput,
25
+ ): Promise<void> => {
26
+ if (
27
+ output.repo !== job.deps.repo ||
28
+ output.issueNumber !== job.issueNumber ||
29
+ output.approvalId !== job.approvalId ||
30
+ !isAncestor(job.cloneDir, output.baseSha, job.defaultBranch) ||
31
+ !isAncestor(job.cloneDir, output.baseSha, output.generatedHead) ||
32
+ commitCountBetween(job.cloneDir, output.baseSha, output.generatedHead) !==
33
+ output.commits
34
+ ) {
35
+ throw new Error('sealed fix output does not match this job');
36
+ }
37
+ const paths = changedPathsBetween(
38
+ job.cloneDir,
39
+ output.baseSha,
40
+ output.generatedHead,
41
+ );
42
+ const workspace = createValidationWorktree(
43
+ job.cloneDir,
44
+ output.generatedHead,
45
+ join(
46
+ job.deps.config.cacheDir,
47
+ 'work',
48
+ `${job.deps.repo.replace('/', '--')}-issue-${job.issueNumber}-validate`,
49
+ ),
50
+ );
51
+ try {
52
+ const forbidden = [
53
+ ...forbiddenDiffPaths(paths, await lockedPathsOf(workspace.dir)),
54
+ ...changedWorkspaceQualityManifests(workspace.dir, output.baseSha, paths),
55
+ ];
56
+ if (forbidden.length > 0) {
57
+ throw new Error(
58
+ `sealed fix output modified protected paths:\n${forbidden.join('\n')}`,
59
+ );
60
+ }
61
+ } finally {
62
+ workspace.cleanup();
63
+ }
64
+ };
@@ -0,0 +1,23 @@
1
+ import { listAllPages } from './github-paginate';
2
+ import { isRecord } from './github-settings-parse';
3
+
4
+ export const repositoryIssueMarkerAuthors = async (
5
+ token: string | null,
6
+ repo: string,
7
+ marker: string,
8
+ ): Promise<ReadonlyArray<string>> => {
9
+ const items = await listAllPages(
10
+ token,
11
+ `/repos/${repo}/issues?state=all`,
12
+ `list ${repo} issues for publication marker`,
13
+ );
14
+ return items.flatMap((item) =>
15
+ isRecord(item) &&
16
+ typeof item.body === 'string' &&
17
+ item.body.includes(marker) &&
18
+ isRecord(item.user) &&
19
+ typeof item.user.login === 'string'
20
+ ? [item.user.login]
21
+ : [],
22
+ );
23
+ };
@@ -0,0 +1,189 @@
1
+ // Pull-request operations for the poller: creating the draft PR that carries
2
+ // a fix, reading PR state for review jobs, posting the review report, and
3
+ // flipping draft to ready (GraphQL: REST cannot change the draft flag).
4
+
5
+ import { apiError, HTTP_CREATED, HTTP_OK, request } from './github-api';
6
+ import { listAllPages } from './github-paginate';
7
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
8
+
9
+ export type PullRequest = {
10
+ readonly number: number;
11
+ readonly title: string;
12
+ readonly body: string;
13
+ readonly headRef: string;
14
+ readonly headSha: string;
15
+ readonly headRepo: string;
16
+ readonly baseRef: string;
17
+ readonly baseSha: string;
18
+ readonly nodeId: string;
19
+ readonly draft: boolean;
20
+ };
21
+
22
+ export const findOpenPullRequestForHead = async (
23
+ token: string | null,
24
+ repo: string,
25
+ head: string,
26
+ ): Promise<number | null> => {
27
+ const [owner = ''] = repo.split('/');
28
+ const response = await request(
29
+ token,
30
+ 'GET',
31
+ `/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}`,
32
+ );
33
+ if (response.status !== HTTP_OK || !Array.isArray(response.body)) {
34
+ return null;
35
+ }
36
+ const [first] = response.body;
37
+ return isRecord(first) && typeof first.number === 'number'
38
+ ? first.number
39
+ : null;
40
+ };
41
+
42
+ export const createDraftPullRequest = async (
43
+ token: string | null,
44
+ repo: string,
45
+ options: {
46
+ readonly title: string;
47
+ readonly body: string;
48
+ readonly head: string;
49
+ readonly base: string;
50
+ },
51
+ ): Promise<number> => {
52
+ const response = await request(token, 'POST', `/repos/${repo}/pulls`, {
53
+ title: options.title,
54
+ body: options.body,
55
+ head: options.head,
56
+ base: options.base,
57
+ draft: true,
58
+ });
59
+ if (
60
+ response.status === HTTP_CREATED &&
61
+ isRecord(response.body) &&
62
+ typeof response.body.number === 'number'
63
+ ) {
64
+ return response.body.number;
65
+ }
66
+ throw new Error(apiError(`create draft PR in ${repo}`, response));
67
+ };
68
+
69
+ export const updatePullRequest = async (
70
+ token: string | null,
71
+ repo: string,
72
+ prNumber: number,
73
+ options: { readonly title: string; readonly body: string },
74
+ ): Promise<void> => {
75
+ const response = await request(
76
+ token,
77
+ 'PATCH',
78
+ `/repos/${repo}/pulls/${prNumber}`,
79
+ options,
80
+ );
81
+ if (response.status !== HTTP_OK) {
82
+ throw new Error(apiError(`update ${repo}#${prNumber}`, response));
83
+ }
84
+ };
85
+
86
+ export const getPullRequest = async (
87
+ token: string | null,
88
+ repo: string,
89
+ prNumber: number,
90
+ ): Promise<PullRequest> => {
91
+ const response = await request(
92
+ token,
93
+ 'GET',
94
+ `/repos/${repo}/pulls/${prNumber}`,
95
+ );
96
+ const { body } = response;
97
+ if (
98
+ response.status !== HTTP_OK ||
99
+ !isRecord(body) ||
100
+ !isRecord(body.head) ||
101
+ !isRecord(body.base) ||
102
+ !isNonEmptyString(body.node_id)
103
+ ) {
104
+ throw new Error(apiError(`read ${repo}#${prNumber}`, response));
105
+ }
106
+ const headRepo = isRecord(body.head.repo)
107
+ ? body.head.repo
108
+ : ({} as Record<string, unknown>);
109
+ return {
110
+ number: prNumber,
111
+ title: isNonEmptyString(body.title) ? body.title : '',
112
+ body: isNonEmptyString(body.body) ? body.body : '',
113
+ headRef: isNonEmptyString(body.head.ref) ? body.head.ref : '',
114
+ headSha: isNonEmptyString(body.head.sha) ? body.head.sha : '',
115
+ headRepo: isNonEmptyString(headRepo.full_name) ? headRepo.full_name : '',
116
+ baseRef: isNonEmptyString(body.base.ref) ? body.base.ref : '',
117
+ baseSha: isNonEmptyString(body.base.sha) ? body.base.sha : '',
118
+ nodeId: body.node_id,
119
+ draft: body.draft === true,
120
+ };
121
+ };
122
+
123
+ export const createPullRequestReview = async (options: {
124
+ readonly token: string | null;
125
+ readonly repo: string;
126
+ readonly prNumber: number;
127
+ readonly body: string;
128
+ readonly commitId: string;
129
+ }): Promise<void> => {
130
+ const { token, repo, prNumber, body, commitId } = options;
131
+ const response = await request(
132
+ token,
133
+ 'POST',
134
+ `/repos/${repo}/pulls/${prNumber}/reviews`,
135
+ {
136
+ event: 'COMMENT',
137
+ body,
138
+ // biome-ignore lint/style/useNamingConvention: GitHub's REST field is snake_case.
139
+ commit_id: commitId,
140
+ },
141
+ );
142
+ if (response.status !== HTTP_OK) {
143
+ throw new Error(apiError(`post review on ${repo}#${prNumber}`, response));
144
+ }
145
+ };
146
+
147
+ export const pullRequestReviewMarkerAuthors = async (options: {
148
+ readonly token: string | null;
149
+ readonly repo: string;
150
+ readonly prNumber: number;
151
+ readonly marker: string;
152
+ readonly commitId: string;
153
+ }): Promise<ReadonlyArray<string>> => {
154
+ const { token, repo, prNumber, marker, commitId } = options;
155
+ const reviews = await listAllPages(
156
+ token,
157
+ `/repos/${repo}/pulls/${prNumber}/reviews`,
158
+ `list reviews on ${repo}#${prNumber}`,
159
+ );
160
+ return reviews.flatMap((review) =>
161
+ isRecord(review) &&
162
+ review.commit_id === commitId &&
163
+ typeof review.body === 'string' &&
164
+ review.body.includes(marker) &&
165
+ isRecord(review.user) &&
166
+ typeof review.user.login === 'string'
167
+ ? [review.user.login]
168
+ : [],
169
+ );
170
+ };
171
+
172
+ export const markPullRequestReady = async (
173
+ token: string | null,
174
+ pullRequestNodeId: string,
175
+ ): Promise<void> => {
176
+ const response = await request(token, 'POST', '/graphql', {
177
+ query:
178
+ // biome-ignore lint/security/noSecrets: a GraphQL mutation string, not a credential.
179
+ 'mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { isDraft } } }',
180
+ variables: { id: pullRequestNodeId },
181
+ });
182
+ const succeeded =
183
+ response.status === HTTP_OK &&
184
+ isRecord(response.body) &&
185
+ response.body.errors === undefined;
186
+ if (!succeeded) {
187
+ throw new Error(apiError('mark PR ready for review', response));
188
+ }
189
+ };
@@ -0,0 +1,87 @@
1
+ // Write-side label and comment operations for the poller. Kept apart from the
2
+ // read helpers so each module stays a single reviewable concern.
3
+
4
+ import { apiError, HTTP_CREATED, HTTP_OK, request } from './github-api';
5
+ import { isRecord } from './github-settings-parse';
6
+
7
+ const HTTP_NOT_FOUND = 404;
8
+
9
+ export const addLabels = async (
10
+ token: string | null,
11
+ repo: string,
12
+ issueNumber: number,
13
+ labels: ReadonlyArray<string>,
14
+ ): Promise<void> => {
15
+ const response = await request(
16
+ token,
17
+ 'POST',
18
+ `/repos/${repo}/issues/${issueNumber}/labels`,
19
+ { labels: [...labels] },
20
+ );
21
+ if (response.status !== HTTP_OK) {
22
+ throw new Error(
23
+ apiError(`add ${labels.join(', ')} to ${repo}#${issueNumber}`, response),
24
+ );
25
+ }
26
+ };
27
+
28
+ // Removing an already-absent label is success, not failure: ticks are
29
+ // re-runnable and a crashed predecessor may have completed this step.
30
+ export const removeLabel = async (
31
+ token: string | null,
32
+ repo: string,
33
+ issueNumber: number,
34
+ label: string,
35
+ ): Promise<void> => {
36
+ const response = await request(
37
+ token,
38
+ 'DELETE',
39
+ `/repos/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`,
40
+ );
41
+ if (response.status !== HTTP_OK && response.status !== HTTP_NOT_FOUND) {
42
+ throw new Error(
43
+ apiError(`remove ${label} from ${repo}#${issueNumber}`, response),
44
+ );
45
+ }
46
+ };
47
+
48
+ export const createIssue = async (
49
+ token: string | null,
50
+ repo: string,
51
+ options: {
52
+ readonly title: string;
53
+ readonly body: string;
54
+ readonly labels: ReadonlyArray<string>;
55
+ },
56
+ ): Promise<void> => {
57
+ const response = await request(token, 'POST', `/repos/${repo}/issues`, {
58
+ title: options.title,
59
+ body: options.body,
60
+ labels: [...options.labels],
61
+ });
62
+ if (response.status !== HTTP_CREATED) {
63
+ throw new Error(apiError(`create issue in ${repo}`, response));
64
+ }
65
+ };
66
+
67
+ export const createComment = async (
68
+ token: string | null,
69
+ repo: string,
70
+ issueNumber: number,
71
+ body: string,
72
+ ): Promise<number> => {
73
+ const response = await request(
74
+ token,
75
+ 'POST',
76
+ `/repos/${repo}/issues/${issueNumber}/comments`,
77
+ { body },
78
+ );
79
+ if (
80
+ response.status !== HTTP_CREATED ||
81
+ !isRecord(response.body) ||
82
+ typeof response.body.id !== 'number'
83
+ ) {
84
+ throw new Error(apiError(`comment on ${repo}#${issueNumber}`, response));
85
+ }
86
+ return response.body.id;
87
+ };