Public: anyone can read this; the crew can change it.
A from-app issue is assigned on arrival, and the flow alarm fires when one waits #10612
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10563-a-from-app-issue-is-assigned-on-arrival → main 3 commits fork 3 threads · 3 unresolved
7 files
changed +300
−5
docs/features/from-app-alarm.md added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | # The from_app alarm (#10563) | |
| 2 | ||
| 3 | A note a signed-in person files from an app becomes a `from-app` issue, assigned on arrival to a free builder of the repo's crew. With every builder busy, or the assignment refused, it stays unassigned. A stranger's note is only a suggestion and is never counted here; hidden notes are left out. | |
| 4 | ||
| 5 | ## When the alarm fires | |
| 6 | ||
| 7 | The `from_app` flow alarm (`src/flow/alarm_from_app.ts`) fires when an open person's note has: | |
| 8 | ||
| 9 | - sat **unassigned for 15 minutes**, or | |
| 10 | - had **no crew comment for 1 hour** (counted from arrival until the crew first speaks, then from the last crew comment). | |
| 11 | ||
| 12 | It names the issue that has waited longest past its bound. Both doors show it: the Home and `/boards` pages, and the TUI flow screen, which draws every alarm `firedAlarms` returns. `GET /api/v1/flow` lists it too. | |
| 13 | ||
| 14 | ## The planner hold | |
| 15 | ||
| 16 | While a person's note in a repository has had no crew comment, that repository's **planner** (the agent with the planner seat) is refused `openPlan` with `from_app_waiting`, naming the issue. Builders, reviewers and people are not held. | |
| 17 | ||
| 18 | The hold lifts as soon as any crew member comments on the note, or the issue is closed, or the note is hidden. A builder that never comments keeps it held; the alarm fires at the hour mark so a person sees it and can comment, reassign or close the issue. |
src/actions/app_notes.ts +20 −3
| @@ -9,18 +9,19 @@ import { dropHiddenPictures } from "../core/note_pictures.ts"; | ||
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | 10 | import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts"; |
| 11 | 11 | import type { AppRecord } from "../core/apps.ts"; |
| 12 | import { createLabel, getIssue, getLabel } from "../core/issues.ts"; | |
| 12 | import { createLabel, getIssue, getLabel, type Issue } from "../core/issues.ts"; | |
| 13 | 13 | import { mergedPullClosing } from "../core/pulls.ts"; |
| 14 | 14 | import { releaseName, releaseOfPull } from "../release/record.ts"; |
| 15 | 15 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 16 | 16 | import { canAdmin, type Repo } from "../core/repos.ts"; |
| 17 | 17 | import { plainText } from "../core/reports.ts"; |
| 18 | import { workingAgentRuns } from "../core/runs.ts"; | |
| 18 | import { listActiveRuns, workingAgentRuns } from "../core/runs.ts"; | |
| 19 | 19 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| 20 | import { crewWithRole } from "../core/agents.ts"; | |
| 20 | 21 | import { unadoptedAmong } from "../crew/direct.ts"; |
| 21 | 22 | import { NOTE_EMPTY } from "../apps/note_words.ts"; |
| 22 | 23 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 23 | import { openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "./issues.ts"; | |
| 24 | import { assignIssue, openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "./issues.ts"; | |
| 24 | 25 | |
| 25 | 26 | export { FROM_APP_LABEL } from "../core/from_app.ts"; |
| 26 | 27 | import { FROM_APP_LABEL } from "../core/from_app.ts"; |
| @@ -241,9 +242,25 @@ export async function sendNote( | ||
| 241 | 242 | visitor: sender.key, |
| 242 | 243 | name: sender.name, |
| 243 | 244 | }); |
| 245 | if (sender.person) assignOnArrival(app, owner, repo, opened.value); | |
| 244 | 246 | return ok(view(app, record, repo, forgeUrl, note)!); |
| 245 | 247 | } |
| 246 | 248 | |
| 249 | /** | |
| 250 | * A person's note goes to a builder when it is filed (#10563): the first of | |
| 251 | * the repo's builders with no run going, in name order, so it does not wait | |
| 252 | * for the planner. With every builder busy, or a refusal (an unverified owner, | |
| 253 | * an archived repo), it stays unassigned and the flow alarm names it at 15 | |
| 254 | * minutes. | |
| 255 | */ | |
| 256 | function assignOnArrival(app: App, owner: User, repo: Repo, issue: Issue): void { | |
| 257 | const busy = new Set(listActiveRuns(app.db, [repo.id]).flatMap((r) => (r.actorId === null ? [] : [r.actorId]))); | |
| 258 | const free = (crewWithRole(app.db, [repo.id], "builder").get(repo.id) ?? []).find((a) => !busy.has(a.user.id)); | |
| 259 | if (free === undefined) return; | |
| 260 | const done = assignIssue(app, owner, repo, issue, free.user); | |
| 261 | if (!done.ok) console.error(`app note: issue #${issue.number} could not be assigned to ${free.user.username}: ${done.error}`); | |
| 262 | } | |
| 263 | ||
| 247 | 264 | /** The owner's: hide a note (closing its issue), or ban its sender too. */ |
| 248 | 265 | export async function moderateNote( |
| 249 | 266 | app: App, |
src/actions/plan.ts +13 −1
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | |
| 13 | 13 | import { sealedScope } from "../core/sealed.ts"; |
| 14 | 14 | import type { App } from "../app.ts"; |
| 15 | import { isCrew } from "../core/agents.ts"; | |
| 15 | import { crewRole, isCrew } from "../core/agents.ts"; | |
| 16 | 16 | import { mapHeldFor, type HoldName } from "../crew/held.ts"; |
| 17 | 17 | import { addDependency, repoGraph, readyNow } from "../core/dependencies.ts"; |
| 18 | 18 | import { findPillar, pillarsOf, type Pillar } from "../core/pillars.ts"; |
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | MAX_TITLE, |
| 24 | 24 | type Issue, |
| 25 | 25 | } from "../core/issues.ts"; |
| 26 | import { unansweredFromApp } from "../core/from_app.ts"; | |
| 26 | 27 | import { canRead, canWrite, type Repo } from "../core/repos.ts"; |
| 27 | 28 | import type { User } from "../core/users.ts"; |
| 28 | 29 | import { transact } from "../kernel/db.ts"; |
| @@ -227,6 +228,17 @@ function vet( | ||
| 227 | 228 | "this repository is mirrored; plan at its origin", |
| 228 | 229 | ); |
| 229 | 230 | |
| 231 | // A person's note from an app is owed an answer before the planner adds to the pile (#10563). | |
| 232 | // Only the repo's planner is held; it lifts when a crew member comments, or the issue is closed or hidden. | |
| 233 | if (actor.kind === "agent" && crewRole(app.db, repo.id, actor.id) === "planner") { | |
outdated
Holds all agents, not just the planner; narrow it or document it, and test that a person's plan is not held. |
||
| 234 | const waiting = unansweredFromApp(app.db, repo.id); | |
| 235 | if (waiting !== undefined) | |
| 236 | return refuse( | |
| 237 | "from_app_waiting", | |
| 238 | `#${waiting.number} was sent by a person from the app and no crew member has answered it: the planner opens no new issues until one does`, | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 230 | 242 | const keys = new Set(items.map((i) => i.key)); |
| 231 | 243 | const existing = new Map<number, Issue>(); |
| 232 | 244 | for (const item of items) |
src/core/from_app.ts +54
| @@ -23,3 +23,57 @@ export function fromAppIssues( | ||
| 23 | 23 | out.add(id); |
| 24 | 24 | return out; |
| 25 | 25 | } |
| 26 | ||
| 27 | /** A `from-app` issue a person with an account sent that is still open. */ | |
| 28 | export interface WaitingFromApp { | |
| 29 | readonly repoId: string; | |
| 30 | /** `owner/name`. */ | |
| 31 | readonly repo: string; | |
| 32 | readonly number: number; | |
| 33 | readonly createdAt: string; | |
| 34 | readonly assigned: boolean; | |
| 35 | /** The newest comment by a crew member, or null when none has spoken. */ | |
| 36 | readonly lastCrewAt: string | null; | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * The open `from-app` issues sent by a person with an account (the note's | |
| 41 | * sender key starts `u:`), oldest first, hidden notes left out. A stranger's | |
| 42 | * note stays a suggestion until a writer adopts it (`crew/direct.ts`); only a | |
| 43 | * person's is owed an answer. `repoId` narrows to one repository. | |
| 44 | */ | |
| 45 | export function waitingFromApp(db: Database, repoId?: string): WaitingFromApp[] { | |
| 46 | const rows = db | |
| 47 | .query< | |
| 48 | { repo_id: string; repo: string; number: number; created_at: string; assignee_id: string | null; last_crew: string | null }, | |
| 49 | [string | null, string] | |
| 50 | >( | |
| 51 | `SELECT i.repo_id, o.username || '/' || r.name AS repo, i.number, i.created_at, i.assignee_id, | |
| 52 | (SELECT MAX(c.created_at) FROM comment c JOIN user u ON u.id = c.author_id | |
| 53 | WHERE c.issue_id = i.id AND u.is_agent = 1) AS last_crew | |
| 54 | FROM issue i | |
| 55 | JOIN repo r ON r.id = i.repo_id | |
| 56 | JOIN user o ON o.id = r.owner_id | |
| 57 | JOIN app_note n ON n.repo_id = i.repo_id AND n.issue_number = i.number | |
| 58 | JOIN issue_label il ON il.issue_id = i.id | |
| 59 | JOIN label l ON l.id = il.label_id AND l.name = ?2 COLLATE NOCASE | |
| 60 | WHERE i.kind = 'issue' AND i.state = 'open' AND n.hidden = 0 AND n.visitor LIKE 'u:%' | |
| 61 | AND (?1 IS NULL OR i.repo_id = ?1) | |
Do not splice the label constant into SQL; bind it as a parameter. |
||
| 62 | GROUP BY i.id | |
| 63 | ORDER BY i.created_at, i.id`, | |
| 64 | ) | |
| 65 | .all(repoId ?? null, FROM_APP_LABEL); | |
| 66 | return rows.map((r) => ({ | |
| 67 | repoId: r.repo_id, | |
| 68 | repo: r.repo, | |
| 69 | number: r.number, | |
| 70 | createdAt: r.created_at, | |
| 71 | assigned: r.assignee_id !== null, | |
| 72 | lastCrewAt: r.last_crew, | |
| 73 | })); | |
| 74 | } | |
| 75 | ||
| 76 | /** Of a repository's waiting `from-app` issues, the oldest no crew member has answered; none when all were. */ | |
| 77 | export function unansweredFromApp(db: Database, repoId: string): WaitingFromApp | undefined { | |
| 78 | return waitingFromApp(db, repoId).find((w) => w.lastCrewAt === null); | |
| 79 | } |
src/flow/alarm_from_app.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * The "person waiting" flow alarm (#10563): an issue a person sent from an | |
| 3 | * app that has sat unassigned for 15 minutes, or has had no crew comment for | |
| 4 | * an hour (counted from its arrival until the crew first speaks). It names | |
| 5 | * the issue, the one that has waited longest past its bound. Home and | |
| 6 | * /boards draw it. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { waitingFromApp } from "../core/from_app.ts"; | |
| 11 | ||
| 12 | const MINUTE_MS = 60_000; | |
| 13 | export const UNASSIGNED_AFTER_MS = 15 * MINUTE_MS; | |
| 14 | export const UNANSWERED_AFTER_MS = 60 * MINUTE_MS; | |
| 15 | ||
| 16 | export interface FromAppAlarm { | |
| 17 | readonly fired: boolean; | |
| 18 | readonly since?: string; | |
| 19 | readonly repo?: string; | |
| 20 | readonly number?: number; | |
| 21 | readonly says: string; | |
| 22 | } | |
| 23 | ||
| 24 | export function alarmFromApp(db: Database, now: Date): FromAppAlarm { | |
| 25 | let worst: { since: number; repo: string; number: number; why: string } | undefined; | |
| 26 | for (const w of waitingFromApp(db)) { | |
| 27 | const born = Date.parse(w.createdAt); | |
| 28 | const spoke = w.lastCrewAt === null ? born : Date.parse(w.lastCrewAt); | |
| 29 | const breaches: { since: number; why: string }[] = []; | |
| 30 | if (!w.assigned && now.getTime() - born >= UNASSIGNED_AFTER_MS) | |
| 31 | breaches.push({ since: born + UNASSIGNED_AFTER_MS, why: "sat unassigned for 15 minutes" }); | |
| 32 | if (now.getTime() - spoke >= UNANSWERED_AFTER_MS) | |
| 33 | breaches.push({ since: spoke + UNANSWERED_AFTER_MS, why: "had no crew comment for an hour" }); | |
| 34 | const first = breaches.sort((a, b) => a.since - b.since)[0]; | |
| 35 | if (first !== undefined && (worst === undefined || first.since < worst.since)) | |
| 36 | worst = { ...first, repo: w.repo, number: w.number }; | |
| 37 | } | |
| 38 | if (worst === undefined) return { fired: false, says: "No person's note from an app is waiting." }; | |
| 39 | return { | |
| 40 | fired: true, | |
| 41 | since: new Date(worst.since).toISOString(), | |
| 42 | repo: worst.repo, | |
| 43 | number: worst.number, | |
| 44 | says: `${worst.repo}#${worst.number}, sent by a person from an app, has ${worst.why}.`, | |
| 45 | }; | |
| 46 | } |
src/flow/alarms.ts +4 −1
| @@ -12,6 +12,7 @@ import { redHref, redMain } from "../checks/red_main.ts"; | ||
| 12 | 12 | import { getRepo } from "../core/repos.ts"; |
| 13 | 13 | import { ownRepoPath } from "../kernel/own_repo.ts"; |
| 14 | 14 | import { bugClockAlarm } from "./bugs.ts"; |
| 15 | import { alarmFromApp } from "./alarm_from_app.ts"; | |
| 15 | 16 | import { alarmIdleCrew } from "./alarm_idle_crew.ts"; |
| 16 | 17 | import { noReleaseAlarm } from "./alarm_no_release.ts"; |
| 17 | 18 | import { redMainAlarm } from "./alarm_red_main.ts"; |
| @@ -20,7 +21,7 @@ import { alarmSilent } from "./alarm_silent.ts"; | ||
| 20 | 21 | import { alarmStuck } from "./alarm_stuck.ts"; |
| 21 | 22 | |
| 22 | 23 | export interface FiredAlarm { |
| 23 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "unreachable"; | |
| 24 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable"; | |
| 24 | 25 | readonly says: string; |
| 25 | 26 | readonly since: string; |
| 26 | 27 | /** A same-origin path to the item the alarm names. */ |
| @@ -55,6 +56,8 @@ export function firedAlarms( | ||
| 55 | 56 | if (silent.fired) out.push({ key: "silent", says: silent.says, since: silent.since!, href: `/${silent.repo}/runs/${silent.runId}` }); |
| 56 | 57 | const repeat = alarmRepeat(db); |
| 57 | 58 | if (repeat.fired) out.push({ key: "repeat", says: repeat.says, since: repeat.since!, href: `/${repeat.repo}/issues/${repeat.number}` }); |
| 59 | const fromApp = alarmFromApp(db, now); | |
| 60 | if (fromApp.fired) out.push({ key: "from_app", says: fromApp.says, since: fromApp.since!, href: `/${fromApp.repo}/issues/${fromApp.number}` }); | |
| 58 | 61 | const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null }); |
| 59 | 62 | if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" }); |
| 60 | 63 | } |
test/alarm_from_app.test.ts added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | /** | |
| 2 | * A note from a person is assigned to a builder on arrival, a flow alarm | |
| 3 | * names one that waits, and the planner opens nothing while one is | |
| 4 | * unanswered (#10563). | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { sendNote, type Sender } from "../src/actions/app_notes.ts"; | |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { openPlan } from "../src/actions/plan.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 16 | import { getAppBySlug } from "../src/core/apps.ts"; | |
| 17 | import { hideNote, notesOfApp } from "../src/core/app_notes.ts"; | |
| 18 | import { createRun } from "../src/core/runs.ts"; | |
| 19 | import { addComment, getIssue } from "../src/core/issues.ts"; | |
| 20 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import { alarmFromApp } from "../src/flow/alarm_from_app.ts"; | |
| 23 | import { firedAlarms } from "../src/flow/alarms.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | const input = { text: "the button is too small", page: "", viewport: "390x844" }; | |
| 29 | const MIN = 60_000; | |
| 30 | ||
| 31 | describe("a person's note from an app", () => { | |
| 32 | let dataDir = ""; | |
| 33 | let app: App; | |
| 34 | let owner: User; | |
| 35 | let builder: User; | |
| 36 | let planner: User; | |
| 37 | let repo: Repo; | |
| 38 | let seq = 0; | |
| 39 | const person = (tag = ""): Sender => ({ key: `u:${owner.id}${tag}`, name: owner.username, person: true }); | |
| 40 | const stranger: Sender = { key: "v:abc", name: "visitor-abc", person: false }; | |
| 41 | ||
| 42 | const send = async (who: Sender) => { | |
| 43 | const record = getAppBySlug(app.db, "shop")!; | |
| 44 | const sent = await sendNote(app, record, repo, "production", undefined, who, input, `10.8.0.${++seq}`); | |
| 45 | if (!sent.ok) throw new Error(sent.error.message); | |
| 46 | return app.db.query<{ number: number }, [string]>("SELECT MAX(number) AS number FROM issue WHERE repo_id = ?1").get(repo.id)!.number; | |
| 47 | }; | |
| 48 | const close = (n: number) => app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = ?2").run(repo.id, n); | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-alarm-from-app-")); | |
| 52 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 53 | const mk = async (username: string, agent: boolean) => { | |
| 54 | const made = await createUser(app.db, { | |
| 55 | username, | |
| 56 | email: `${username}@forge.test`, | |
| 57 | ...(agent ? { agent: true, origin: "agent" as const } : { password: "a-good-passphrase", origin: "seed" as const, trusted: true, adminIfFirst: true }), | |
| 58 | }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | if (agent) createAgentRow(app.db, { userId: made.value.id, roles: [username === "plan" ? "planner" : "builder"] }); | |
| 61 | return made.value; | |
| 62 | }; | |
| 63 | owner = await mk("alice", false); | |
| 64 | builder = await mk("build", true); | |
| 65 | planner = await mk("plan", true); | |
| 66 | const made = await newApp(app, owner, { name: "shop", description: "Words", visibility: "public" }); | |
| 67 | if (!made.ok) throw new Error(String(made.error)); | |
| 68 | repo = getRepo(app.db, "alice", "shop")!; | |
| 69 | addCrew(app.db, repo.id, builder.id, owner.id, "builder"); | |
| 70 | addCrew(app.db, repo.id, planner.id, owner.id, "planner"); | |
| 71 | }); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | app.close(); | |
| 75 | await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); | |
| 76 | }); | |
| 77 | ||
| 78 | test("is assigned to a builder when it is filed; a stranger's is not", async () => { | |
| 79 | const n = await send(person()); | |
| 80 | expect(getIssue(app.db, repo.id, n)!.assigneeName).toBe("build"); | |
| 81 | const s = await send(stranger); | |
| 82 | expect(getIssue(app.db, repo.id, s)!.assigneeName).toBeNull(); | |
| 83 | close(n); | |
| 84 | close(s); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the alarm fires at 15 minutes unassigned, or an hour without a crew comment, and names the issue", async () => { | |
| 88 | const n = await send(person()); | |
| 89 | app.db.query("UPDATE issue SET assignee_id = NULL WHERE repo_id = ?1 AND number = ?2").run(repo.id, n); | |
| 90 | const at = Date.now(); | |
| 91 | expect(alarmFromApp(app.db, new Date(at + 14 * MIN)).fired).toBe(false); | |
| 92 | const unassigned = alarmFromApp(app.db, new Date(at + 16 * MIN)); | |
| 93 | expect(unassigned.fired).toBe(true); | |
| 94 | expect(unassigned.says).toContain(`alice/shop#${n}`); | |
| 95 | expect(unassigned.says).toContain("unassigned"); | |
| 96 | expect(firedAlarms(app.db, new Date(at + 16 * MIN)).map((a) => a.key)).toContain("from_app"); | |
| 97 | // Assigned: quiet until the hour. | |
| 98 | app.db.query("UPDATE issue SET assignee_id = ?3 WHERE repo_id = ?1 AND number = ?2").run(repo.id, n, builder.id); | |
| 99 | expect(alarmFromApp(app.db, new Date(at + 59 * MIN)).fired).toBe(false); | |
| 100 | const silent = alarmFromApp(app.db, new Date(at + 61 * MIN)); | |
| 101 | expect(silent.fired).toBe(true); | |
| 102 | expect(silent.says).toContain("no crew comment"); | |
| 103 | // A crew comment restarts the hour. | |
| 104 | addComment(app.db, { issueId: getIssue(app.db, repo.id, n)!.id, authorId: builder.id, body: "on it" }); | |
| 105 | expect(alarmFromApp(app.db, new Date(Date.now() + 30 * MIN)).fired).toBe(false); | |
| 106 | expect(alarmFromApp(app.db, new Date(Date.now() + 61 * MIN)).fired).toBe(true); | |
| 107 | close(n); | |
| 108 | expect(alarmFromApp(app.db, new Date(Date.now() + 5 * 60 * MIN)).fired).toBe(false); | |
| 109 | }); | |
| 110 | ||
| 111 | test("the planner opens no issues in the repo while one is unanswered, and says why", async () => { | |
| 112 | const items = [{ key: "a", title: "Plan item", body: "x", blockedBy: [] }]; | |
| 113 | const n = await send(person()); | |
| 114 | const refused = openPlan(app, planner, repo, items); | |
| 115 | expect(refused.ok).toBe(false); | |
| 116 | if (!refused.ok) { | |
| 117 | expect(refused.error.error).toBe("from_app_waiting"); | |
| 118 | expect(refused.error.message).toContain(`#${n}`); | |
| 119 | } | |
| 120 | // A person's plan is not held, nor is a builder's; the crew answering lifts the planner's. | |
| 121 | expect(openPlan(app, owner, repo, [{ key: "p", title: "Person item", body: "x", blockedBy: [] }]).ok).toBe(true); | |
| 122 | const asBuilder = openPlan(app, builder, repo, [{ key: "b", title: "Builder item", body: "x", blockedBy: [] }]); | |
| 123 | expect(asBuilder.ok).toBe(true); | |
| 124 | addComment(app.db, { issueId: getIssue(app.db, repo.id, n)!.id, authorId: builder.id, body: "looking" }); | |
| 125 | expect(openPlan(app, planner, repo, items).ok).toBe(true); | |
| 126 | }); | |
| 127 | ||
| 128 | test("with every builder busy the note stays unassigned", async () => { | |
| 129 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: builder.id, reason: "test" }); | |
| 130 | const n = await send(person("-b")); | |
| 131 | expect(getIssue(app.db, repo.id, n)!.assigneeName).toBeNull(); | |
| 132 | app.db.query("UPDATE run SET state = 'ok' WHERE id = ?1").run(run.id); | |
| 133 | close(n); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a hidden note fires no alarm and holds no planner", async () => { | |
| 137 | const n = await send(person("-c")); | |
| 138 | const record = getAppBySlug(app.db, "shop")!; | |
| 139 | for (const note of notesOfApp(app.db, record.id)) hideNote(app.db, note.id); | |
| 140 | app.db.query("UPDATE issue SET assignee_id = NULL WHERE repo_id = ?1 AND number = ?2").run(repo.id, n); | |
| 141 | expect(alarmFromApp(app.db, new Date(Date.now() + 5 * 60 * MIN)).fired).toBe(false); | |
| 142 | expect(openPlan(app, planner, repo, [{ key: "h", title: "After hidden", body: "x", blockedBy: [] }]).ok).toBe(true); | |
| 143 | close(n); | |
| 144 | }); | |
| 145 | }); |
Second import from ../core/runs.ts; merge with workingAgentRuns on line 19.