local-review 0.2.0 → 0.4.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/CHANGELOG.md +23 -0
- package/dist/demo/lib/gitworld.js +224 -0
- package/dist/demo/world/api.js +2436 -0
- package/dist/demo/world/infra.js +189 -0
- package/dist/demo/world/people.js +16 -0
- package/dist/demo/world/review.js +578 -0
- package/dist/demo/world/sdk.js +376 -0
- package/dist/demo/world/web.js +657 -0
- package/dist/server/cli.js +28 -0
- package/dist/server/config.js +3 -0
- package/dist/server/examples.js +204 -0
- package/dist/server/index.js +17 -2
- package/dist/server/projects.js +2 -0
- package/dist/web/assets/{angular-html-DJ4eInGE.js → angular-html-F7zRgWCZ.js} +1 -1
- package/dist/web/assets/{angular-ts-DBhgg2a7.js → angular-ts-BpEpytbw.js} +1 -1
- package/dist/web/assets/{apl-TCQAu201.js → apl-CXKG97CP.js} +1 -1
- package/dist/web/assets/{astro-DVtFuwC2.js → astro-Bbu7K9fw.js} +1 -1
- package/dist/web/assets/{blade-CMp-sZYg.js → blade-BIHnATAa.js} +1 -1
- package/dist/web/assets/{c-DgqspIM2.js → c-C-acQYm6.js} +1 -1
- package/dist/web/assets/{chapel-NTLt7i8E.js → chapel-CO4oLJ27.js} +1 -1
- package/dist/web/assets/{cobol-CJiWQrT4.js → cobol-DwOqj9Jg.js} +1 -1
- package/dist/web/assets/{coffee-Bv_hE6wf.js → coffee-D4-tkAkt.js} +1 -1
- package/dist/web/assets/{cpp-CuUsyAvc.js → cpp-B5AO2QHP.js} +1 -1
- package/dist/web/assets/{crystal-BeLopiD_.js → crystal-Da5vXEdt.js} +1 -1
- package/dist/web/assets/{css-OngwBV3K.js → css-BPZthOhS.js} +1 -1
- package/dist/web/assets/{edge-BFLildoE.js → edge-Dax-tr1_.js} +1 -1
- package/dist/web/assets/{elixir-1Fs6FjVs.js → elixir-Bn9yrJM6.js} +1 -1
- package/dist/web/assets/{elm-piMM67vF.js → elm-BOsm44l2.js} +1 -1
- package/dist/web/assets/{erb-JJ0sF8GL.js → erb-Gt3RYukx.js} +1 -1
- package/dist/web/assets/{git-rebase-CtqXywWe.js → git-rebase-PkordWam.js} +1 -1
- package/dist/web/assets/{glimmer-js-BPewWxGC.js → glimmer-js-B5msfXVo.js} +1 -1
- package/dist/web/assets/{glimmer-ts-DyUQWB47.js → glimmer-ts-CR148_Kt.js} +1 -1
- package/dist/web/assets/{glsl-D6tJoMaT.js → glsl-qur0cRcN.js} +1 -1
- package/dist/web/assets/{graphql-E6u2hkWE.js → graphql-C42K488Q.js} +1 -1
- package/dist/web/assets/{hack-Djq191-P.js → hack-NLFouBH4.js} +1 -1
- package/dist/web/assets/{haml-C0LqmZG3.js → haml-DKn_-I-I.js} +1 -1
- package/dist/web/assets/{handlebars-DwdxF6uN.js → handlebars-CQReOdSJ.js} +1 -1
- package/dist/web/assets/{html-derivative-DTVrLAw3.js → html-derivative-DoqJMb8S.js} +1 -1
- package/dist/web/assets/{html-B5Myt0fy.js → html-iUF_eP_4.js} +1 -1
- package/dist/web/assets/{http-CFoh3cQf.js → http-BJrCx5B5.js} +1 -1
- package/dist/web/assets/{hurl-B_38naAo.js → hurl-DlDfbRcL.js} +1 -1
- package/dist/web/assets/{index-CfxIojO0.js → index-CH6ceWie.js} +52 -52
- package/dist/web/assets/index-DU33o_Lu.css +1 -0
- package/dist/web/assets/{java-odnexk6r.js → java-DdxicsS9.js} +1 -1
- package/dist/web/assets/{javascript-DvFCJKws.js → javascript-Dw8rInq-.js} +1 -1
- package/dist/web/assets/{jinja-UeLt2DYu.js → jinja-ztBbs9jc.js} +1 -1
- package/dist/web/assets/{jison-HNaDYeqE.js → jison-DyOjkTQj.js} +1 -1
- package/dist/web/assets/{json-D57CnrjR.js → json-C6t0u9rP.js} +1 -1
- package/dist/web/assets/{jsx-o_5ZAydi.js → jsx-BpIc6zqG.js} +1 -1
- package/dist/web/assets/{julia-20UebbrD.js → julia-CvA9RPfz.js} +1 -1
- package/dist/web/assets/{just-DqnPCg-Q.js → just-CiH5trEM.js} +1 -1
- package/dist/web/assets/{latex-CZhIB-LV.js → latex-BbpuYBUm.js} +1 -1
- package/dist/web/assets/{liquid-BzouPsN0.js → liquid-Dy7K9saE.js} +1 -1
- package/dist/web/assets/{lua-BXclv8Fm.js → lua-CLN5DayT.js} +1 -1
- package/dist/web/assets/{marko-M78_INlG.js → marko-Be70_WI8.js} +1 -1
- package/dist/web/assets/{mdc-CBluOiY7.js → mdc-RgJIMLnK.js} +1 -1
- package/dist/web/assets/{nginx-CZFFQFbT.js → nginx-GhsGoiRj.js} +1 -1
- package/dist/web/assets/{nim-17kKsGqk.js → nim-BaIl7YzC.js} +1 -1
- package/dist/web/assets/{org-BZABHRQH.js → org-ksusUjYr.js} +1 -1
- package/dist/web/assets/{perl-CMrAQ9Je.js → perl-KX9noYTv.js} +1 -1
- package/dist/web/assets/{php-DwA3kETk.js → php-C-szMz57.js} +1 -1
- package/dist/web/assets/{pug-D4fM85kA.js → pug-nbUZSc0V.js} +1 -1
- package/dist/web/assets/{qml-C3893RHi.js → qml-BD2H6imu.js} +1 -1
- package/dist/web/assets/{r-DwJhLW77.js → r-CbR0TzPr.js} +1 -1
- package/dist/web/assets/{razor-_KkCNZ8V.js → razor-D_SYu_ob.js} +1 -1
- package/dist/web/assets/{regexp-BZ9NHg3b.js → regexp-BIEFfqXG.js} +1 -1
- package/dist/web/assets/{rst-gdK-1c4t.js → rst-D21NyilJ.js} +1 -1
- package/dist/web/assets/{ruby-DsmqYtlH.js → ruby-CCk7F-X_.js} +1 -1
- package/dist/web/assets/{sas-BNGv1ScF.js → sas-BasHIiX3.js} +1 -1
- package/dist/web/assets/{scss-DjYUXT73.js → scss-DL8S6L92.js} +1 -1
- package/dist/web/assets/{shellscript-DBtV-eli.js → shellscript-D-B4NAUX.js} +1 -1
- package/dist/web/assets/{shellsession-BKEV8Fnx.js → shellsession-LwE3hLIg.js} +1 -1
- package/dist/web/assets/{soy-DUapMxos.js → soy-DaUNAouD.js} +1 -1
- package/dist/web/assets/{sql-B-UyqKkd.js → sql-Drw6fxyA.js} +1 -1
- package/dist/web/assets/{stata-Xq3iZiMK.js → stata-Bk-VgNHo.js} +1 -1
- package/dist/web/assets/{surrealql-CzXacK8i.js → surrealql-BfOZjDkH.js} +1 -1
- package/dist/web/assets/{svelte-CZaBEmPP.js → svelte-BP8BXHT0.js} +1 -1
- package/dist/web/assets/{templ-CxkGuUpr.js → templ-BgcZlshd.js} +1 -1
- package/dist/web/assets/{tex-DP74JH6g.js → tex-BQczXiA6.js} +1 -1
- package/dist/web/assets/{ts-tags-BAcrCE2Y.js → ts-tags-CVFTjDsr.js} +1 -1
- package/dist/web/assets/{tsx-Dw11YP6z.js → tsx-j3JIiZaT.js} +1 -1
- package/dist/web/assets/{twig-BMVjBxD6.js → twig-D7saNHJy.js} +1 -1
- package/dist/web/assets/{typescript-DoBLlVdC.js → typescript-D62o9E27.js} +1 -1
- package/dist/web/assets/{typst-w2u_vl4_.js → typst-DQOeX7Fq.js} +1 -1
- package/dist/web/assets/{vue-BmuHTv5p.js → vue-BpdrgvY0.js} +1 -1
- package/dist/web/assets/{vue-html-Dt0zSF4I.js → vue-html-Cwi6ng_G.js} +1 -1
- package/dist/web/assets/{vue-vine-wvJ1BTex.js → vue-vine-BO8zdtYP.js} +1 -1
- package/dist/web/assets/{xml-CXftIBCQ.js → xml-DgdKyO3D.js} +1 -1
- package/dist/web/assets/{xsl-BoHSOMz1.js → xsl-CLXFRDPl.js} +1 -1
- package/dist/web/assets/{yaml-dGreldtD.js → yaml-BYa9ePnt.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/dist/web/assets/index-QywYYqTw.css +0 -1
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// The Local Review data home: three projects over the four repos, with PR overrides, readiness, branches, GitHub
|
|
2
|
+
// records, landed PRs and review threads, written as plain YAML following the schemas on the docs site
|
|
3
|
+
// (website/src/content/docs/reference/: project.yaml, PR files, comment files). Line numbers are looked up in the generated files, never hard-coded.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import YAML from 'yaml';
|
|
6
|
+
import { git, gitAsync } from '../lib/gitworld.js';
|
|
7
|
+
import { REVIEWER, at, prUrl } from './people.js';
|
|
8
|
+
const yaml = (o) => YAML.stringify(o, { lineWidth: 0, blockQuote: 'literal' });
|
|
9
|
+
/** Deterministic ids in the server's format (`t-1a2b3c4d`, `c-…`), from a seed string. */
|
|
10
|
+
const id = (prefix, seed) => `${prefix}-${crypto.createHash('sha1').update(seed).digest('hex').slice(0, 8)}`;
|
|
11
|
+
/** `git patch-id --stable` of one commit, as the server records it (empty for a commit it gives none, e.g. a merge). */
|
|
12
|
+
export async function patchIdAsync(dir, sha) {
|
|
13
|
+
const show = await gitAsync(dir, ['show', '--no-color', '--no-ext-diff', sha]);
|
|
14
|
+
return (await gitAsync(dir, ['patch-id', '--stable'], show)).split(' ')[0];
|
|
15
|
+
}
|
|
16
|
+
/** Every commit's patch id, by repo name and sha, without blocking (a few at a time). */
|
|
17
|
+
export async function patchIds(repos, repoDir) {
|
|
18
|
+
const out = new Map();
|
|
19
|
+
const jobs = Object.values(repos).flatMap((r) => {
|
|
20
|
+
const m = new Map();
|
|
21
|
+
out.set(r.name, m);
|
|
22
|
+
return r.commits.map((c) => async () => {
|
|
23
|
+
try {
|
|
24
|
+
m.set(c.sha, await patchIdAsync(repoDir(r.name), c.sha));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* left out: buildDataHome works it out itself if it needs this one */
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
await Promise.all(Array.from({ length: 6 }, async () => {
|
|
32
|
+
for (let job = jobs.shift(); job; job = jobs.shift())
|
|
33
|
+
await job();
|
|
34
|
+
}));
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** The three projects, by their plain names (before DataHomeOptions.slug). */
|
|
38
|
+
export const PROJECTS = ['waitlists', 'calendar-feed', 'webhook-retries'];
|
|
39
|
+
export function buildDataHome(repos, repoDir, opts = {}) {
|
|
40
|
+
const out = [];
|
|
41
|
+
const repoPath = opts.repoPath ?? ((name) => `~/code/${name}`);
|
|
42
|
+
const slug = opts.slug ?? ((name) => name);
|
|
43
|
+
const { api, sdk, web, infra } = repos;
|
|
44
|
+
/** 1-based line number of the first line containing `text` in `file` at commit `key` (on disk). */
|
|
45
|
+
const lineOf = (r, key, file, text, from = 1) => {
|
|
46
|
+
const lines = r.file(key, file).split('\n');
|
|
47
|
+
for (let i = from - 1; i < lines.length; i++)
|
|
48
|
+
if (lines[i].replace(/\t/g, ' ').includes(text))
|
|
49
|
+
return i + 1;
|
|
50
|
+
throw new Error(`${r.name} ${key}: "${text}" not in ${file}`);
|
|
51
|
+
};
|
|
52
|
+
const patchId = (r, key) => {
|
|
53
|
+
const known = opts.patchIds?.get(r.name)?.get(r.sha(key));
|
|
54
|
+
if (known !== undefined)
|
|
55
|
+
return known;
|
|
56
|
+
const dir = repoDir(r.name);
|
|
57
|
+
const show = git(dir, ['show', '--no-color', '--no-ext-diff', r.sha(key)]);
|
|
58
|
+
return git(dir, ['patch-id', '--stable'], show).split(' ')[0];
|
|
59
|
+
};
|
|
60
|
+
const thread = (r, key, seed, t) => {
|
|
61
|
+
const tid = id('t', `${r.name}/${key}/${seed}`);
|
|
62
|
+
const o = { id: tid };
|
|
63
|
+
if (t.file) {
|
|
64
|
+
const line = lineOf(r, key, t.file, t.line);
|
|
65
|
+
const end = t.to ? lineOf(r, key, t.file, t.to, line) : line;
|
|
66
|
+
Object.assign(o, { path: t.file, side: 'right', line: end, ...(end > line ? { start_line: line } : {}) });
|
|
67
|
+
}
|
|
68
|
+
o.resolved = t.resolved ?? false;
|
|
69
|
+
o.comments = t.comments.map((c, i) => ({ id: id('c', `${tid}/${i}`), author: c.by ?? REVIEWER, created: c.at, body: c.body.replace(/\n*$/, '\n') }));
|
|
70
|
+
return o;
|
|
71
|
+
};
|
|
72
|
+
/** A review file: reviews/<repo>/<sha>.yaml; `branch` is the repo's range end, as the server writes it. */
|
|
73
|
+
const review = (project, r, key, branch, o) => {
|
|
74
|
+
const c = r.get(key);
|
|
75
|
+
const doc = { repo: r.name, ...(branch ? { branch } : {}), commit: c.sha, patch_id: patchId(r, key), subject: c.subject, ...o.extra };
|
|
76
|
+
doc.viewed = o.viewed ?? [];
|
|
77
|
+
doc.threads = o.threads ?? [];
|
|
78
|
+
out.push({ path: `projects/${project}/reviews/${r.name}/${c.sha}.yaml`, content: yaml(doc), mtime: o.mtime });
|
|
79
|
+
};
|
|
80
|
+
/** A prs file: prs/<repo>/<sha>.yaml. */
|
|
81
|
+
const prs = (project, r, key, o) => {
|
|
82
|
+
const c = r.get(key);
|
|
83
|
+
const { mtime, ...fields } = o;
|
|
84
|
+
const doc = { commit: c.sha, patch_id: patchId(r, key), subject: c.subject, ...fields, updated: mtime };
|
|
85
|
+
out.push({ path: `projects/${project}/prs/${r.name}/${c.sha}.yaml`, content: yaml(doc), mtime });
|
|
86
|
+
};
|
|
87
|
+
const gh = (repo, n, branch, base, created, stack, stackedOn) => ({
|
|
88
|
+
number: n,
|
|
89
|
+
url: prUrl(repo, n),
|
|
90
|
+
branch,
|
|
91
|
+
base,
|
|
92
|
+
created,
|
|
93
|
+
stack,
|
|
94
|
+
...(stackedOn ? { stacked_on: stackedOn } : {}),
|
|
95
|
+
});
|
|
96
|
+
const prefix = (r, key) => r.sha(key).slice(0, 12);
|
|
97
|
+
// ======================================================================== waitlists (the flagship)
|
|
98
|
+
const P = slug('waitlists');
|
|
99
|
+
const BR = 'maya/waitlists';
|
|
100
|
+
out.push({
|
|
101
|
+
path: `projects/${P}/project.yaml`,
|
|
102
|
+
mtime: at('2026-09-16 15:30'),
|
|
103
|
+
content: `# Maya's waitlists work: four repos, reviewed here before (and while) it goes up as GitHub stacked PRs.
|
|
104
|
+
title: Class waitlists
|
|
105
|
+
description: |
|
|
106
|
+
Full classes turn people away, and the spots that free up when someone cancels mostly go unused:
|
|
107
|
+
nobody refreshes the schedule at 6am. This adds a waitlist to every full class. When a booking is
|
|
108
|
+
cancelled, the first person in line is offered the spot and has two hours to take it before it
|
|
109
|
+
moves on to the next.
|
|
110
|
+
|
|
111
|
+
It spans the API (data model, promotion, expiry), the SDK, the web app and the infra (the expiry
|
|
112
|
+
sweep and the rollout flag), and ships behind the \`waitlists\` flag, on for three beta studios first.
|
|
113
|
+
repos:
|
|
114
|
+
- path: ${repoPath('orchard-api')}
|
|
115
|
+
branch: ${BR}
|
|
116
|
+
base: origin/main
|
|
117
|
+
github_stack: 117
|
|
118
|
+
# merged on GitHub (#112, #113), frozen before the rebase onto the new main
|
|
119
|
+
landed: ["${prefix(api, 'f1')}", "${prefix(api, 'f2')}"]
|
|
120
|
+
stacks:
|
|
121
|
+
- title: Groundwork
|
|
122
|
+
color: blue
|
|
123
|
+
starts_at: "Add the waitlist_entries table"
|
|
124
|
+
description: |
|
|
125
|
+
The table and its store, with nothing calling them yet. Merged as #112 and #113.
|
|
126
|
+
- title: Joining and leaving
|
|
127
|
+
color: green
|
|
128
|
+
starts_at: "Let members join a full class's waitlist"
|
|
129
|
+
description: |
|
|
130
|
+
Members can join and leave a full class's waitlist and see their place in line. Nobody is
|
|
131
|
+
promoted yet: that's the next stack.
|
|
132
|
+
- title: Automatic promotion
|
|
133
|
+
color: purple
|
|
134
|
+
starts_at: "Promote the first waitlisted member when a spot opens"
|
|
135
|
+
description: |
|
|
136
|
+
A cancellation offers the spot to the first person in line, held for two hours; a sweep
|
|
137
|
+
expires offers nobody took and passes the spot on; the offer goes out by email.
|
|
138
|
+
- title: Studio controls
|
|
139
|
+
starts_at: "Let studio staff reorder a waitlist"
|
|
140
|
+
follow_on: true
|
|
141
|
+
description: |
|
|
142
|
+
Parked until the beta studios have used the basic waitlist for a few weeks. Next after these:
|
|
143
|
+
a per-studio hold window (it's a constant, two hours, for now).
|
|
144
|
+
- path: ${repoPath('orchard-sdk')}
|
|
145
|
+
branch: ${BR}
|
|
146
|
+
base: origin/main
|
|
147
|
+
stacks:
|
|
148
|
+
- title: Waitlist client
|
|
149
|
+
color: teal
|
|
150
|
+
description: |
|
|
151
|
+
Types and calls for the new endpoints. Released as 0.8.0-beta.1 for the web app to build on.
|
|
152
|
+
- path: ${repoPath('orchard-web')}
|
|
153
|
+
branch: ${BR}
|
|
154
|
+
base: origin/main
|
|
155
|
+
stacks:
|
|
156
|
+
- title: Waitlist UI
|
|
157
|
+
color: orange
|
|
158
|
+
starts_at: "Add a Join waitlist button to full classes"
|
|
159
|
+
- title: Offer copy
|
|
160
|
+
color: pink
|
|
161
|
+
starts_at: "Explain the hold window in the offer banner"
|
|
162
|
+
description: |
|
|
163
|
+
Split out so the copy can be reviewed with June without holding up the UI.
|
|
164
|
+
- path: ${repoPath('orchard-infra')}
|
|
165
|
+
branch: ${BR}
|
|
166
|
+
base: origin/main
|
|
167
|
+
stacks:
|
|
168
|
+
- title: Rollout
|
|
169
|
+
color: red
|
|
170
|
+
`,
|
|
171
|
+
});
|
|
172
|
+
// ---------------- orchard-api: prs files
|
|
173
|
+
prs(P, api, 'f1', {
|
|
174
|
+
status: 'ready',
|
|
175
|
+
branch: 'maya/waitlist-table',
|
|
176
|
+
github: gh('orchard-api', 112, 'maya/waitlist-table', 'main', at('2026-09-13 16:20'), 111),
|
|
177
|
+
landed: { head: api.sha('f1'), base: api.sha('Refuse bookings for classes that have already started'), commit: api.sha('squash #112'), method: 'squash', at: at('2026-09-14 10:02') },
|
|
178
|
+
mtime: at('2026-09-14 10:30'),
|
|
179
|
+
});
|
|
180
|
+
prs(P, api, 'f2', {
|
|
181
|
+
status: 'ready',
|
|
182
|
+
branch: 'maya/waitlist-store',
|
|
183
|
+
github: gh('orchard-api', 113, 'maya/waitlist-store', 'main', at('2026-09-13 16:21'), 111, 'maya/waitlist-table'),
|
|
184
|
+
landed: { head: api.sha('f2 on GitHub'), base: api.sha('squash #112'), commit: api.sha('squash #113'), method: 'squash', at: at('2026-09-14 10:09') },
|
|
185
|
+
mtime: at('2026-09-14 10:31'),
|
|
186
|
+
});
|
|
187
|
+
prs(P, api, 'f3', { status: 'ready', branch: 'maya/waitlist-join', github: gh('orchard-api', 118, 'maya/waitlist-join', 'main', at('2026-09-15 09:12'), 117), mtime: at('2026-09-15 09:20') });
|
|
188
|
+
prs(P, api, 'f4', { status: 'ready', branch: 'maya/waitlist-leave', github: gh('orchard-api', 119, 'maya/waitlist-leave', 'maya/waitlist-join', at('2026-09-15 09:13'), 117), mtime: at('2026-09-15 09:20') });
|
|
189
|
+
prs(P, api, 'f5', {
|
|
190
|
+
title: 'Show your waitlist position in GET /me/bookings',
|
|
191
|
+
status: 'ready',
|
|
192
|
+
branch: 'maya/waitlist-position',
|
|
193
|
+
github: gh('orchard-api', 120, 'maya/waitlist-position', 'maya/waitlist-leave', at('2026-09-15 09:14'), 117),
|
|
194
|
+
mtime: at('2026-09-15 09:20'),
|
|
195
|
+
});
|
|
196
|
+
prs(P, api, 'f6', {
|
|
197
|
+
branch: 'maya/promote-on-cancel',
|
|
198
|
+
body: `Cancelling a booking now offers the freed spot to the first person in the class's waitlist. The spot is held for them for two hours (\`HoldWindow\`); \`POST /me/offers/{id}/accept\` turns the offer into a booking.
|
|
199
|
+
|
|
200
|
+
Promotion runs inside the cancel transaction, holding the class lock, so two cancellations at once offer two different spots rather than the same one twice.`,
|
|
201
|
+
mtime: at('2026-09-16 15:40'),
|
|
202
|
+
});
|
|
203
|
+
prs(P, api, 'f7', { status: 'ready', branch: 'maya/promotion-expiry', mtime: at('2026-09-16 11:05') });
|
|
204
|
+
prs(P, api, 'f8', { branch: 'maya/promotion-emails', mtime: at('2026-09-12 17:00') });
|
|
205
|
+
// ---------------- orchard-api: reviews
|
|
206
|
+
review(P, api, 'f1', BR, {
|
|
207
|
+
mtime: at('2026-09-13 15:02'),
|
|
208
|
+
viewed: ['migrations/0005_waitlist.sql'],
|
|
209
|
+
threads: [
|
|
210
|
+
thread(api, 'f1', 'unique', {
|
|
211
|
+
file: 'migrations/0005_waitlist.sql',
|
|
212
|
+
line: 'UNIQUE (class_id, member_id)',
|
|
213
|
+
resolved: true,
|
|
214
|
+
comments: [
|
|
215
|
+
{ at: at('2026-09-12 09:14'), body: "This stops someone rejoining a waitlist after they've left (or after an offer to them expired). Is that what we want? I'd expect them to be able to get back in line, at the back." },
|
|
216
|
+
{
|
|
217
|
+
by: 'claude',
|
|
218
|
+
at: at('2026-09-12 09:31'),
|
|
219
|
+
body: "It is, and the constraint stays: the store's `JoinWaitlist` (next PR) upserts on it, so rejoining resets the row to `waiting` at the back of the line rather than inserting a second one. One row per member per class also keeps the position query simple. There's a test for it there: `TestRejoiningGoesToTheBack`.",
|
|
220
|
+
},
|
|
221
|
+
{ at: at('2026-09-12 10:02'), body: 'Makes sense, thanks.' },
|
|
222
|
+
],
|
|
223
|
+
}),
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
review(P, api, 'f2', BR, {
|
|
227
|
+
mtime: at('2026-09-13 15:05'),
|
|
228
|
+
viewed: ['internal/store/waitlist.go', 'internal/store/waitlist_test.go'],
|
|
229
|
+
threads: [
|
|
230
|
+
thread(api, 'f2', 'ci', {
|
|
231
|
+
resolved: true,
|
|
232
|
+
comments: [
|
|
233
|
+
{ at: at('2026-09-12 10:20'), body: 'These tests need `TEST_DATABASE_URL` and skip without it. Is CI setting it for this package, or are they quietly skipping there too?' },
|
|
234
|
+
{ by: 'claude', at: at('2026-09-12 10:26'), body: "CI sets it for the whole `go test ./...` run (`.github/workflows/ci.yml`) and applies every migration first, so 0005 is in place. I checked the last run's log: `TestRejoiningGoesToTheBack` ran and passed. Locally they skip, like the booking tests." },
|
|
235
|
+
],
|
|
236
|
+
}),
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
review(P, api, 'f3', BR, {
|
|
240
|
+
mtime: at('2026-09-14 16:40'),
|
|
241
|
+
viewed: ['README.md', 'internal/booking/waitlist.go', 'internal/httpapi/router.go', 'internal/httpapi/waitlist.go'],
|
|
242
|
+
threads: [
|
|
243
|
+
thread(api, 'f3', 'status', {
|
|
244
|
+
file: 'internal/httpapi/waitlist.go',
|
|
245
|
+
line: 'case errors.Is(err, booking.ErrClassNotFull):',
|
|
246
|
+
resolved: true,
|
|
247
|
+
comments: [
|
|
248
|
+
{ at: at('2026-09-14 15:58'), body: "409 or 422 here? The request itself is fine; the class just isn't in the state it needs." },
|
|
249
|
+
{ by: 'claude', at: at('2026-09-14 16:05'), body: "409, to match `class_full` and `already_booked`: the clients already treat a 409 as \"the class changed under you, refresh it\", which is the right reaction here too. A 422 would be a new case for every client to handle." },
|
|
250
|
+
{ at: at('2026-09-14 16:39'), body: 'Agreed.' },
|
|
251
|
+
],
|
|
252
|
+
}),
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
review(P, api, 'f4', BR, { mtime: at('2026-09-14 16:44'), viewed: ['internal/booking/waitlist.go', 'internal/httpapi/router.go', 'internal/httpapi/waitlist.go'] });
|
|
256
|
+
review(P, api, 'f5', BR, {
|
|
257
|
+
mtime: at('2026-09-14 17:10'),
|
|
258
|
+
viewed: ['internal/httpapi/bookings.go', 'internal/httpapi/waitlist.go', 'internal/store/waitlist.go'],
|
|
259
|
+
threads: [
|
|
260
|
+
thread(api, 'f5', 'position', {
|
|
261
|
+
file: 'internal/store/waitlist.go',
|
|
262
|
+
line: '(SELECT count(*) FROM waitlist_entries a',
|
|
263
|
+
to: "WHERE a.class_id = w.class_id AND a.status = 'waiting' AND a.position <= w.position),",
|
|
264
|
+
resolved: true,
|
|
265
|
+
comments: [
|
|
266
|
+
{ at: at('2026-09-14 16:58'), body: "So the position counts only the people still waiting, and it can go *down* while you wait, when someone ahead leaves. Good: that's what we want to show. Worth saying in the doc comment, so nobody \"fixes\" it to return the stored `position`." },
|
|
267
|
+
{ by: 'claude', at: at('2026-09-14 17:04'), body: 'Added to the doc comment on `MemberWaitlist`: "Position counts only the people still waiting ahead of them."' },
|
|
268
|
+
],
|
|
269
|
+
}),
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
// the outdated thread: made on the first version of "Promote…", on lines the amend removed
|
|
273
|
+
const v1 = 'f6@v1';
|
|
274
|
+
const promote = 'internal/booking/promote.go';
|
|
275
|
+
const oStart = lineOf(api, v1, promote, 'class, err := s.db.Class(ctx, classID)');
|
|
276
|
+
const oEnd = oStart + 3;
|
|
277
|
+
const v1Lines = api.file(v1, promote).split('\n');
|
|
278
|
+
const outdated = {
|
|
279
|
+
...thread(api, v1, 'outside-tx', {
|
|
280
|
+
file: promote,
|
|
281
|
+
line: 'class, err := s.db.Class(ctx, classID)',
|
|
282
|
+
to: ' }',
|
|
283
|
+
comments: [
|
|
284
|
+
{ at: at('2026-09-15 14:21'), body: "This reads the class through the pool, outside the transaction, so it neither sees nor locks anything the cancellation just did. See the general comment." },
|
|
285
|
+
{ by: 'claude', at: at('2026-09-16 15:22'), body: 'Gone in the amend: the class now comes from `store.LockClass` inside the cancel transaction, and is passed in.' },
|
|
286
|
+
],
|
|
287
|
+
}),
|
|
288
|
+
outdated: true,
|
|
289
|
+
anchor: {
|
|
290
|
+
commit: api.sha(v1),
|
|
291
|
+
path: promote,
|
|
292
|
+
side: 'right',
|
|
293
|
+
line: oEnd,
|
|
294
|
+
start_line: oStart,
|
|
295
|
+
before: v1Lines.slice(oStart - 4, oStart - 1).join('\n'),
|
|
296
|
+
text: v1Lines.slice(oStart - 1, oEnd).join('\n'),
|
|
297
|
+
after: v1Lines.slice(oEnd, oEnd + 3).join('\n'),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
review(P, api, 'f6', BR, {
|
|
301
|
+
mtime: at('2026-09-18 09:47'),
|
|
302
|
+
extra: { previous_commits: [api.sha(v1)], matched_by: 'subject' },
|
|
303
|
+
viewed: ['internal/httpapi/offers.go', 'internal/httpapi/router.go', 'internal/store/waitlist.go'],
|
|
304
|
+
threads: [
|
|
305
|
+
thread(api, 'f6', 'race', {
|
|
306
|
+
comments: [
|
|
307
|
+
{
|
|
308
|
+
at: at('2026-09-15 14:16'),
|
|
309
|
+
body: "The happy path reads well. My main worry is two cancellations for the same class landing at once: both transactions count the bookings before either commits, both see a free spot, and we offer two people the same place.\n\nCan you take the class lock first, like `Book` does?",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
by: 'claude',
|
|
313
|
+
at: at('2026-09-16 15:21'),
|
|
314
|
+
body: "Done, squashed into this commit (the stack is rebased on it):\n\n- `Cancel` takes `store.LockClass` before promoting, and `promoteFirstWaiting` takes the locked class instead of reading it again.\n- `ExpireHolds`, in the next PR, locks the same way.\n- New test: `TestConcurrentCancellationsOfferOneSpotEach` cancels two bookings at once and checks that the first two in line get an offer each and the third keeps waiting.",
|
|
315
|
+
},
|
|
316
|
+
{ at: at('2026-09-17 08:52'), body: "Great. Leaving this open until I've read the test properly." },
|
|
317
|
+
],
|
|
318
|
+
}),
|
|
319
|
+
outdated,
|
|
320
|
+
thread(api, 'f6', 'offered-spots', {
|
|
321
|
+
file: promote,
|
|
322
|
+
line: 'n, err := store.CountBookings(ctx, tx, class.ID)',
|
|
323
|
+
to: 'if n >= class.Capacity {',
|
|
324
|
+
comments: [
|
|
325
|
+
{
|
|
326
|
+
at: at('2026-09-17 09:05'),
|
|
327
|
+
body: "`CountBookings` only counts confirmed bookings, so while a spot is on offer it still looks free. Someone booking from the schedule during the hold takes it, and the person we emailed finds it gone when they tap **Take the spot**.",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
by: 'claude',
|
|
331
|
+
at: at('2026-09-17 09:18'),
|
|
332
|
+
body: "You're right, and `Book` has the same gap. Two ways to fix it:\n\n1. Count outstanding offers in `CountBookings` itself, so `Book`, `JoinWaitlist` and promotion all agree on what's taken.\n2. A separate `TakenSpots`, used here and in `Book`.\n\nI'd do 1, in this commit: an offered spot is taken for every purpose I can think of. Shall I?",
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
}),
|
|
336
|
+
thread(api, 'f6', 'hold', {
|
|
337
|
+
file: promote,
|
|
338
|
+
line: 'const HoldWindow = 2 * time.Hour',
|
|
339
|
+
resolved: true,
|
|
340
|
+
comments: [
|
|
341
|
+
{ at: at('2026-09-15 14:30'), body: "Two hours is long for a 7am class when the cancellation comes in at 6:30. Not for this PR, but studios will want to set this." },
|
|
342
|
+
{ by: 'claude', at: at('2026-09-16 15:24'), body: "Agreed. I've left it a constant here and added a per-studio hold window to the Studio controls stack's note, as the next thing after those two PRs." },
|
|
343
|
+
],
|
|
344
|
+
}),
|
|
345
|
+
],
|
|
346
|
+
});
|
|
347
|
+
review(P, api, 'f7', BR, {
|
|
348
|
+
mtime: at('2026-09-17 11:02'),
|
|
349
|
+
viewed: ['README.md', 'cmd/orchard-api/main.go', 'cmd/orchard-api/sweep.go', 'internal/booking/expiry.go', 'internal/store/waitlist.go'],
|
|
350
|
+
threads: [
|
|
351
|
+
thread(api, 'f7', 'accept-race', {
|
|
352
|
+
file: 'internal/booking/expiry.go',
|
|
353
|
+
line: 'ok, err := store.ExpireOffer(ctx, tx, e.ID, s.now())',
|
|
354
|
+
resolved: true,
|
|
355
|
+
comments: [
|
|
356
|
+
{ at: at('2026-09-17 10:31'), body: 'What happens if the member taps accept in the same second the sweep runs?' },
|
|
357
|
+
{
|
|
358
|
+
by: 'claude',
|
|
359
|
+
at: at('2026-09-17 10:40'),
|
|
360
|
+
body: "Both are a single `UPDATE … WHERE status = 'offered'`, so whichever commits first wins and the other matches no row: an accept after the expiry gets `offer_gone` (410), and a sweep after the accept skips it (`!ok`). There's no window in which both succeed.",
|
|
361
|
+
},
|
|
362
|
+
{ at: at('2026-09-17 11:01'), body: 'Perfect, thanks.' },
|
|
363
|
+
],
|
|
364
|
+
}),
|
|
365
|
+
],
|
|
366
|
+
});
|
|
367
|
+
review(P, api, 'f8', BR, {
|
|
368
|
+
mtime: at('2026-09-18 10:12'),
|
|
369
|
+
viewed: ['internal/store/outbox.go', 'migrations/0006_notification_outbox.sql'],
|
|
370
|
+
threads: [
|
|
371
|
+
thread(api, 'f8', 'skip-locked', {
|
|
372
|
+
file: 'internal/store/outbox.go',
|
|
373
|
+
line: 'FOR UPDATE SKIP LOCKED',
|
|
374
|
+
resolved: true,
|
|
375
|
+
comments: [
|
|
376
|
+
{ at: at('2026-09-18 09:58'), body: 'With three API replicas we get three relays. Can two of them send the same message?' },
|
|
377
|
+
{
|
|
378
|
+
by: 'claude',
|
|
379
|
+
at: at('2026-09-18 10:04'),
|
|
380
|
+
body: "Not normally: each relay claims its rows with `FOR UPDATE SKIP LOCKED` and marks them sent before it commits, so a row one relay holds is invisible to the others. A failed send leaves the row unsent for the next tick. The one way to get a duplicate is a pod dying after the send but before the commit: at-least-once, the usual outbox trade-off.",
|
|
381
|
+
},
|
|
382
|
+
{ at: at('2026-09-18 10:11'), body: 'At-least-once is fine for this.' },
|
|
383
|
+
],
|
|
384
|
+
}),
|
|
385
|
+
thread(api, 'f8', 'copy', {
|
|
386
|
+
file: 'internal/notify/messages.go',
|
|
387
|
+
line: 'Good news: a spot in %s with %s (%s) is yours if you want it.',
|
|
388
|
+
comments: [
|
|
389
|
+
{ at: at('2026-09-18 10:15'), body: '"is yours if you want it" could read as *already booked*. Let\'s get June to look at the wording before this goes up; the web banner has the same sentence.' },
|
|
390
|
+
],
|
|
391
|
+
}),
|
|
392
|
+
],
|
|
393
|
+
});
|
|
394
|
+
// ---------------- orchard-sdk
|
|
395
|
+
prs(P, sdk, 's1', { status: 'ready', branch: 'maya/sdk-waitlist-types', github: gh('orchard-sdk', 64, 'maya/sdk-waitlist-types', 'main', at('2026-09-15 10:02'), 63), mtime: at('2026-09-15 10:05') });
|
|
396
|
+
prs(P, sdk, 's2', { title: 'Add joinWaitlist() and leaveWaitlist()', status: 'ready', branch: 'maya/sdk-join-leave', mtime: at('2026-09-15 15:40') });
|
|
397
|
+
prs(P, sdk, 's3', { branch: 'maya/sdk-list-waitlist', mtime: at('2026-09-15 16:10') });
|
|
398
|
+
review(P, sdk, 's1', BR, { mtime: at('2026-09-15 09:50'), viewed: ['src/types.ts'] });
|
|
399
|
+
review(P, sdk, 's2', BR, {
|
|
400
|
+
mtime: at('2026-09-15 15:38'),
|
|
401
|
+
viewed: ['src/resources/bookings.ts', 'test/waitlist.test.ts'],
|
|
402
|
+
threads: [
|
|
403
|
+
thread(sdk, 's2', 'retry', {
|
|
404
|
+
file: 'src/resources/bookings.ts',
|
|
405
|
+
line: "/** Leave a class's waitlist. Leaving one you're not on is fine. */",
|
|
406
|
+
resolved: true,
|
|
407
|
+
comments: [
|
|
408
|
+
{ at: at('2026-09-15 15:12'), body: "The client retries DELETEs on 502/503, so \"leaving one you're not on is fine\" is load-bearing: a retried leave has to succeed. Is that tested anywhere?" },
|
|
409
|
+
{ by: 'claude', at: at('2026-09-15 15:20'), body: 'It is now: `retries a leave on 503, since leaving twice is harmless` in `test/waitlist.test.ts` checks the client makes three attempts. The API side is `LeaveWaitlist`, which updates no rows (and returns 204) when you\'re not on the list.' },
|
|
410
|
+
],
|
|
411
|
+
}),
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
review(P, sdk, 's3', BR, {
|
|
415
|
+
mtime: at('2026-09-16 09:30'),
|
|
416
|
+
viewed: ['CHANGELOG.md'],
|
|
417
|
+
threads: [
|
|
418
|
+
thread(sdk, 's3', 'optional', {
|
|
419
|
+
file: 'src/resources/bookings.ts',
|
|
420
|
+
line: 'listMine(): Promise<{ bookings: Booking[]; waitlist: WaitlistPlace[] }> {',
|
|
421
|
+
comments: [
|
|
422
|
+
{ at: at('2026-09-16 09:12'), body: "The API only has waitlists for studios with the flag on. Should `waitlist` be optional here, so nobody's code assumes it's there?" },
|
|
423
|
+
{ by: 'claude', at: at('2026-09-16 09:21'), body: "The API always returns `waitlist`, as an empty list when you're on none, flag or not: the flag only gates joining. So it's safe to keep it required. I can add a line to the CHANGELOG saying it's always present, if that helps." },
|
|
424
|
+
],
|
|
425
|
+
}),
|
|
426
|
+
],
|
|
427
|
+
});
|
|
428
|
+
// ---------------- orchard-web
|
|
429
|
+
prs(P, web, 'w1', { status: 'ready', branch: 'maya/web-join-button', mtime: at('2026-09-16 14:00') });
|
|
430
|
+
prs(P, web, 'w2', { branch: 'maya/web-waitlist-position', mtime: at('2026-09-16 14:00') });
|
|
431
|
+
review(P, web, 'w1', BR, {
|
|
432
|
+
mtime: at('2026-09-16 13:55'),
|
|
433
|
+
viewed: ['src/pages/Schedule.tsx', 'src/styles.css'],
|
|
434
|
+
threads: [
|
|
435
|
+
thread(web, 'w1', 'a11y', {
|
|
436
|
+
file: 'src/components/ClassCard.tsx',
|
|
437
|
+
line: '<button className="secondary" disabled={busy} onClick={() => run(onJoinWaitlist)}>',
|
|
438
|
+
to: '</button>',
|
|
439
|
+
comments: [
|
|
440
|
+
{ at: at('2026-09-16 13:40'), body: 'A screen reader hears "Join waitlist, button" with no idea the class is full. The spots line already has an id, so `` aria-describedby={`spots-${cls.id}`} `` would read "Full" after it.' },
|
|
441
|
+
{ by: 'claude', at: at('2026-09-16 13:47'), body: 'Good idea; I\'ll add it to both buttons (Book then reads "3 of 12 left"). The length of the line isn\'t in the schedule response yet. If you want that read out too, I can add `waitlist_length` to the API\'s class JSON.' },
|
|
442
|
+
],
|
|
443
|
+
}),
|
|
444
|
+
],
|
|
445
|
+
});
|
|
446
|
+
review(P, web, 'w3', BR, {
|
|
447
|
+
mtime: at('2026-09-16 14:20'),
|
|
448
|
+
viewed: ['src/styles.css'],
|
|
449
|
+
threads: [
|
|
450
|
+
thread(web, 'w3', 'how-long', {
|
|
451
|
+
file: 'src/pages/MyBookings.tsx',
|
|
452
|
+
line: "<p>A spot opened up and it's yours if you want it.</p>",
|
|
453
|
+
resolved: true,
|
|
454
|
+
comments: [
|
|
455
|
+
{ at: at('2026-09-16 14:08'), body: "This needs to say how long they have, or people will leave it until after class." },
|
|
456
|
+
{ by: 'claude', at: at('2026-09-16 14:15'), body: 'Done in the next commit, "Explain the hold window in the offer banner", so this one stays a UI change and the wording can be reviewed with June on its own.' },
|
|
457
|
+
],
|
|
458
|
+
}),
|
|
459
|
+
],
|
|
460
|
+
});
|
|
461
|
+
// ---------------- orchard-infra
|
|
462
|
+
prs(P, infra, 'i1', { status: 'ready', branch: 'maya/infra-sweep-cron', mtime: at('2026-09-17 15:00') });
|
|
463
|
+
prs(P, infra, 'i2', { branch: 'maya/infra-sweep-alert', mtime: at('2026-09-17 15:00') });
|
|
464
|
+
review(P, infra, 'i1', BR, {
|
|
465
|
+
mtime: at('2026-09-17 14:52'),
|
|
466
|
+
viewed: ['k8s/api/cronjobs.yaml'],
|
|
467
|
+
threads: [
|
|
468
|
+
thread(infra, 'i1', 'cadence', {
|
|
469
|
+
file: 'k8s/api/cronjobs.yaml',
|
|
470
|
+
line: 'schedule: "*/5 * * * *"',
|
|
471
|
+
comments: [
|
|
472
|
+
{ at: at('2026-09-17 14:30'), body: 'Every five minutes means a hold can run up to five minutes past its two hours, while the email gives an exact time. Should the sweep run every minute instead?' },
|
|
473
|
+
{
|
|
474
|
+
by: 'claude',
|
|
475
|
+
at: at('2026-09-17 14:41'),
|
|
476
|
+
body: "I'd keep five minutes. The time in the email is exact for the member: the API refuses an accept once `hold_until` has passed (`TakeOffer` checks it). What can be up to five minutes late is only the hand-off to the next person. Every minute would be 1,440 job pods a day for a sweep that usually finds nothing.",
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
}),
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
review(P, infra, 'i3', BR, { mtime: at('2026-09-17 15:10'), viewed: ['flags/flags.yaml'] });
|
|
483
|
+
// ======================================================================== calendar-feed (one repo, no stacks)
|
|
484
|
+
const C = slug('calendar-feed');
|
|
485
|
+
out.push({
|
|
486
|
+
path: `projects/${C}/project.yaml`,
|
|
487
|
+
mtime: at('2026-09-11 16:00'),
|
|
488
|
+
content: `title: Calendar feed
|
|
489
|
+
description: |
|
|
490
|
+
Theo's private iCalendar feed of each member's bookings, so classes show up in their own calendar app.
|
|
491
|
+
repos:
|
|
492
|
+
- path: ${repoPath('orchard-api')}
|
|
493
|
+
branch: theo/calendar-feed
|
|
494
|
+
base: origin/main
|
|
495
|
+
`,
|
|
496
|
+
});
|
|
497
|
+
const cal1 = 'Add an iCalendar encoder for bookings';
|
|
498
|
+
const cal2Key = api.commits.find((c) => c.subject.startsWith('Serve a private calendar feed')).key;
|
|
499
|
+
prs(C, api, cal1, { status: 'ready', mtime: at('2026-09-11 15:20') });
|
|
500
|
+
review(C, api, cal1, 'theo/calendar-feed', {
|
|
501
|
+
mtime: at('2026-09-11 15:18'),
|
|
502
|
+
viewed: ['internal/ical/ical.go', 'internal/ical/ical_test.go'],
|
|
503
|
+
threads: [
|
|
504
|
+
thread(api, cal1, 'fold', {
|
|
505
|
+
file: 'internal/ical/ical.go',
|
|
506
|
+
line: 'for len(s) > 75 {',
|
|
507
|
+
resolved: true,
|
|
508
|
+
comments: [
|
|
509
|
+
{ at: at('2026-09-11 15:02'), body: 'This folds at 75 *bytes*, which can split a multi-byte character (a coach called Zoë). Calendar apps are mostly forgiving, but it\'s easy to fold on a rune boundary.' },
|
|
510
|
+
{ by: 'claude', at: at('2026-09-11 15:10'), body: "Right: RFC 5545 counts octets, but it says not to split a UTF-8 sequence. I'll back up to the last rune start before the 75th byte, and add Zoë to the test." },
|
|
511
|
+
],
|
|
512
|
+
}),
|
|
513
|
+
],
|
|
514
|
+
});
|
|
515
|
+
review(C, api, cal2Key, 'theo/calendar-feed', {
|
|
516
|
+
mtime: at('2026-09-11 15:45'),
|
|
517
|
+
viewed: ['migrations/0005_calendar_tokens.sql'],
|
|
518
|
+
threads: [
|
|
519
|
+
thread(api, cal2Key, 'unauth', {
|
|
520
|
+
file: 'internal/httpapi/router.go',
|
|
521
|
+
line: 'r.Get("/calendar/{token}", h.calendarFeed)',
|
|
522
|
+
comments: [
|
|
523
|
+
{ at: at('2026-09-11 15:40'), body: "Outside the auth group, by design (calendar apps can't send a bearer token). With 32 random bytes guessing is hopeless, but a leaked URL can be polled forever. Should a token expire if nothing fetches it for a few months?" },
|
|
524
|
+
],
|
|
525
|
+
}),
|
|
526
|
+
],
|
|
527
|
+
});
|
|
528
|
+
// ======================================================================== webhook-retries (archived: all landed)
|
|
529
|
+
const W = slug('webhook-retries');
|
|
530
|
+
out.push({
|
|
531
|
+
path: `projects/${W}/project.yaml`,
|
|
532
|
+
mtime: at('2026-08-06 17:10'),
|
|
533
|
+
content: `title: Payment webhook retries
|
|
534
|
+
description: |
|
|
535
|
+
Sam's fix for payment webhooks that were lost when processing failed: store every delivery first,
|
|
536
|
+
retry failures with backoff, and list the ones that never succeed for staff. Went up as GitHub
|
|
537
|
+
stack #87 and merged in August; kept here for reference.
|
|
538
|
+
repos:
|
|
539
|
+
- path: ${repoPath('orchard-api')}
|
|
540
|
+
base: origin/main
|
|
541
|
+
github_stack: 87
|
|
542
|
+
landed: ["${api.sha('Record webhook deliveries before processing them').slice(0, 12)}", "${api.sha('Retry failed deliveries with exponential backoff').slice(0, 12)}", "${api.sha('List deliveries that keep failing for staff').slice(0, 12)}"]
|
|
543
|
+
stacks:
|
|
544
|
+
- title: Webhook retries
|
|
545
|
+
color: green
|
|
546
|
+
starts_at: "Record webhook deliveries before processing them"
|
|
547
|
+
`,
|
|
548
|
+
});
|
|
549
|
+
const merged = [
|
|
550
|
+
{ key: 'Record webhook deliveries before processing them', n: 88, branch: 'sam/webhook-deliveries', base: 'Document the API error codes', merge: 'merge #88', at: '2026-08-04 14:12' },
|
|
551
|
+
{ key: 'Retry failed deliveries with exponential backoff', n: 89, branch: 'sam/webhook-retry', base: 'merge #88', merge: 'merge #89', at: '2026-08-05 09:31', on: 'sam/webhook-deliveries' },
|
|
552
|
+
{ key: 'List deliveries that keep failing for staff', n: 90, branch: 'sam/webhook-dead-letters', base: 'merge #89', merge: 'merge #90', at: '2026-08-06 16:47', on: 'sam/webhook-retry' },
|
|
553
|
+
];
|
|
554
|
+
for (const m of merged)
|
|
555
|
+
prs(W, api, m.key, {
|
|
556
|
+
status: 'ready',
|
|
557
|
+
branch: m.branch,
|
|
558
|
+
github: gh('orchard-api', m.n, m.branch, 'main', at('2026-07-30 11:00'), 87, m.on),
|
|
559
|
+
landed: { head: api.sha(m.key), base: api.sha(m.base), commit: api.sha(m.merge), method: 'merge', at: at(m.at) },
|
|
560
|
+
mtime: at(m.at),
|
|
561
|
+
});
|
|
562
|
+
review(W, api, merged[1].key, undefined, {
|
|
563
|
+
mtime: at('2026-07-30 10:40'),
|
|
564
|
+
viewed: ['internal/payments/retry.go', 'internal/payments/retry_test.go'],
|
|
565
|
+
threads: [
|
|
566
|
+
thread(api, merged[1].key, 'cap', {
|
|
567
|
+
file: 'internal/payments/retry.go',
|
|
568
|
+
line: 'd := 30 * time.Second << min(max(attempts-1, 0), 7)',
|
|
569
|
+
resolved: true,
|
|
570
|
+
comments: [
|
|
571
|
+
{ at: at('2026-07-30 10:12'), body: "Does this overflow for large `attempts`? A `Duration` shifted left 40 times isn't an hour any more, it's negative." },
|
|
572
|
+
{ by: 'claude', at: at('2026-07-30 10:20'), body: 'It would have, from about 40. `DueDeliveries` never lists a delivery past `MaxAttempts` (8), but `Backoff` shouldn\'t rely on its caller, so the shift is now clamped at 7 (an hour is reached at 8 anyway), and `TestBackoff` covers 30 attempts.' },
|
|
573
|
+
],
|
|
574
|
+
}),
|
|
575
|
+
],
|
|
576
|
+
});
|
|
577
|
+
return out;
|
|
578
|
+
}
|