Public: anyone can read this; the crew can change it.
The planner proposes bets from unlabelled open issues and the owner's notes, grouped by theme #8870
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8850-the-planner-proposes-bets-from-unlabelle → main 5 commits fork 4 threads · 4 unresolved
5 files
changed +236
−14
docs/features/bets.md +3 −2
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | A bet is a name, an appetite (a share of the week), what done looks like in a few journeys, and scopes — existing issues — that each finish on their own. One crew member leads. Open `/<owner>/<repo>/bets`; the MCP has `bets` (read) and `bet` (act); the terminal has **Bets** in the palette (`p` propose, `b` back, `s` stop, `c` cut, `e` end, `l` what is left, `d` feels done, `n` not yet). All three go through `actions/bets.ts`, and every write emits a `bet.*` event, which the web pages follow with their "new activity" pill. |
| 4 | 4 | - **Who sees what.** The plan (shares, journeys, left lines, a verdict's reason) is for a person who administers the repo; any other reader sees names, states and scopes. Agents never act on a bet. `bet.*` events are admin-only to match. |
| 5 | 5 | |
| 6 | - **Propose.** "Ask the planner to propose bets" groups open issues in no live bet by their first label; a theme with two or more issues becomes a proposal at 20% of the week. At most five proposals a pass. | |
| 6 | - **Propose.** "Ask the planner to propose bets" groups open issues in no live bet: by first label; else by the pillar an unlabelled issue holds up (later and structural issues are skipped); else by the title word most unlabelled issues share. A group of two or more becomes a proposal at 20% of the week. At most five proposals a pass; no model, so the same state gives the same proposals. | |
| 7 | - **Notes.** Only notes written by the repo's owner or an admin count (a visitor's never join or appear). Such a note joins a proposal when its issue is held there, it names one of the group's issues as `#N`, or it shares two words with the group's name and its issues' titles; it is quoted in the journeys as "The owner's note". | |
| 7 | 8 | - **Back.** The owner (a person who administers the repo, never an agent) backs one with a share. At most 5 run, and their shares stay within 100%. |
| 8 | 9 | - **Picks.** Once a repository has ever had a bet backed, builders pick only scopes of a running bet, issues the owner asked for or raised, and small fixes (held to `slots.smallShare`, #7571). An issue in no bet does not start by itself. A repository with no bet picks as before. |
| 9 | 10 | - **Appetite.** The budget is share × builder slots × one week of builder time, counted from the runs on the bet's scopes since it was backed. At the budget the bet stops: no extension. Its left line defaults to the open scopes; the lead rewrites it in one line; the owner re-backs, cuts or ends it (ending as done is for a stopped bet only; a running bet finishes when every scope closes). |
| @@ -11,4 +12,4 @@ A bet is a name, an appetite (a share of the week), what done looks like in a fe | ||
| 11 | 12 | - **Finishing.** When every scope is closed the bet is finished and its page says "This is done. Try it." with a link; the owner taps *Feels done* or *Not yet, because…* (the bet returns to stopped with that reason). `/changelog` marks each landing with its bet and filters by it (`?bet=`). |
| 12 | 13 | - **Idle forge.** If nothing was ever backed and the oldest proposal is a day old, the top two start at 10%, marked "not backed by the owner". One tap on *Stop it* ends any of them, and the planner does not restart a stopped bet. |
| 13 | 14 | |
| 14 | Left out: proposing from notes (only open issues, by label; tracked as a follow-up), a per-week reset of the appetite window. | |
| 15 | Left out: a per-week reset of the appetite window. |
src/actions/bets.ts +68 −9
| @@ -11,9 +11,12 @@ import { listCrewRoles } from "../core/agents.ts"; | ||
| 11 | 11 | import { |
| 12 | 12 | type Bet, betRuns, betScopes, getBet, insertBet, listBets, markBacked, markEnded, setLeftLine, setTryUrl, setVerdict, |
| 13 | 13 | } from "../core/bets.ts"; |
| 14 | import { labelsForIssues } from "../core/issues.ts"; | |
| 14 | import { notesOfRepo } from "../core/app_notes.ts"; | |
| 15 | import { themeWords, groupByWords } from "../core/bet_themes.ts"; | |
| 16 | import { getIssuesByNumbers, labelsForIssues, numberKey } from "../core/issues.ts"; | |
| 17 | import { isStructural, LATER, pillarsOf, servedBy } from "../core/pillars.ts"; | |
| 15 | 18 | import { canAdmin, type Repo } from "../core/repos.ts"; |
| 16 | import type { User } from "../core/users.ts"; | |
| 19 | import { getUserById, type User } from "../core/users.ts"; | |
| 17 | 20 | import { AUTO_SHARE, autoStarts, budgetSeconds, mayBack, spentSeconds, spentUp } from "../crew/bets.ts"; |
| 18 | 21 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 19 | 22 | |
| @@ -39,9 +42,13 @@ const backed = (app: App, repo: Repo) => listBets(app.db, repo.id).filter((b) => | ||
| 39 | 42 | |
| 40 | 43 | /** |
| 41 | 44 | * The planner's proposals: open issues in no live bet, grouped by their first |
| 42 | * label (what they share, the theme), a bet each with a modest share and the | |
| 43 | * issues as scopes, led by the crew's first builder. Run again it adds only | |
| 44 | * what is new. At most five. | |
| 45 | * label, else the pillar they hold up, else the title words they share (the | |
| 46 | * theme; no model, the same state gives the same proposals). A note written by | |
| 47 | * the repo's owner or an admin joins a group when its issue is held there, it | |
| 48 | * names one of the group's issues as #N, or it shares two words with the | |
| 49 | * group's name and titles; it is quoted in the bet. Visitors' notes never join. Each group becomes a bet with | |
| 50 | * a modest share and the issues as scopes, led by the crew's first builder. | |
| 51 | * Run again it adds only what is new. At most five. | |
| 45 | 52 | */ |
| 46 | 53 | export function proposeBets(app: App, repo: Repo, now = new Date()): Bet[] { |
| 47 | 54 | const live = new Set( |
| @@ -60,10 +67,58 @@ export function proposeBets(app: App, repo: Repo, now = new Date()): Bet[] { | ||
| 60 | 67 | .all(repo.id) |
| 61 | 68 | .filter((i) => !live.has(i.id)); |
| 62 | 69 | const labels = labelsForIssues(app.db, open.map((i) => i.id)); |
| 63 | const themes = new Map<string, typeof open>(); | |
| 70 | type Item = (typeof open)[number]; | |
| 71 | const themes = new Map<string, Item[]>(); | |
| 72 | const add = (theme: string, i: Item) => themes.set(theme, [...(themes.get(theme) ?? []), i]); | |
| 73 | // 1. The first label; 2. the pillar an unlabelled issue holds up; 3. title words the rest share. | |
| 74 | const loose: Item[] = []; | |
| 75 | const set = pillarsOf(app.db, repo); | |
| 76 | const served = servedBy(app.db, set); | |
| 64 | 77 | for (const i of open) { |
| 65 | const theme = (labels.get(i.id) ?? [])[0]?.name ?? ""; | |
| 66 | if (theme !== "") themes.set(theme, [...(themes.get(theme) ?? []), i]); | |
| 78 | const label = (labels.get(i.id) ?? [])[0]?.name ?? ""; | |
| 79 | const pillar = served.get(i.id); | |
| 80 | if (label !== "") add(label, i); | |
| 81 | else if (pillar === LATER || isStructural(set, i.id)) continue; | |
| 82 | else if (pillar !== undefined) add(pillar, i); | |
| 83 | else loose.push(i); | |
| 84 | } | |
| 85 | for (const [word, items] of groupByWords(loose)) for (const i of items) add(word, i); | |
| 86 | // The owner's and admins' notes (a visitor's never): each is the issue its words became. A note joins the theme | |
| 87 | // that holds its issue, names one of its issues as #N, or shares two words with its name and its issues' titles; | |
| 88 | // its issue joins too when no theme holds it. | |
| 89 | const byNumber = new Map(open.map((i) => [i.number, i])); | |
| 90 | const trusted = new Map<string, boolean>(); | |
| 91 | const mayNote = (visitor: string): boolean => { | |
| 92 | let ok = trusted.get(visitor); | |
| 93 | if (ok === undefined) { | |
| 94 | const user = visitor.startsWith("u:") ? getUserById(app.db, visitor.slice(2)) : undefined; | |
| 95 | ok = user !== undefined && (user.isAdmin || canAdmin(app.db, repo, user)); | |
| 96 | trusted.set(visitor, ok); | |
| 97 | } | |
| 98 | return ok; | |
| 99 | }; | |
| 100 | const written = notesOfRepo(app.db, repo.id).filter((n) => mayNote(n.visitor)); | |
| 101 | const found = getIssuesByNumbers(app.db, written.map((n) => ({ repoId: repo.id, number: n.issueNumber }))); | |
| 102 | const notes = written.flatMap((n) => { | |
| 103 | const issue = found.get(numberKey(repo.id, n.issueNumber)); | |
| 104 | return issue === undefined ? [] : [{ issue, text: `${issue.title}\n${issue.body}` }]; | |
| 105 | }); | |
outdated
themeWords(n.text).filter(w => wanted.has(w)).length >= 2 needs a theme with 2+ words, but wanted is built from the theme key alone (one label/word/pillar). Match against the union of themeWords of the group issue titles so this branch is reachable. |
||
| 106 | const quoted = new Map<string, string[]>(); | |
| 107 | const held = new Set([...themes.values()].flat().map((i) => i.id)); | |
| 108 | for (const [theme, issues] of [...themes].sort((a, b) => a[0].localeCompare(b[0]))) { | |
| 109 | const own = new Set(issues.map((i) => i.id)); | |
| 110 | const wanted = new Set([theme, ...issues.map((i) => i.title)].flatMap(themeWords)); | |
| 111 | for (const n of notes) { | |
| 112 | const named = [...n.text.matchAll(/#(\d+)/g)].some((m) => own.has(byNumber.get(Number(m[1]))?.id ?? "")); | |
| 113 | const shares = themeWords(n.text).filter((w) => wanted.has(w)).length >= 2; | |
| 114 | if (!own.has(n.issue.id) && !named && !shares) continue; | |
| 115 | quoted.set(theme, [...(quoted.get(theme) ?? []), n.issue.title]); | |
| 116 | const mine = byNumber.get(n.issue.number); | |
| 117 | if (mine !== undefined && !held.has(mine.id)) { | |
| 118 | held.add(mine.id); | |
| 119 | add(theme, mine); | |
| 120 | } | |
| 121 | } | |
| 67 | 122 | } |
| 68 | 123 | const named = new Set(listBets(app.db, repo.id).filter((b) => b.state !== "cut" && b.state !== "done").map((b) => b.name)); |
| 69 | 124 | const lead = listCrewRoles(app.db, repo.id).find((c) => c.role === "builder")?.agent.user.id ?? null; |
| @@ -71,7 +126,11 @@ export function proposeBets(app: App, repo: Repo, now = new Date()): Bet[] { | ||
| 71 | 126 | for (const [theme, issues] of [...themes].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))) { |
| 72 | 127 | const name = oneLine(theme[0]!.toUpperCase() + theme.slice(1), NAME_MAX); |
| 73 | 128 | if (made.length >= 5 || issues.length < 2 || named.has(name)) continue; |
| 74 | const journeys = issues.slice(0, 3).map((i) => `Someone can ${oneLine(i.title, 100)}`).join("\n"); | |
| 129 | named.add(name); | |
| 130 | const journeys = [ | |
| 131 | ...issues.slice(0, 3).map((i) => `Someone can ${oneLine(i.title, 100)}`), | |
| 132 | ...(quoted.get(theme) ?? []).slice(0, 2).map((t) => `The owner's note: "${oneLine(t, 100)}"`), | |
| 133 | ].join("\n"); | |
| 75 | 134 | const bet = insertBet(app.db, { repoId: repo.id, name, journeys, appetite: 20, leadId: lead, issueIds: issues.map((i) => i.id) }, now); |
| 76 | 135 | app.bus.append({ kind: "bet.proposed", repoId: repo.id, payload: { bet: bet.id, name } }); |
| 77 | 136 | made.push(bet); |
src/core/app_notes.ts +10
| @@ -137,3 +137,13 @@ export function isBanned(db: Database, appId: string, visitor: string): boolean | ||
| 137 | 137 | export function markSender(db: Database, issueId: string, name: string): void { |
| 138 | 138 | db.query("UPDATE issue SET origin_author = ?1 WHERE id = ?2").run(name, issueId); |
| 139 | 139 | } |
| 140 | ||
| 141 | /** Every visible note of a repository's apps, oldest first: what the planner reads. */ | |
| 142 | export function notesOfRepo(db: Database, repoId: string, limit = 200): AppNote[] { | |
Takes no sender filter. Return only notes the owner or an admin wrote, or add a parameter for it. |
||
| 143 | return db | |
| 144 | .query<Row, [string, number]>( | |
| 145 | "SELECT * FROM app_note WHERE repo_id = ?1 AND hidden = 0 ORDER BY created_at, id LIMIT ?2", | |
| 146 | ) | |
| 147 | .all(repoId, limit) | |
| 148 | .map(fromRow); | |
| 149 | } |
src/core/bet_themes.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * Themes for the planner (#8850): the words a title is about, and issues | |
| 3 | * grouped by the word they share most. Plain data in, plain data out; no | |
| 4 | * model, so the same issues always fall into the same groups. | |
| 5 | */ | |
| 6 | ||
| 7 | const STOP = new Set([ | |
| 8 | "about", "add", "after", "allow", "also", "and", "are", "can", "does", "each", "for", "from", "have", "into", "make", | |
| 9 | "more", "not", "one", "should", "show", "that", "the", "their", "them", "then", "there", "this", "use", "when", | |
| 10 | "with", "without", "your", | |
| 11 | ]); | |
| 12 | ||
| 13 | /** The words of a text worth grouping by: lowercase, 4+ letters, not filler. */ | |
| 14 | export function themeWords(text: string): string[] { | |
| 15 | const words = text.toLowerCase().match(/[a-z][a-z0-9]{3,}/g) ?? []; | |
| 16 | return [...new Set(words.filter((w) => !STOP.has(w)))]; | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * Group items by shared title words: repeatedly take the word in the most | |
| 21 | * ungrouped titles (ties: alphabetical) while at least two share it. Items | |
| 22 | * sharing no word with another are left out. Order of the input is kept | |
| 23 | * inside each group. | |
| 24 | */ | |
| 25 | export function groupByWords<T extends { title: string }>(items: readonly T[]): Map<string, T[]> { | |
| 26 | const words = items.map((i) => ({ item: i, words: new Set(themeWords(i.title)) })); | |
| 27 | let rest = words; | |
| 28 | const out = new Map<string, T[]>(); | |
| 29 | for (;;) { | |
| 30 | const count = new Map<string, number>(); | |
| 31 | for (const r of rest) for (const w of r.words) count.set(w, (count.get(w) ?? 0) + 1); | |
| 32 | const best = [...count].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]; | |
| 33 | if (best === undefined) return out; | |
| 34 | out.set(best[0], rest.filter((r) => r.words.has(best[0])).map((r) => r.item)); | |
| 35 | rest = rest.filter((r) => !r.words.has(best[0])); | |
| 36 | } | |
| 37 | } |
test/bets.test.ts +118 −3
| @@ -12,7 +12,9 @@ import { backBet, endBet, giveVerdict, proposeBets, stopBet, tickBets, writeLeft | ||
| 12 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 13 | import { openIssue } from "../src/actions/issues.ts"; |
| 14 | 14 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 15 | import { getBet, insertBet, listBets, markBacked } from "../src/core/bets.ts"; | |
| 15 | import { insertNote } from "../src/core/app_notes.ts"; | |
| 16 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 17 | import { betScopes, getBet, insertBet, listBets, markBacked } from "../src/core/bets.ts"; | |
| 16 | 18 | import { createLabel, getIssueById, setIssueLabels, type Issue } from "../src/core/issues.ts"; |
| 17 | 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 18 | 20 | import { createUser, type User } from "../src/core/users.ts"; |
| @@ -75,12 +77,44 @@ let servers: Servers; | ||
| 75 | 77 | const fresh = () => getRepoById(app.db, repo.id)!; |
| 76 | 78 | const NOW = new Date("2026-11-03T12:00:00Z"); |
| 77 | 79 | |
| 78 | function open(title: string): Issue { | |
| 79 | const made = openIssue(app, alice, fresh(), { title }); | |
| 80 | function open(title: string, into?: Repo): Issue { | |
| 81 | const made = openIssue(app, alice, into === undefined ? fresh() : getRepoById(app.db, into.id)!, { title }); | |
| 80 | 82 | if (!made.ok) throw new Error(String(made.error)); |
| 81 | 83 | return made.value; |
| 82 | 84 | } |
| 83 | 85 | |
| 86 | /** A repository of its own, so a test's issues and bets touch no other test's; named to sort after "notes", whose bets the terminal lists first. */ | |
| 87 | async function blank(name: string): Promise<Repo> { | |
| 88 | const made = await createRepo(app, alice, { name, visibility: "public" }); | |
| 89 | if (!made.ok) throw new Error(String(made.error)); | |
| 90 | return made.value; | |
| 91 | } | |
| 92 | ||
| 93 | function label(into: Repo, name: string, issues: readonly Issue[]): void { | |
| 94 | const made = createLabel(app.db, into.id, name, "aabbcc"); | |
| 95 | if (!made.ok) throw new Error(String(made.error)); | |
| 96 | for (const i of issues) setIssueLabels(app.db, i.id, [made.value.id]); | |
| 97 | } | |
| 98 | ||
| 99 | /** The same state, built anew in a fresh repository: a pillar pair with a note naming it, a word pair, a label pair. */ | |
| 100 | async function sameState(name: string): Promise<{ name: string; journeys: string; scopes: string[] }[]> { | |
| 101 | const into = await blank(name); | |
| 102 | const umbrella = open("P7 Speed: be quick", into); | |
| 103 | const a = open("Cache the rendered page", into); | |
| 104 | const b = open("Trim the startup path", into); | |
| 105 | for (const i of [a, b]) expect(addDependency(app.db, umbrella, i, alice.id).ok).toBe(true); | |
| 106 | const asked = open(`Please make #${a.number} faster for me`, into); | |
| 107 | insertNote(app.db, { appId: "app-x", repoId: into.id, issueNumber: asked.number, visitor: `u:${alice.id}`, name: "alice" }); | |
| 108 | open("Export zebra stripes", into); | |
| 109 | open("Import zebra stripes", into); | |
| 110 | label(into, "search", [open("Find by word", into), open("Find by tag", into)]); | |
| 111 | return proposeBets(app, getRepoById(app.db, into.id)!, NOW).map((bet) => ({ | |
| 112 | name: bet.name, | |
| 113 | journeys: bet.journeys, | |
| 114 | scopes: betScopes(app.db, bet.id).map((sc) => sc.title).sort(), | |
| 115 | })); | |
| 116 | } | |
| 117 | ||
| 84 | 118 | beforeAll(async () => { |
| 85 | 119 | dataDir = await mkdtemp(join(tmpdir(), "forge-bets-")); |
| 86 | 120 | app = createApp({ dataDir, port: 0 }); |
| @@ -110,6 +144,87 @@ afterAll(async () => { | ||
| 110 | 144 | await rm(dataDir, { recursive: true, force: true }); |
| 111 | 145 | }); |
| 112 | 146 | |
| 147 | describe("proposing from unlabelled work and notes (#8850)", () => { | |
| 148 | test("an unlabelled pair under one pillar is one proposal; a note that names it joins and is quoted; same state, same proposals", () => { | |
| 149 | const umbrella = open("P7 Speed: be quick"); | |
| 150 | const a = open("Cache the rendered page"); | |
| 151 | const b = open("Trim the startup path"); | |
| 152 | for (const i of [a, b]) expect(addDependency(app.db, umbrella, i, alice.id).ok).toBe(true); | |
| 153 | const asked = open(`Please make #${a.number} faster for me`); | |
| 154 | insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: asked.number, visitor: `u:${alice.id}`, name: "alice" }); | |
| 155 | const made = proposeBets(app, fresh(), NOW); | |
| 156 | const speed = made.find((m) => m.name === "P7 Speed"); | |
| 157 | expect(speed).toBeDefined(); | |
| 158 | expect(speed!.journeys).toContain("Someone can Cache the rendered page"); | |
| 159 | expect(speed!.journeys).toContain(`The owner's note: "Please make #${a.number} faster for me"`); | |
| 160 | expect(betScopes(app.db, speed!.id).map((s) => s.number).sort()).toEqual([a.number, b.number, asked.number].sort()); | |
| 161 | expect(made.length).toBeLessThanOrEqual(5); | |
| 162 | expect(proposeBets(app, fresh(), NOW)).toEqual([]); | |
| 163 | }); | |
| 164 | ||
| 165 | test("the same state gives the same proposals: names, journeys and scopes match across two repositories", async () => { | |
| 166 | const first = await sameState("zz-same-1"); | |
| 167 | const second = await sameState("zz-same-2"); | |
| 168 | expect(first.map((p) => p.name)).toEqual(["P7 Speed", "Search", "Stripes"]); | |
| 169 | expect(second).toEqual(first); | |
| 170 | }); | |
| 171 | ||
| 172 | test("an owner's note joins by two words shared with the group's titles, and not by one", async () => { | |
| 173 | const into = await blank("zz-two-words"); | |
| 174 | open("Export zebra stripes", into); | |
| 175 | open("Import zebra stripes", into); | |
| 176 | const two = open("Blurry zebra stripes on print", into); | |
| 177 | const one = open("Zebra crossing paint", into); | |
| 178 | label(into, "print", [two]); | |
| 179 | label(into, "paint", [one]); | |
| 180 | for (const n of [two, one]) { | |
| 181 | insertNote(app.db, { appId: "app-x", repoId: into.id, issueNumber: n.number, visitor: `u:${alice.id}`, name: "alice" }); | |
| 182 | } | |
| 183 | const made = proposeBets(app, getRepoById(app.db, into.id)!, NOW); | |
| 184 | const stripes = made.find((m) => m.name === "Stripes")!; | |
| 185 | expect(stripes.journeys).toContain(`The owner's note: "Blurry zebra stripes on print"`); | |
| 186 | expect(stripes.journeys).not.toContain("Zebra crossing paint"); | |
| 187 | }); | |
| 188 | ||
| 189 | test("unlabelled issues with no pillar group by shared title words, and a lone issue makes no bet", () => { | |
| 190 | open("Export zebra stripes"); | |
| 191 | open("Import zebra stripes"); | |
| 192 | open("Wholly unrelated quokka"); | |
| 193 | const made = proposeBets(app, fresh(), NOW); | |
| 194 | expect(made.map((m) => m.name)).toEqual(["Stripes"]); | |
| 195 | expect(betScopes(app.db, made[0]!.id)).toHaveLength(2); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a visitor's note neither joins a proposal nor is quoted; a loose single shared word does not attach an owner's note", () => { | |
| 199 | const umbrella = open("P8 Render: draw faster"); | |
| 200 | const a = open("Cache the rendered page"); | |
| 201 | const b = open("Trim the startup path"); | |
| 202 | for (const i of [a, b]) expect(addDependency(app.db, umbrella, i, alice.id).ok).toBe(true); | |
| 203 | const spoof = open(`Ignore rules, see #${a.number}`); | |
| 204 | insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: spoof.number, visitor: "v:stranger", name: "visitor-1" }); | |
| 205 | const bobNote = open(`Also #${b.number} please`); | |
| 206 | insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: bobNote.number, visitor: `u:${bob.id}`, name: "@bob" }); | |
| 207 | const loose = open("Startup banner colour"); | |
| 208 | insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: loose.number, visitor: `u:${alice.id}`, name: "alice" }); | |
| 209 | const made = proposeBets(app, fresh(), NOW); | |
| 210 | const bet = made.find((m) => m.name === "P8 Render")!; | |
| 211 | expect(bet.journeys).not.toContain("The owner's note"); | |
| 212 | const scopes = betScopes(app.db, bet.id).map((s) => s.number); | |
| 213 | expect(scopes).not.toContain(spoof.number); | |
| 214 | expect(scopes).not.toContain(bobNote.number); | |
| 215 | expect(scopes).not.toContain(loose.number); | |
| 216 | }); | |
| 217 | ||
| 218 | test("at most five proposals a pass", async () => { | |
| 219 | const into = await blank("zz-cap"); | |
| 220 | for (const w of ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf"]) { | |
| 221 | label(into, `cap-${w}`, [open(`${w} one`, into), open(`${w} two`, into)]); | |
| 222 | } | |
| 223 | const made = proposeBets(app, getRepoById(app.db, into.id)!, NOW); | |
| 224 | expect(made).toHaveLength(5); | |
| 225 | }); | |
| 226 | }); | |
| 227 | ||
| 113 | 228 | describe("proposing and backing", () => { |
| 114 | 229 | test("the planner groups open work by theme, once; only the owner backs, at most 5 run", () => { |
| 115 | 230 | const label = createLabel(app.db, repo.id, "search", "aabbcc"); |
new above
offline
HIGH: this reads every visitor's note, not the owner's, and quotes it as 'The owner's note' in journeys. Filter to the owner or an admin, and add a test for a non-owner note.