@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.
@@ -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
+ };
@@ -19,7 +19,7 @@ export type TrustContext = {
19
19
  readonly roleCache: RoleCache;
20
20
  };
21
21
 
22
- const cachedRole = async (
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 cachedRole(context, comment.authorLogin);
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 cachedRole(context, comment.authorLogin);
90
+ const role = await cachedCollaboratorRole(context, comment.authorLogin);
91
91
  if (isTrustedRole(role)) {
92
92
  answers.push(comment.body);
93
93
  }
@@ -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
- // Bounds idle pickup latency while preserving a full cooldown after each tick:
12
- // OnUnitInactiveSec starts counting when the oneshot service finishes.
13
- // AccuracySec matters at this scale: systemd's default 1min accuracy would
14
- // stretch a 1min interval into a 1-2min window.
15
- const TICK_INTERVAL_MINUTES = 1;
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
- ): { readonly service: string; readonly timer: string } => ({
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=${TICK_INTERVAL_MINUTES}min
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
- };