@davidvornholt/standards 0.15.1 → 0.17.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/README.md +3 -3
- package/package.json +10 -2
- package/src/cli.ts +27 -2
- package/src/poller-acknowledgements.ts +159 -0
- package/src/poller-claim.ts +60 -28
- package/src/poller-codex.ts +5 -11
- package/src/poller-commands.ts +26 -5
- package/src/poller-comment-metadata.ts +25 -0
- package/src/poller-fix-run.ts +3 -0
- package/src/poller-github-write.ts +27 -1
- package/src/poller-outcome.ts +60 -18
- package/src/poller-prompts.ts +35 -9
- package/src/poller-protocol.ts +21 -11
- package/src/poller-queue-marker.ts +194 -0
- package/src/poller-review-artifacts.ts +4 -30
- package/src/poller-review-eligibility.ts +33 -0
- package/src/poller-review-execution.ts +7 -4
- package/src/poller-review-output.ts +14 -35
- package/src/poller-review-plan-parse.ts +94 -0
- package/src/poller-review-publication.ts +3 -3
- package/src/poller-review-run.ts +17 -26
- package/src/poller-review-thread-api.ts +166 -0
- package/src/poller-review-thread-write.ts +64 -0
- package/src/poller-review-threads.ts +119 -0
- package/src/poller-status.ts +133 -0
- package/src/poller-trust.ts +3 -3
- package/src/poller-units.ts +38 -7
- package/src/poller-github-publication.ts +0 -23
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { hasLabel } from './github-label-identity';
|
|
2
|
+
import {
|
|
3
|
+
type ApprovalBinding,
|
|
4
|
+
issueRevision,
|
|
5
|
+
prRevision,
|
|
6
|
+
readApprovalBinding,
|
|
7
|
+
} from './poller-approval';
|
|
8
|
+
import { hiddenCommentMetadata } from './poller-comment-metadata';
|
|
9
|
+
import { getIssue } from './poller-github';
|
|
10
|
+
import { getPullRequest } from './poller-github-pulls';
|
|
11
|
+
import { createComment, deleteComment } from './poller-github-write';
|
|
12
|
+
import type { JobDeps } from './poller-job-shared';
|
|
13
|
+
import { NEEDS_CLARIFICATION, QUEUE_METADATA_MARKER } from './poller-protocol';
|
|
14
|
+
import {
|
|
15
|
+
approvalLabelFor,
|
|
16
|
+
inProgressLabelFor,
|
|
17
|
+
matchingTrustedQueueCommentIds,
|
|
18
|
+
type PollerJobKind,
|
|
19
|
+
queueMarkerFor,
|
|
20
|
+
} from './poller-queue-marker';
|
|
21
|
+
import { reviewEligibility } from './poller-review-eligibility';
|
|
22
|
+
|
|
23
|
+
const queuedMessage = (kind: PollerJobKind) =>
|
|
24
|
+
kind === 'fix'
|
|
25
|
+
? '**Fix queued**\n\nThe poller has picked this up and will start work as soon as a run is available. You don’t need to do anything else.'
|
|
26
|
+
: '**Review queued**\n\nThe poller has picked this up and will start the review as soon as a run is available. You don’t need to do anything else.';
|
|
27
|
+
|
|
28
|
+
const isStillQueueable = async (
|
|
29
|
+
deps: JobDeps,
|
|
30
|
+
issueNumber: number,
|
|
31
|
+
approval: ApprovalBinding,
|
|
32
|
+
kind: PollerJobKind,
|
|
33
|
+
): Promise<boolean> => {
|
|
34
|
+
const expectedApprovalLabel = approvalLabelFor(kind);
|
|
35
|
+
if (
|
|
36
|
+
approval.repo !== deps.repo ||
|
|
37
|
+
approval.issueNumber !== issueNumber ||
|
|
38
|
+
approval.label !== expectedApprovalLabel
|
|
39
|
+
) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const issue = await getIssue(deps.token, deps.repo, issueNumber);
|
|
43
|
+
if (
|
|
44
|
+
!hasLabel(issue.labels, expectedApprovalLabel) ||
|
|
45
|
+
hasLabel(issue.labels, inProgressLabelFor(kind)) ||
|
|
46
|
+
hasLabel(issue.labels, NEEDS_CLARIFICATION)
|
|
47
|
+
) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
let target = issueRevision(issue);
|
|
51
|
+
if (kind === 'review') {
|
|
52
|
+
const pr = await getPullRequest(deps.token, deps.repo, issueNumber);
|
|
53
|
+
if (
|
|
54
|
+
reviewEligibility({
|
|
55
|
+
repo: deps.repo,
|
|
56
|
+
pr,
|
|
57
|
+
hasPlan: false,
|
|
58
|
+
}).kind !== 'eligible'
|
|
59
|
+
) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
target = prRevision(pr.baseRef, pr.baseSha, pr.headSha);
|
|
63
|
+
}
|
|
64
|
+
if (target !== approval.target) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const currentApproval = await readApprovalBinding(
|
|
68
|
+
{
|
|
69
|
+
token: deps.token,
|
|
70
|
+
repo: deps.repo,
|
|
71
|
+
issueNumber,
|
|
72
|
+
},
|
|
73
|
+
expectedApprovalLabel,
|
|
74
|
+
target,
|
|
75
|
+
);
|
|
76
|
+
return (
|
|
77
|
+
typeof currentApproval !== 'string' && currentApproval.id === approval.id
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const acknowledgeQueuedJob = async (
|
|
82
|
+
deps: JobDeps,
|
|
83
|
+
issueNumber: number,
|
|
84
|
+
approval: ApprovalBinding,
|
|
85
|
+
kind: PollerJobKind,
|
|
86
|
+
): Promise<boolean> => {
|
|
87
|
+
const expected = queueMarkerFor(approval, kind);
|
|
88
|
+
const current = await getIssue(deps.token, deps.repo, issueNumber);
|
|
89
|
+
if (
|
|
90
|
+
hasLabel(current.labels, inProgressLabelFor(kind)) ||
|
|
91
|
+
hasLabel(current.labels, NEEDS_CLARIFICATION)
|
|
92
|
+
) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
(
|
|
97
|
+
await matchingTrustedQueueCommentIds(
|
|
98
|
+
deps,
|
|
99
|
+
issueNumber,
|
|
100
|
+
expected.approvalId,
|
|
101
|
+
kind,
|
|
102
|
+
)
|
|
103
|
+
).length > 0
|
|
104
|
+
) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const markerId = await createComment(
|
|
108
|
+
deps.token,
|
|
109
|
+
deps.repo,
|
|
110
|
+
issueNumber,
|
|
111
|
+
`${queuedMessage(kind)}\n\n${hiddenCommentMetadata(
|
|
112
|
+
QUEUE_METADATA_MARKER,
|
|
113
|
+
expected,
|
|
114
|
+
)}`,
|
|
115
|
+
);
|
|
116
|
+
const [winner] = [
|
|
117
|
+
...(await matchingTrustedQueueCommentIds(
|
|
118
|
+
deps,
|
|
119
|
+
issueNumber,
|
|
120
|
+
expected.approvalId,
|
|
121
|
+
kind,
|
|
122
|
+
)),
|
|
123
|
+
].sort((left, right) => left - right);
|
|
124
|
+
if (winner !== markerId) {
|
|
125
|
+
await deleteComment(deps.token, deps.repo, markerId);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (!(await isStillQueueable(deps, issueNumber, approval, kind))) {
|
|
129
|
+
await deleteComment(deps.token, deps.repo, markerId);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
};
|
package/src/poller-trust.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type TrustContext = {
|
|
|
19
19
|
readonly roleCache: RoleCache;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
export const cachedCollaboratorRole = async (
|
|
23
23
|
context: TrustContext,
|
|
24
24
|
username: string,
|
|
25
25
|
): Promise<string> => {
|
|
@@ -53,7 +53,7 @@ const lastTrustedQuestionIndex = async (
|
|
|
53
53
|
const comment = comments[index];
|
|
54
54
|
if (comment !== undefined && hasQuestionMarker(comment)) {
|
|
55
55
|
// biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
|
|
56
|
-
const role = await
|
|
56
|
+
const role = await cachedCollaboratorRole(context, comment.authorLogin);
|
|
57
57
|
if (isTrustedRole(role)) {
|
|
58
58
|
return index;
|
|
59
59
|
}
|
|
@@ -87,7 +87,7 @@ export const answerState = async (
|
|
|
87
87
|
);
|
|
88
88
|
for (const comment of candidates) {
|
|
89
89
|
// biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
|
|
90
|
-
const role = await
|
|
90
|
+
const role = await cachedCollaboratorRole(context, comment.authorLogin);
|
|
91
91
|
if (isTrustedRole(role)) {
|
|
92
92
|
answers.push(comment.body);
|
|
93
93
|
}
|
package/src/poller-units.ts
CHANGED
|
@@ -8,11 +8,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import type { PollerConfig } from './poller-config';
|
|
9
9
|
|
|
10
10
|
const SERVICE_NAME = 'standards-poller';
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
const
|
|
11
|
+
// OnUnitInactiveSec starts counting when each oneshot service finishes.
|
|
12
|
+
// Keep worker pickup prompt while giving the acknowledgement observer enough
|
|
13
|
+
// API headroom for mixed queues, recovery plans, pagination, and worker calls.
|
|
14
|
+
const WORKER_INTERVAL_MINUTES = 1;
|
|
15
|
+
export const ACKNOWLEDGEMENT_INTERVAL_MINUTES = 5;
|
|
16
16
|
// Non-agent tick work: stale sweeps, clone/fetch, GitHub reads and writes.
|
|
17
17
|
const TICK_OVERHEAD_MINUTES = 30;
|
|
18
18
|
const MAX_ASCII_CONTROL_CODE = 31;
|
|
@@ -50,7 +50,12 @@ const tickBudgetMinutes = (config: PollerConfig): number =>
|
|
|
50
50
|
export const renderUnits = (
|
|
51
51
|
configPath: string,
|
|
52
52
|
config: PollerConfig,
|
|
53
|
-
): {
|
|
53
|
+
): {
|
|
54
|
+
readonly service: string;
|
|
55
|
+
readonly timer: string;
|
|
56
|
+
readonly acknowledgementService: string;
|
|
57
|
+
readonly acknowledgementTimer: string;
|
|
58
|
+
} => ({
|
|
54
59
|
service: `[Unit]
|
|
55
60
|
Description=Standards fix poller tick
|
|
56
61
|
After=network-online.target
|
|
@@ -66,7 +71,29 @@ Description=Run the standards fix poller on an interval
|
|
|
66
71
|
|
|
67
72
|
[Timer]
|
|
68
73
|
OnBootSec=2min
|
|
69
|
-
OnUnitInactiveSec=${
|
|
74
|
+
OnUnitInactiveSec=${WORKER_INTERVAL_MINUTES}min
|
|
75
|
+
AccuracySec=15s
|
|
76
|
+
Persistent=true
|
|
77
|
+
|
|
78
|
+
[Install]
|
|
79
|
+
WantedBy=timers.target
|
|
80
|
+
`,
|
|
81
|
+
acknowledgementService: `[Unit]
|
|
82
|
+
Description=Acknowledge queued standards poller requests
|
|
83
|
+
After=network-online.target
|
|
84
|
+
Wants=network-online.target
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
Type=oneshot
|
|
88
|
+
ExecStart=${quoteSystemdExecArg(process.execPath)} ${quoteSystemdExecArg(cliEntryPath())} poller --acknowledge-only --config ${quoteSystemdExecArg(configPath)}
|
|
89
|
+
TimeoutStartSec=${TICK_OVERHEAD_MINUTES}min
|
|
90
|
+
`,
|
|
91
|
+
acknowledgementTimer: `[Unit]
|
|
92
|
+
Description=Check for newly queued standards poller requests
|
|
93
|
+
|
|
94
|
+
[Timer]
|
|
95
|
+
OnBootSec=1min
|
|
96
|
+
OnUnitInactiveSec=${ACKNOWLEDGEMENT_INTERVAL_MINUTES}min
|
|
70
97
|
AccuracySec=15s
|
|
71
98
|
Persistent=true
|
|
72
99
|
|
|
@@ -84,4 +111,8 @@ export const runPollerPrintUnits = (
|
|
|
84
111
|
console.log(units.service);
|
|
85
112
|
console.log(`# ${SERVICE_NAME}.timer`);
|
|
86
113
|
console.log(units.timer);
|
|
114
|
+
console.log(`# ${SERVICE_NAME}-acknowledgements.service`);
|
|
115
|
+
console.log(units.acknowledgementService);
|
|
116
|
+
console.log(`# ${SERVICE_NAME}-acknowledgements.timer`);
|
|
117
|
+
console.log(units.acknowledgementTimer);
|
|
87
118
|
};
|
|
@@ -1,23 +0,0 @@
|
|
|
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
|
-
};
|