@davidvornholt/standards 0.10.1 → 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 +85 -13
  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,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
+ };
@@ -0,0 +1,190 @@
1
+ // Read-side GitHub helpers for the poller. Every function either returns a
2
+ // narrow parsed shape or throws with the failing endpoint in the message; the
3
+ // tick converts per-job throws into reported problems. Lists are always read
4
+ // exhaustively (see github-paginate.ts) — the trust anchor is "the latest
5
+ // label event", which a truncated page would silently falsify.
6
+
7
+ import { apiError, HTTP_OK, request } from './github-api';
8
+ import { labelIdentity } from './github-label-identity';
9
+ import { listAllPages } from './github-paginate';
10
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
11
+
12
+ export type IssueItem = {
13
+ readonly number: number;
14
+ readonly title: string;
15
+ readonly body: string;
16
+ readonly isPullRequest: boolean;
17
+ readonly labels: ReadonlyArray<string>;
18
+ readonly authorLogin: string;
19
+ };
20
+
21
+ export type IssueComment = {
22
+ readonly id: number;
23
+ readonly body: string;
24
+ readonly authorLogin: string;
25
+ readonly createdAt: string;
26
+ };
27
+
28
+ export type LabelEvent = {
29
+ readonly id: number;
30
+ readonly actorLogin: string;
31
+ readonly createdAt: string;
32
+ };
33
+
34
+ const asString = (value: unknown): string =>
35
+ typeof value === 'string' ? value : '';
36
+
37
+ const labelNames = (raw: unknown): ReadonlyArray<string> =>
38
+ Array.isArray(raw)
39
+ ? raw
40
+ .map((label) => (isRecord(label) ? asString(label.name) : ''))
41
+ .filter((name) => name.length > 0)
42
+ : [];
43
+
44
+ export const parseIssue = (raw: unknown): IssueItem | null => {
45
+ if (!(isRecord(raw) && typeof raw.number === 'number')) {
46
+ return null;
47
+ }
48
+ return {
49
+ number: raw.number,
50
+ title: asString(raw.title),
51
+ body: asString(raw.body),
52
+ isPullRequest: isRecord(raw.pull_request),
53
+ labels: labelNames(raw.labels),
54
+ authorLogin: isRecord(raw.user) ? asString(raw.user.login) : '',
55
+ };
56
+ };
57
+
58
+ export const getIssue = async (
59
+ token: string | null,
60
+ repo: string,
61
+ issueNumber: number,
62
+ ): Promise<IssueItem> => {
63
+ const response = await request(
64
+ token,
65
+ 'GET',
66
+ `/repos/${repo}/issues/${issueNumber}`,
67
+ );
68
+ const issue = response.status === HTTP_OK ? parseIssue(response.body) : null;
69
+ if (issue === null) {
70
+ throw new Error(apiError(`read ${repo}#${issueNumber}`, response));
71
+ }
72
+ return issue;
73
+ };
74
+
75
+ export const listOpenIssuesWithLabel = async (
76
+ token: string | null,
77
+ repo: string,
78
+ label: string,
79
+ ): Promise<ReadonlyArray<IssueItem>> => {
80
+ const items = await listAllPages(
81
+ token,
82
+ `/repos/${repo}/issues?state=open&labels=${encodeURIComponent(label)}`,
83
+ `list ${repo} issues labeled ${label}`,
84
+ );
85
+ return items
86
+ .map(parseIssue)
87
+ .filter((issue): issue is IssueItem => issue !== null);
88
+ };
89
+
90
+ export const listIssueComments = async (
91
+ token: string | null,
92
+ repo: string,
93
+ issueNumber: number,
94
+ ): Promise<ReadonlyArray<IssueComment>> => {
95
+ const items = await listAllPages(
96
+ token,
97
+ `/repos/${repo}/issues/${issueNumber}/comments`,
98
+ `list ${repo}#${issueNumber} comments`,
99
+ );
100
+ return items.flatMap((raw) => {
101
+ if (!(isRecord(raw) && typeof raw.id === 'number')) {
102
+ return [];
103
+ }
104
+ return [
105
+ {
106
+ id: raw.id,
107
+ body: asString(raw.body),
108
+ authorLogin: isRecord(raw.user) ? asString(raw.user.login) : '',
109
+ createdAt: asString(raw.created_at),
110
+ },
111
+ ];
112
+ });
113
+ };
114
+
115
+ // The most recent `labeled` event for a label, from the full issue timeline.
116
+ // This is the poller's trust anchor (who approved) and its claim clock (when
117
+ // the in-progress label was applied); reading anything less than the full
118
+ // timeline would let a stale event stand in for the latest one.
119
+ export const lastLabelEvent = async (
120
+ token: string | null,
121
+ repo: string,
122
+ issueNumber: number,
123
+ label: string,
124
+ ): Promise<LabelEvent | null> => {
125
+ const expectedIdentity = labelIdentity(label);
126
+ const events = await listAllPages(
127
+ token,
128
+ `/repos/${repo}/issues/${issueNumber}/timeline`,
129
+ `read ${repo}#${issueNumber} timeline`,
130
+ );
131
+ let latest: LabelEvent | null = null;
132
+ for (const raw of events) {
133
+ if (
134
+ isRecord(raw) &&
135
+ typeof raw.id === 'number' &&
136
+ raw.event === 'labeled' &&
137
+ isRecord(raw.label) &&
138
+ typeof raw.label.name === 'string' &&
139
+ labelIdentity(raw.label.name) === expectedIdentity &&
140
+ isRecord(raw.actor) &&
141
+ isNonEmptyString(raw.actor.login) &&
142
+ isNonEmptyString(raw.created_at)
143
+ ) {
144
+ latest = {
145
+ id: raw.id,
146
+ actorLogin: raw.actor.login,
147
+ createdAt: raw.created_at,
148
+ };
149
+ }
150
+ }
151
+ return latest;
152
+ };
153
+
154
+ // role_name distinguishes maintain from write; the legacy `permission` field
155
+ // collapses both to "write" and cannot express the trust boundary. A 404
156
+ // means "not a collaborator" — an untrusted answer, not an API failure.
157
+ export const collaboratorRole = async (
158
+ token: string | null,
159
+ repo: string,
160
+ username: string,
161
+ ): Promise<string> => {
162
+ const HttpNotFound = 404;
163
+ const response = await request(
164
+ token,
165
+ 'GET',
166
+ `/repos/${repo}/collaborators/${encodeURIComponent(username)}/permission`,
167
+ );
168
+ if (response.status === HttpNotFound) {
169
+ return 'none';
170
+ }
171
+ if (response.status !== HTTP_OK || !isRecord(response.body)) {
172
+ throw new Error(apiError(`read ${repo} role for ${username}`, response));
173
+ }
174
+ return asString(response.body.role_name);
175
+ };
176
+
177
+ export const repoDefaultBranch = async (
178
+ token: string | null,
179
+ repo: string,
180
+ ): Promise<string> => {
181
+ const response = await request(token, 'GET', `/repos/${repo}`);
182
+ if (
183
+ response.status !== HTTP_OK ||
184
+ !isRecord(response.body) ||
185
+ !isNonEmptyString(response.body.default_branch)
186
+ ) {
187
+ throw new Error(apiError(`read ${repo} default branch`, response));
188
+ }
189
+ return response.body.default_branch;
190
+ };
@@ -0,0 +1,132 @@
1
+ // Shared job plumbing for fix and review runs: the failure and question
2
+ // transitions are identical state-machine moves, parameterized only by which
3
+ // approval/progress/failure labels the job family uses.
4
+
5
+ import { hasLabel } from './github-label-identity';
6
+ import { type ApprovalBinding, readApprovalBinding } from './poller-approval';
7
+ import type { PollerConfig } from './poller-config';
8
+ import { addLabels, createComment, removeLabel } from './poller-github-write';
9
+ import {
10
+ FAILURE_MARKER,
11
+ NEEDS_CLARIFICATION,
12
+ QUESTION_MARKER,
13
+ } from './poller-protocol';
14
+ import { answerState, type RoleCache } from './poller-trust';
15
+
16
+ export type JobDeps = {
17
+ readonly config: PollerConfig;
18
+ readonly token: string | null;
19
+ readonly repo: string;
20
+ readonly roleCache: RoleCache;
21
+ };
22
+
23
+ export type JobResult = {
24
+ readonly lines: ReadonlyArray<string>;
25
+ readonly ranCodex: boolean;
26
+ };
27
+
28
+ export type JobLabels = {
29
+ readonly approved: string;
30
+ readonly inProgress: string;
31
+ readonly failed: string;
32
+ };
33
+
34
+ const FAILURE_SNIPPET_LIMIT = 1500;
35
+
36
+ export type JobPreamble =
37
+ | { readonly kind: 'rejected' }
38
+ | { readonly kind: 'waiting' }
39
+ | {
40
+ readonly kind: 'go';
41
+ readonly answers: ReadonlyArray<string>;
42
+ readonly approval: ApprovalBinding;
43
+ };
44
+
45
+ // The shared front half of every job: verify the approval's actor, then
46
+ // resolve the question/answer state. Both job families make exactly these
47
+ // moves, differing only in which labels they use.
48
+ export const jobPreamble = async (
49
+ deps: JobDeps,
50
+ item: { readonly number: number; readonly labels: ReadonlyArray<string> },
51
+ labels: JobLabels,
52
+ target: string,
53
+ ): Promise<JobPreamble> => {
54
+ const trust = {
55
+ token: deps.token,
56
+ repo: deps.repo,
57
+ issueNumber: item.number,
58
+ roleCache: deps.roleCache,
59
+ };
60
+ const approval = await readApprovalBinding(trust, labels.approved, target);
61
+ if (typeof approval === 'string') {
62
+ await removeLabel(deps.token, deps.repo, item.number, labels.approved);
63
+ await createComment(
64
+ deps.token,
65
+ deps.repo,
66
+ item.number,
67
+ `${FAILURE_MARKER}\nApproval not honored: ${approval}`,
68
+ );
69
+ return { kind: 'rejected' };
70
+ }
71
+ const answers = await answerState(trust);
72
+ if (answers.waiting) {
73
+ if (!hasLabel(item.labels, NEEDS_CLARIFICATION)) {
74
+ await addLabels(deps.token, deps.repo, item.number, [
75
+ NEEDS_CLARIFICATION,
76
+ ]);
77
+ }
78
+ await removeLabel(deps.token, deps.repo, item.number, labels.inProgress);
79
+ return { kind: 'waiting' };
80
+ }
81
+ if (hasLabel(item.labels, NEEDS_CLARIFICATION)) {
82
+ await removeLabel(deps.token, deps.repo, item.number, NEEDS_CLARIFICATION);
83
+ }
84
+ return { kind: 'go', answers: answers.answers, approval };
85
+ };
86
+
87
+ export const failJob = async (
88
+ deps: JobDeps,
89
+ labels: JobLabels,
90
+ issueNumber: number,
91
+ reason: string,
92
+ ): Promise<void> => {
93
+ await createComment(
94
+ deps.token,
95
+ deps.repo,
96
+ issueNumber,
97
+ `${FAILURE_MARKER}\nAutomated run failed and needs a human look:\n\n${reason.slice(0, FAILURE_SNIPPET_LIMIT)}\n\nRe-apply \`${labels.approved}\` after addressing this to retry.`,
98
+ );
99
+ await addLabels(deps.token, deps.repo, issueNumber, [labels.failed]);
100
+ await removeLabel(deps.token, deps.repo, issueNumber, labels.approved);
101
+ await removeLabel(deps.token, deps.repo, issueNumber, labels.inProgress);
102
+ };
103
+
104
+ export const askQuestion = async (
105
+ deps: JobDeps,
106
+ labels: JobLabels,
107
+ issueNumber: number,
108
+ question: string,
109
+ ): Promise<void> => {
110
+ await createComment(
111
+ deps.token,
112
+ deps.repo,
113
+ issueNumber,
114
+ `${QUESTION_MARKER}\n${question}`,
115
+ );
116
+ await addLabels(deps.token, deps.repo, issueNumber, [NEEDS_CLARIFICATION]);
117
+ await removeLabel(deps.token, deps.repo, issueNumber, labels.inProgress);
118
+ };
119
+
120
+ // Also clears a failed-label left by an earlier attempt: a completed retry
121
+ // must not keep advertising "needs a human look".
122
+ export const releaseLabels = async (
123
+ deps: JobDeps,
124
+ labels: JobLabels,
125
+ issueNumber: number,
126
+ ): Promise<void> => {
127
+ await removeLabel(deps.token, deps.repo, issueNumber, labels.inProgress);
128
+ await removeLabel(deps.token, deps.repo, issueNumber, labels.failed);
129
+ // Approval is the scheduler's retry signal, so remove it only after every
130
+ // other cleanup write has succeeded.
131
+ await removeLabel(deps.token, deps.repo, issueNumber, labels.approved);
132
+ };
@@ -0,0 +1,136 @@
1
+ // Outcome-file parsing for poller Codex runs: the structured handoff the
2
+ // agent writes into its worktree. Strict validation — an outcome that fails
3
+ // any check is treated as no outcome at all, which routes the job to the
4
+ // explicit failure path instead of acting on half-trusted data.
5
+
6
+ import { existsSync, rmSync } from 'node:fs';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
10
+ import {
11
+ type DeferredFinding,
12
+ type FixOutcome,
13
+ OUTCOME_FILE,
14
+ type ReviewOutcome,
15
+ } from './poller-protocol';
16
+
17
+ const readOutcomeRaw = async (workDir: string): Promise<unknown | null> => {
18
+ const path = join(workDir, OUTCOME_FILE);
19
+ if (!existsSync(path)) {
20
+ return null;
21
+ }
22
+ try {
23
+ return JSON.parse(await readFile(path, 'utf8')) as unknown;
24
+ } catch {
25
+ return null;
26
+ } finally {
27
+ rmSync(path, { force: true });
28
+ }
29
+ };
30
+
31
+ const FIX_STATUSES: ReadonlySet<string> = new Set([
32
+ 'fixed',
33
+ 'question',
34
+ 'stale',
35
+ 'cannot-fix',
36
+ ]);
37
+
38
+ const REVIEW_STATUSES: ReadonlySet<string> = new Set([
39
+ 'reviewed',
40
+ 'question',
41
+ 'cannot-review',
42
+ ]);
43
+
44
+ // Conventional Commit subject: consumers lint PR titles in CI, so a
45
+ // malformed title is caught here instead of as a red check later.
46
+ const PR_TITLE_PATTERN = /^[a-z]+(?:\([^)]+\))?!?: .+/u;
47
+
48
+ export const readFixOutcome = async (
49
+ workDir: string,
50
+ ): Promise<FixOutcome | null> => {
51
+ const raw = await readOutcomeRaw(workDir);
52
+ if (
53
+ !isRecord(raw) ||
54
+ typeof raw.status !== 'string' ||
55
+ !FIX_STATUSES.has(raw.status) ||
56
+ !isNonEmptyString(raw.summary)
57
+ ) {
58
+ return null;
59
+ }
60
+ const status = raw.status as FixOutcome['status'];
61
+ if (status === 'question' && !isNonEmptyString(raw.question)) {
62
+ return null;
63
+ }
64
+ if (
65
+ status === 'fixed' &&
66
+ !(
67
+ isNonEmptyString(raw.prTitle) &&
68
+ PR_TITLE_PATTERN.test(raw.prTitle) &&
69
+ isNonEmptyString(raw.prBody)
70
+ )
71
+ ) {
72
+ return null;
73
+ }
74
+ return {
75
+ status,
76
+ summary: raw.summary,
77
+ question: typeof raw.question === 'string' ? raw.question : undefined,
78
+ prTitle: typeof raw.prTitle === 'string' ? raw.prTitle : undefined,
79
+ prBody: typeof raw.prBody === 'string' ? raw.prBody : undefined,
80
+ };
81
+ };
82
+
83
+ export const readReviewOutcome = async (
84
+ workDir: string,
85
+ ): Promise<ReviewOutcome | null> => {
86
+ const raw = await readOutcomeRaw(workDir);
87
+ if (
88
+ !isRecord(raw) ||
89
+ typeof raw.status !== 'string' ||
90
+ !REVIEW_STATUSES.has(raw.status) ||
91
+ !isNonEmptyString(raw.summary)
92
+ ) {
93
+ return null;
94
+ }
95
+ const status = raw.status as ReviewOutcome['status'];
96
+ if (status === 'question' && !isNonEmptyString(raw.question)) {
97
+ return null;
98
+ }
99
+ if (status === 'reviewed' && !isNonEmptyString(raw.report)) {
100
+ return null;
101
+ }
102
+ const deferred = parseDeferred(raw.deferred);
103
+ if (deferred === null) {
104
+ return null;
105
+ }
106
+ return {
107
+ status,
108
+ summary: raw.summary,
109
+ question: typeof raw.question === 'string' ? raw.question : undefined,
110
+ report: typeof raw.report === 'string' ? raw.report : undefined,
111
+ deferred,
112
+ };
113
+ };
114
+
115
+ const parseDeferred = (raw: unknown): ReadonlyArray<DeferredFinding> | null => {
116
+ if (raw === undefined) {
117
+ return [];
118
+ }
119
+ if (!Array.isArray(raw)) {
120
+ return null;
121
+ }
122
+ const deferred: Array<DeferredFinding> = [];
123
+ for (const entry of raw) {
124
+ if (
125
+ !(
126
+ isRecord(entry) &&
127
+ isNonEmptyString(entry.title) &&
128
+ isNonEmptyString(entry.body)
129
+ )
130
+ ) {
131
+ return null;
132
+ }
133
+ deferred.push({ title: entry.title, body: entry.body });
134
+ }
135
+ return deferred;
136
+ };
@@ -0,0 +1,107 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { runGit, type Workspace } from './poller-workspace';
3
+
4
+ const lines = (value: string): ReadonlyArray<string> =>
5
+ value
6
+ .split('\n')
7
+ .map((line) => line.trim())
8
+ .filter((line) => line.length > 0);
9
+
10
+ const GIT_OBJECT_ID = /^[0-9a-f]{40}$/u;
11
+ const WHITESPACE = /\s+/u;
12
+
13
+ export const isGitObjectId = (value: string): boolean =>
14
+ GIT_OBJECT_ID.test(value);
15
+
16
+ export const dirtyOutputPaths = (workDir: string): ReadonlyArray<string> =>
17
+ runGit(
18
+ [
19
+ '-C',
20
+ workDir,
21
+ 'status',
22
+ '--porcelain=v1',
23
+ '--no-renames',
24
+ '-z',
25
+ '--untracked-files=all',
26
+ ],
27
+ null,
28
+ )
29
+ .split('\0')
30
+ .filter((line) => line.length > 0);
31
+
32
+ export const assertCleanOutputWorktree = (workDir: string): void => {
33
+ const dirty = dirtyOutputPaths(workDir);
34
+ if (dirty.length > 0) {
35
+ throw new Error(
36
+ `refusing to seal a dirty output worktree:\n${dirty.join('\n')}`,
37
+ );
38
+ }
39
+ };
40
+
41
+ export const singleParentOf = (
42
+ cloneDir: string,
43
+ commit: string,
44
+ ): string | null => {
45
+ const [, parent, extra] = runGit(
46
+ ['-C', cloneDir, 'rev-list', '--parents', '-n', '1', commit],
47
+ null,
48
+ )
49
+ .trim()
50
+ .split(WHITESPACE);
51
+ return parent === undefined || extra !== undefined ? null : parent;
52
+ };
53
+
54
+ export const isAncestor = (
55
+ cloneDir: string,
56
+ ancestor: string,
57
+ descendant: string,
58
+ ): boolean => {
59
+ try {
60
+ runGit(
61
+ ['-C', cloneDir, 'merge-base', '--is-ancestor', ancestor, descendant],
62
+ null,
63
+ );
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ };
69
+
70
+ export const commitCountBetween = (
71
+ cloneDir: string,
72
+ base: string,
73
+ head: string,
74
+ ): number =>
75
+ Number.parseInt(
76
+ runGit(['-C', cloneDir, 'rev-list', '--count', `${base}..${head}`], null),
77
+ 10,
78
+ );
79
+
80
+ export const changedPathsBetween = (
81
+ cloneDir: string,
82
+ base: string,
83
+ head: string,
84
+ ): ReadonlyArray<string> =>
85
+ lines(runGit(['-C', cloneDir, 'diff', '--name-only', base, head], null));
86
+
87
+ export const createValidationWorktree = (
88
+ cloneDir: string,
89
+ startRef: string,
90
+ workDir: string,
91
+ ): Workspace => {
92
+ rmSync(workDir, { recursive: true, force: true });
93
+ runGit(['-C', cloneDir, 'worktree', 'prune'], null);
94
+ runGit(
95
+ ['-C', cloneDir, 'worktree', 'add', '--detach', workDir, startRef],
96
+ null,
97
+ );
98
+ const cleanup = (): void => {
99
+ try {
100
+ runGit(['-C', cloneDir, 'worktree', 'remove', '--force', workDir], null);
101
+ } catch {
102
+ rmSync(workDir, { recursive: true, force: true });
103
+ runGit(['-C', cloneDir, 'worktree', 'prune'], null);
104
+ }
105
+ };
106
+ return { dir: workDir, baseSha: startRef, cleanup };
107
+ };