The plan reads itself: Triage suggestions, progress in Now, and letting go (Closes #206)
Phase 3, Triage suggestions: every few minutes the work service's cron asks the workspace's own AI provider (integrations' ai_brief, on its fast model; g1t runs no model of its own) where each new work item in Triage goes: a horizon (now, next or later), an appetite, an open work item it seems to repeat, and why. Asked once per work item, a dozen calls a run at most; a workspace with no provider gets none and holds no one else up. Triage shows the suggestion with Accept and, for a likely duplicate, Close as duplicate (a "Duplicate of #N" comment, then closed as not planned); an item's Plan box shows it too. Home tells those who can plan when work items have waited in Triage more than a week, after the work to take next. Phase 4, progress draws itself: get_plan answers progress for each work item in Now: its hill (figuring_out, decided, building, landing) from its decisions, commits and pull requests, when anything last happened, quiet after 5 days, and a sentence or two on where it stands that g1t drafts from its sessions, decisions and pull requests whenever something new happens. The Plan's Now rows and an item's Plan box show them; This week lists what landed from Now in the last 7 days and how many went quiet. g1t work plan (CLI 0.4.3) prints the hill, the standing and what landed. Phase 5, letting go: a work item in Later with nothing happening for 90 days is set aside on the cron, saying so in its conversation and publishing work_item.planned with no actor; the plan says how many were set aside in the last 30 days. Work service migration 0057_plan_reads_itself (work_items.suggestion, suggested_at, standing, standing_at). Docs: work/plan (suggestions, how work in Now stands, letting go), guides/ai (what the plan asks the provider), guides/home, the get_plan reference.
- Checked:
- cargo test -p g1t-work --lib (302, new: the hill from the record, quiet, what waits too long, reading a suggestion, bounded standings), -p g1t-contracts --lib, -p g1t-api (222), -p g1t (117 unit); cargo check --workspace --tests; cargo check -p g1t-api -p g1t-work --target wasm32-unknown-unknown; apps/web npm run typecheck, npm test (1121), react-router build, load-check (every route loads, 281); Prettier; docs build. Pages rendered with mock data at desktop and 375px, dark and light.
- G1t-Session:
- ses_01m5bgsckce7gt943v64h219yy
| 1396 | 1396 | ||
| 1397 | 1397 | [[package]] | |
| 1398 | 1398 | name = "g1t" | |
| 1399 | − | version = "0.4.2" | |
| 1399 | + | version = "0.4.3" | |
| 1400 | 1400 | dependencies = [ | |
| 1401 | 1401 | "anstyle-query", | |
| 1402 | 1402 | "base64 0.22.1", |
Binary or large file; its contents are not shown.
| 19 | 19 | answer with the provider and model chosen under **Ask g1t**. | |
| 20 | 20 | - **Sessions on work items** start on the workspace's Anthropic provider. See | |
| 21 | 21 | [Start a session on your Anthropic account](/guides/sessions/#start-a-session-on-your-anthropic-account). | |
| 22 | + | - **[The plan](/work/plan/)** asks the provider chosen under **Ask g1t**, on | |
| 23 | + | its fast model, where each new work item in Triage goes, and drafts where | |
| 24 | + | each work item in Now stands. A few short questions every few minutes, | |
| 25 | + | only when something is new. | |
| 22 | 26 | ||
| 23 | 27 | Only workspace owners add, test, change and remove providers, and choose | |
| 24 | 28 | what Ask uses. Members see which providers there are and what Ask answers |
| 126 | 126 | | Code | A failed production build; your open pull request that can't land until you act (it conflicts, a required check fails, someone asked for changes, approvals are missing); your pull request that meets everything its branch asks for, ready for you to merge; a review asked of you by name; an invitation to a repository; a job waiting for a self-hosted runner | | |
| 127 | 127 | | Notifications | An unread notification in this workspace that is a warning (someone or something is waiting) or a failure. One whose cause is over, such as a workflow that has passed since or a pull request that merged, has [settled](/guides/notifications/#threads-that-settle-themselves) and is not here | | |
| 128 | 128 | | Chat | A conversation that mentions you, or a direct message you haven't read. Muted conversations are left out | | |
| 129 | − | | Work | A work item in [the plan](/work/plan/)'s Now that is assigned to you or that you claimed, in Now's order | | |
| 129 | + | | Work | A work item in [the plan](/work/plan/)'s Now that is assigned to you or that you claimed, in Now's order; for those who can plan, work items that have waited in Triage more than a week | | |
| 130 | 130 | ||
| 131 | 131 | A notification about something already on the list, such as a pull | |
| 132 | 132 | request whose row is there, is left out. |
| 45 | 45 | in a place. The move is noted in its conversation ("moved this from Next | |
| 46 | 46 | to Now"). | |
| 47 | 47 | ||
| 48 | + | ### Suggestions in Triage | |
| 49 | + | ||
| 50 | + | When the workspace has an [AI provider](/guides/ai/) chosen for Ask, g1t | |
| 51 | + | suggests where each new work item in Triage goes: a horizon (Now, Next or | |
| 52 | + | Later), an appetite, an open work item it seems to repeat, and why, in a | |
| 53 | + | sentence. g1t asks the workspace's own provider once for each new work | |
| 54 | + | item, within a few minutes of it being filed. Nothing moves until someone | |
| 55 | + | accepts: | |
| 56 | + | ||
| 57 | + | - **Accept** moves it as suggested: last on that horizon, with that | |
| 58 | + | appetite. | |
| 59 | + | - **Close as duplicate**, when it seems to repeat another, closes it as | |
| 60 | + | not planned with a comment naming the other. | |
| 61 | + | ||
| 62 | + | A workspace with no provider gets no suggestions; Triage works the same | |
| 63 | + | without them. When work items have waited in Triage more than a week, | |
| 64 | + | Home tells those who can plan. | |
| 65 | + | ||
| 48 | 66 | ### Who can plan | |
| 49 | 67 | ||
| 50 | 68 | | To | You need | | |
| ⋯ | |||
| 85 | 103 | ahead of everything ranked there, and stays there until it closes. | |
| 86 | 104 | Reopening a work item clears it. | |
| 87 | 105 | ||
| 106 | + | ## How work in Now stands | |
| 107 | + | ||
| 108 | + | Nobody reports progress by hand. Each work item in Now shows where it is | |
| 109 | + | on its **hill**, from what its record says: | |
| 110 | + | ||
| 111 | + | | Stage | When | | |
| 112 | + | | ------------------- | --------------------------------------------------------------------------- | | |
| 113 | + | | **Figuring it out** | Nothing is decided yet: sessions on it, or nothing at all. | | |
| 114 | + | | **Decided** | A decision is recorded on it, so the approach is settled. | | |
| 115 | + | | **Building** | It has a draft pull request, or a session on it made commits. | | |
| 116 | + | | **Landing** | A pull request for it is in review or merged, or it landed in some targets. | | |
| 117 | + | ||
| 118 | + | Under its title, a sentence or two says **where it stands** and what comes | |
| 119 | + | next. g1t drafts it from the work item's sessions, decisions and pull | |
| 120 | + | requests with the workspace's AI provider, again whenever something new | |
| 121 | + | happens on it. A work item with nothing happening on it for 5 days says | |
| 122 | + | it has gone **quiet**. | |
| 123 | + | ||
| 124 | + | **This week**, beside the plan, lists what landed from Now in the last 7 | |
| 125 | + | days and how many in Now have gone quiet. | |
| 126 | + | ||
| 127 | + | ## Letting go | |
| 128 | + | ||
| 129 | + | Later is for work you want, not for everything anyone ever filed. A work | |
| 130 | + | item in Later that nothing has happened to for 90 days is set aside, and | |
| 131 | + | its conversation says so ("set this aside: nothing happened on it in | |
| 132 | + | Later for 90 days"). It is not deleted: search finds it, and one move puts | |
| 133 | + | it back. The plan says how many were set aside in the last 30 days. | |
| 134 | + | ||
| 88 | 135 | ## What to take next | |
| 89 | 136 | ||
| 90 | 137 | The work item to take next is the first one in **Now** that nobody is | |
| ⋯ | |||
| 122 | 169 | | List one horizon | [`list_work_items`](/reference/api/work-items/list-work-items/) with `horizon`, in the plan's order | | |
| 123 | 170 | | Set Now's cap | [`configure_plan`](/reference/api/work-items/configure-plan/) | | |
| 124 | 171 | ||
| 172 | + | `get_plan` also answers `progress` (each Now work item's `hill`, | |
| 173 | + | `active_at`, `quiet` and drafted `standing`), `landed` (what closed from | |
| 174 | + | Now in the last 7 days), `triage_waiting` and `set_aside_recent`. A work | |
| 175 | + | item in Triage carries its `suggestion`. | |
| 176 | + | ||
| 125 | 177 | Each move publishes `work_item.planned`, with the work item's `horizon`, | |
| 126 | 178 | `fromHorizon` when it changed horizon, its `appetite` and whether it is | |
| 127 | − | `urgent`. See [webhooks](/guides/webhooks/). | |
| 179 | + | `urgent`. Setting aside after 90 days publishes it too, with no actor. | |
| 180 | + | See [webhooks](/guides/webhooks/). | |
Binary or large file; its contents are not shown.
| 14 | 14 | GitCommitHorizontal, | |
| 15 | 15 | GitMerge, | |
| 16 | 16 | GitPullRequest, | |
| 17 | + | Inbox, | |
| 17 | 18 | ListTodo, | |
| 18 | 19 | Loader, | |
| 19 | 20 | Mail, | |
| ⋯ | |||
| 161 | 162 | notification: <Bell size={16} className="text-warn" />, | |
| 162 | 163 | chat: <AtSign size={16} className="text-accent" />, | |
| 163 | 164 | now: <Target size={16} className="text-accent" />, | |
| 165 | + | triage: <Inbox size={16} className="text-warn" />, | |
| 164 | 166 | next: <ListTodo size={16} className="text-accent" />, | |
| 165 | 167 | }; | |
| 166 | 168 | ||
| 1 | 1 | import { Link, useFetcher } from "react-router"; | |
| 2 | 2 | import { useEffect, useId, useState } from "react"; | |
| 3 | 3 | ||
| 4 | − | import { APPETITES, HORIZONS, type Horizon, type WorkItem, appetiteLetter, horizonLabel } from "@g1t/contracts"; | |
| 4 | + | import { Check, Sparkles } from "lucide-react"; | |
| 5 | + | ||
| 6 | + | import { APPETITES, HORIZONS, type Horizon, type Progress, type WorkItem, appetiteLetter, hillLabel, horizonLabel } from "@g1t/contracts"; | |
| 5 | 7 | ||
| 6 | − | import { AppetiteChip } from "./plan"; | |
| 8 | + | import { AppetiteChip, HillMark, QuietBadge, StandingText } from "./plan"; | |
| 7 | 9 | import { ErrorText } from "./ui"; | |
| 8 | 10 | import { Input, InputAddon, InputGroup } from "./ui/input"; | |
| 9 | 11 | import { Badge } from "./ui/badge"; | |
| ⋯ | |||
| 11 | 13 | import { SelectField } from "./ui/select"; | |
| 12 | 14 | import { Switch } from "./ui/switch"; | |
| 13 | 15 | import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; | |
| 16 | + | import { Card } from "./ui/card"; | |
| 14 | 17 | import { Hint } from "./ui/hint"; | |
| 15 | − | import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, budgetText, planPath } from "../lib/plan"; | |
| 18 | + | import { cn } from "../lib/cn"; | |
| 19 | + | import { itemPath } from "../lib/work-items"; | |
| 20 | + | import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, acceptForm, budgetText, canAccept, planPath, suggestionText } from "../lib/plan"; | |
| 16 | 21 | ||
| 17 | 22 | /** | |
| 18 | 23 | * The item page's Plan, in its sidebar beside Milestone: where it stands | |
| ⋯ | |||
| 20 | 25 | * working on it, and urgent, which puts it first in Now. Each change posts | |
| 21 | 26 | * `action=plan` to the page. Read-only for who cannot plan (`canEdit`, the | |
| 22 | 27 | * Triage role); Now and urgent also take the right to manage milestones | |
| 23 | − | * (`canNow`), which the work service checks again. | |
| 28 | + | * (`canNow`), which the work service checks again. In Triage, where g1t | |
| 29 | + | * suggests it goes, with Accept; in Now, how it stands (`progress`): its | |
| 30 | + | * hill and where it stands, as the plan reads them. | |
| 24 | 31 | */ | |
| 25 | 32 | export function PlanBox({ | |
| 26 | 33 | item, | |
| 27 | 34 | workspace, | |
| 28 | 35 | canEdit, | |
| 29 | 36 | canNow, | |
| 37 | + | progress, | |
| 30 | 38 | }: { | |
| 31 | − | item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state">; | |
| 39 | + | item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state" | "suggestion">; | |
| 32 | 40 | workspace: string; | |
| 33 | 41 | canEdit: boolean; | |
| 34 | 42 | canNow: boolean; | |
| 43 | + | /** How it stands, while it is in Now; null when it could not be read. */ | |
| 44 | + | progress?: Progress | null; | |
| 35 | 45 | }) { | |
| 36 | 46 | const fetcher = useFetcher<{ error?: string } | null>(); | |
| 37 | 47 | const budgetId = useId(); | |
| ⋯ | |||
| 45 | 55 | useEffect(() => setBudget(item.budgetUsd == null ? "" : String(item.budgetUsd)), [item.budgetUsd]); | |
| 46 | 56 | const post = (fields: Record<string, string>) => fetcher.submit({ action: "plan", ...fields }, { method: "post" }); | |
| 47 | 57 | const lockedInNow = horizon === "now" && !canNow; | |
| 58 | + | const suggestion = item.horizon === "triage" && horizon === "triage" && item.state === "open" ? (item.suggestion ?? null) : null; | |
| 59 | + | const standing = item.horizon === "now" && horizon === "now" && item.state === "open" ? (progress ?? null) : null; | |
| 48 | 60 | return ( | |
| 49 | 61 | <section> | |
| 50 | 62 | <div className="flex items-center justify-between gap-2"> | |
| ⋯ | |||
| 53 | 65 | Open the plan | |
| 54 | 66 | </Link> | |
| 55 | 67 | </div> | |
| 68 | + | {suggestion && ( | |
| 69 | + | <Card tone="plain" radius="lg" className="mt-2 space-y-1.5 border-accent/20 bg-accent/5 px-3 py-2 text-xs"> | |
| 70 | + | <p className="flex items-center gap-1.5"> | |
| 71 | + | <Sparkles size={12} className="shrink-0 text-accent" aria-hidden /> | |
| 72 | + | <span className="font-medium text-fg">Suggested: {suggestionText(suggestion)}</span> | |
| 73 | + | </p> | |
| 74 | + | {suggestion.duplicateOf != null && ( | |
| 75 | + | <p className="text-muted"> | |
| 76 | + | Looks like{" "} | |
| 77 | + | <Link to={itemPath(workspace, suggestion.duplicateOf)} className="font-mono text-fg underline-offset-4 hover:underline"> | |
| 78 | + | #{suggestion.duplicateOf} | |
| 79 | + | </Link> | |
| 80 | + | </p> | |
| 81 | + | )} | |
| 82 | + | <p className="text-muted">{suggestion.why}</p> | |
| 83 | + | {editable && canAccept(suggestion, canNow) && ( | |
| 84 | + | <Button size="xs" variant="accent" disabled={fetcher.state !== "idle"} onClick={() => post(acceptForm(suggestion))}> | |
| 85 | + | <Check size={13} /> | |
| 86 | + | Accept | |
| 87 | + | </Button> | |
| 88 | + | )} | |
| 89 | + | </Card> | |
| 90 | + | )} | |
| 91 | + | {standing && ( | |
| 92 | + | <div className="mt-2 space-y-1.5"> | |
| 93 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 94 | + | <HillMark hill={standing.hill} /> | |
| 95 | + | <span className="text-xs text-muted">{hillLabel(standing.hill)}</span> | |
| 96 | + | <QuietBadge progress={standing} /> | |
| 97 | + | </div> | |
| 98 | + | <StandingText standing={standing} /> | |
| 99 | + | </div> | |
| 100 | + | )} | |
| 56 | 101 | {editable ? ( | |
| 57 | − | <div className="mt-2 space-y-3"> | |
| 102 | + | <div className={cn("space-y-3", suggestion || standing ? "mt-3" : "mt-2")}> | |
| 58 | 103 | <SelectField | |
| 59 | 104 | aria-label="Horizon" | |
| 60 | 105 | size="sm" | |
| ⋯ | |||
| 110 | 155 | {lockedInNow && <p className="text-xs text-faint">Moving it out of Now takes the right to manage milestones.</p>} | |
| 111 | 156 | </div> | |
| 112 | 157 | ) : ( | |
| 113 | − | <div className="mt-2 flex flex-wrap items-center gap-1.5 text-sm"> | |
| 158 | + | <div className={cn("flex flex-wrap items-center gap-1.5 text-sm", suggestion || standing ? "mt-3" : "mt-2")}> | |
| 114 | 159 | {item.urgent && <Badge tone="danger">Urgent</Badge>} | |
| 115 | 160 | <span>{horizonLabel(item.horizon)}</span> | |
| 116 | 161 | {item.appetite && <AppetiteChip appetite={item.appetite} />} | |
| 1 | − | import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal } from "lucide-react"; | |
| 1 | + | import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal, Sparkles } from "lucide-react"; | |
| 2 | 2 | import type { DragEvent, ReactNode } from "react"; | |
| 3 | 3 | import { Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import { type Appetite, type Horizon, type WorkItem, appetiteLetter, horizonLabel } from "@g1t/contracts"; | |
| 5 | + | import { type Appetite, type Hill, type Horizon, type Progress as Standing, type WorkItem, appetiteLetter, hillLabel, horizonLabel } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { HarnessMark, StateDot } from "./sessions"; | |
| 8 | 8 | import { RepoChips } from "./work-items"; | |
| ⋯ | |||
| 13 | 13 | import { Hint } from "./ui/hint"; | |
| 14 | 14 | import { Progress } from "./ui/progress"; | |
| 15 | 15 | import { cn } from "../lib/cn"; | |
| 16 | − | import { APPETITE_HINTS, type CapState, type Move, type OnIt, type Position, menuHorizons, moveDown, moveUp, waitingLabel } from "../lib/plan"; | |
| 16 | + | import { | |
| 17 | + | APPETITE_HINTS, | |
| 18 | + | type CapState, | |
| 19 | + | HILL_STEPS, | |
| 20 | + | type Move, | |
| 21 | + | type OnIt, | |
| 22 | + | type Position, | |
| 23 | + | hillStep, | |
| 24 | + | menuHorizons, | |
| 25 | + | moveDown, | |
| 26 | + | moveUp, | |
| 27 | + | quietText, | |
| 28 | + | waitingLabel, | |
| 29 | + | } from "../lib/plan"; | |
| 17 | 30 | import { onItemSessionTitle, runsIn, sessionHref } from "../lib/sessions"; | |
| 31 | + | import { timeAgo } from "../lib/time-ago"; | |
| 18 | 32 | import { itemPath } from "../lib/work-items"; | |
| 19 | 33 | ||
| 20 | 34 | /** A work item's appetite as its letter in a small square, with what it means on hover. */ | |
| ⋯ | |||
| 28 | 42 | ); | |
| 29 | 43 | } | |
| 30 | 44 | ||
| 45 | + | /** Where each of a hill's four stages sits on its curve, as x and y in a 44 by 16 box. */ | |
| 46 | + | const HILL_POINTS = HILL_STEPS.map((_, step) => { | |
| 47 | + | const along = (step + 0.5) / HILL_STEPS.length; | |
| 48 | + | return { x: 3 + along * 38, y: 14 - Math.sin(along * Math.PI) * 10 }; | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * How far along a work item in Now is, as its record says: a small hill | |
| 53 | + | * with its four stages (figuring it out, decided, building, landing) and | |
| 54 | + | * the one it is at filled in. Its stage is its accessible name and its | |
| 55 | + | * hint; `label` also shows it in words, before the hill. | |
| 56 | + | */ | |
| 57 | + | export function HillMark({ hill, label = false }: { hill: Hill; label?: boolean }) { | |
| 58 | + | const at = hillStep(hill); | |
| 59 | + | const words = `${hillLabel(hill)}, stage ${at + 1} of ${HILL_STEPS.length}`; | |
| 60 | + | return ( | |
| 61 | + | <Hint label={words}> | |
| 62 | + | <span role="img" aria-label={words} className="inline-flex shrink-0 items-center gap-1.5"> | |
| 63 | + | {label && <span className="text-[0.6875rem] whitespace-nowrap text-faint">{hillLabel(hill)}</span>} | |
| 64 | + | <svg viewBox="0 0 44 16" className="h-4 w-11" aria-hidden> | |
| 65 | + | <path d="M2 15 Q 22 -5 42 15" fill="none" className="stroke-line-strong" strokeWidth="1.25" /> | |
| 66 | + | {HILL_POINTS.map((point, step) => | |
| 67 | + | step === at ? ( | |
| 68 | + | <circle key={step} cx={point.x} cy={point.y} r="3" className="fill-accent" /> | |
| 69 | + | ) : ( | |
| 70 | + | <circle key={step} cx={point.x} cy={point.y} r="1.25" className={step < at ? "fill-accent/50" : "fill-line-strong"} /> | |
| 71 | + | ), | |
| 72 | + | )} | |
| 73 | + | </svg> | |
| 74 | + | </span> | |
| 75 | + | </Hint> | |
| 76 | + | ); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** | |
| 80 | + | * Where a work item in Now stands, in g1t's words from its record (its | |
| 81 | + | * sessions, decisions and pull requests), muted, with a small mark that | |
| 82 | + | * says g1t drafted it and when. Nothing until it has been drafted. | |
| 83 | + | */ | |
| 84 | + | export function StandingText({ standing, className }: { standing: Pick<Standing, "standing" | "standingAt">; className?: string }) { | |
| 85 | + | if (!standing.standing) return null; | |
| 86 | + | const when = standing.standingAt ? ` ${timeAgo(standing.standingAt)}` : ""; | |
| 87 | + | return ( | |
| 88 | + | <p className={cn("text-xs leading-relaxed text-muted", className)}> | |
| 89 | + | {standing.standing} | |
| 90 | + | <Hint label={<span suppressHydrationWarning>{`Drafted by g1t from its record${when}`}</span>}> | |
| 91 | + | <span className="ml-1 inline-flex translate-y-px items-center text-faint" aria-label="Drafted by g1t" role="img"> | |
| 92 | + | <Sparkles size={11} /> | |
| 93 | + | </span> | |
| 94 | + | </Hint> | |
| 95 | + | </p> | |
| 96 | + | ); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /** "Quiet for 6 days", for a work item in Now nothing has happened on lately. */ | |
| 100 | + | export function QuietBadge({ progress }: { progress: Pick<Standing, "quiet" | "activeAt"> }) { | |
| 101 | + | const text = quietText(progress, Date.now()); | |
| 102 | + | if (!text) return null; | |
| 103 | + | return ( | |
| 104 | + | <Badge tone="warn" suppressHydrationWarning> | |
| 105 | + | {text} | |
| 106 | + | </Badge> | |
| 107 | + | ); | |
| 108 | + | } | |
| 109 | + | ||
| 31 | 110 | /** Who is on a work item: people's avatars, and the live sessions on it with what they run in, as the board shows them. */ | |
| 32 | 111 | export function OnItLine({ on, workspace }: { on: OnIt | undefined; workspace: string }) { | |
| 33 | 112 | if (!on || (on.people.length === 0 && on.sessions.length === 0)) return null; | |
| ⋯ | |||
| 168 | 247 | /** | |
| 169 | 248 | * One work item in the plan: its place (`rank`), urgent first, its number | |
| 170 | 249 | * and title, its repositories, what it waits on, who is on it, its | |
| 171 | − | * appetite, and its menu. | |
| 250 | + | * appetite, and its menu. In Now, also how it stands (`progress`): its | |
| 251 | + | * hill, whether it has gone quiet, and where it stands under its title. | |
| 172 | 252 | */ | |
| 173 | 253 | export function PlanRow({ | |
| 174 | 254 | item, | |
| ⋯ | |||
| 178 | 258 | waiting, | |
| 179 | 259 | drag, | |
| 180 | 260 | menu, | |
| 261 | + | progress, | |
| 181 | 262 | }: { | |
| 182 | 263 | item: WorkItem; | |
| 183 | 264 | rank: number | null; | |
| ⋯ | |||
| 187 | 268 | waiting: readonly number[]; | |
| 188 | 269 | drag?: RowDrag; | |
| 189 | 270 | menu?: ReactNode; | |
| 271 | + | progress?: Standing; | |
| 190 | 272 | }) { | |
| 273 | + | const quiet = progress?.quiet ?? false; | |
| 191 | 274 | return ( | |
| 192 | 275 | <li | |
| 193 | 276 | draggable={drag?.draggable || undefined} | |
| ⋯ | |||
| 227 | 310 | <span className="mr-1.5 font-mono text-xs font-normal text-faint">#{item.number}</span> | |
| 228 | 311 | {item.title} | |
| 229 | 312 | </Link> | |
| 230 | − | {(item.repos.length > 0 || waiting.length > 0 || (on && (on.people.length > 0 || on.sessions.length > 0))) && ( | |
| 313 | + | {progress && <StandingText standing={progress} className="mt-0.5 line-clamp-2" />} | |
| 314 | + | {(progress || item.repos.length > 0 || waiting.length > 0 || quiet || (on && (on.people.length > 0 || on.sessions.length > 0))) && ( | |
| 231 | 315 | <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-faint"> | |
| 316 | + | {/* On a phone the hill goes under the title, in words, where there is room for it. */} | |
| 317 | + | {progress && ( | |
| 318 | + | <span className="sm:hidden"> | |
| 319 | + | <HillMark hill={progress.hill} label /> | |
| 320 | + | </span> | |
| 321 | + | )} | |
| 232 | 322 | <RepoChips repos={item.repos} workspace={workspace} max={1} /> | |
| 323 | + | {progress && <QuietBadge progress={progress} />} | |
| 233 | 324 | {waiting.length > 0 && ( | |
| 234 | 325 | <Badge tone="warn" className="max-w-full"> | |
| 235 | 326 | <span className="truncate">{waitingLabel(waiting)}</span> | |
| ⋯ | |||
| 240 | 331 | )} | |
| 241 | 332 | </div> | |
| 242 | 333 | <div className="flex shrink-0 items-center gap-1"> | |
| 334 | + | {progress && ( | |
| 335 | + | <span className="mr-1 max-sm:hidden"> | |
| 336 | + | <HillMark hill={progress.hill} /> | |
| 337 | + | </span> | |
| 338 | + | )} | |
| 243 | 339 | {item.appetite && <AppetiteChip appetite={item.appetite} />} | |
| 244 | 340 | {menu} | |
| 245 | 341 | </div> | |
| 46 | 46 | spendIn, | |
| 47 | 47 | startHere, | |
| 48 | 48 | summarizeQuestion, | |
| 49 | + | triageRow, | |
| 49 | 50 | waitingSentence, | |
| 50 | 51 | withSessions, | |
| 51 | 52 | withShipped, | |
| ⋯ | |||
| 239 | 240 | assert.deepEqual(attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: null }).missing, ["Work"]); | |
| 240 | 241 | }); | |
| 241 | 242 | ||
| 243 | + | test("work waiting in Triage over a week is a row for whoever plans, last, after their work in Now", () => { | |
| 244 | + | assert.equal(triageRow("acme", 0), null); | |
| 245 | + | const one = triageRow("acme", 1); | |
| 246 | + | assert.equal(one?.title, "1 work item has waited in Triage over a week"); | |
| 247 | + | const row = triageRow("acme", 4); | |
| 248 | + | assert.equal(row?.title, "4 work items have waited in Triage over a week"); | |
| 249 | + | assert.deepEqual(row?.action, { label: "Triage", to: "/acme/-/work/triage" }); | |
| 250 | + | const mine = nowRows([workItem(7, { horizon: "now", assignees: ["bo"] })], [], "bo"); | |
| 251 | + | const dm = { channel: { id: "c1", kind: "dm" as const, name: null, last_message_at: iso(NOW - DAY) }, title: "ana", muted: false, unread: 1, mentions: 0 }; | |
| 252 | + | const out = attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [dm], now: mine, triageWaiting: 4 }); | |
| 253 | + | assert.deepEqual( | |
| 254 | + | out.rows.map((r) => r.key), | |
| 255 | + | ["chat:c1", "item:acme#7", "triage:acme"], | |
| 256 | + | ); | |
| 257 | + | // Alone, it is where to start, before the work item to take next. | |
| 258 | + | const alone = attention({ | |
| 259 | + | slug: "acme", | |
| 260 | + | code: false, | |
| 261 | + | codeNeeds: null, | |
| 262 | + | notifications: [], | |
| 263 | + | chat: [], | |
| 264 | + | now: [], | |
| 265 | + | triageWaiting: 2, | |
| 266 | + | next: { item: workItem(31), viewer: "bo" }, | |
| 267 | + | }); | |
| 268 | + | // The work to take next comes before the nudge; with none, the nudge leads. | |
| 269 | + | assert.equal(alone.start?.row.key, nextRow(workItem(31)).key); | |
| 270 | + | assert.deepEqual( | |
| 271 | + | alone.rows.map((row) => row.key), | |
| 272 | + | ["triage:acme"], | |
| 273 | + | ); | |
| 274 | + | const nudge = attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: [], triageWaiting: 2 }); | |
| 275 | + | assert.equal(nudge.start?.row.key, "triage:acme"); | |
| 276 | + | assert.match(nudge.start?.why ?? "", /waited over a week to be placed/); | |
| 277 | + | // Nothing waiting that long, or a viewer who does not plan: no row. | |
| 278 | + | assert.deepEqual(attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: [], triageWaiting: 0 }).rows, []); | |
| 279 | + | assert.deepEqual(attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: [] }).rows, []); | |
| 280 | + | }); | |
| 281 | + | ||
| 242 | 282 | test("the same order whatever order the rows came in", () => { | |
| 243 | 283 | const rows = [row({ key: "b", kind: "review", at: NOW }), row({ key: "a", kind: "review", at: NOW, action: { label: "Review", to: "/y" } })]; | |
| 244 | 284 | assert.deepEqual( | |
| 30 | 30 | import { HARNESS_NAMES } from "./coordination.ts"; | |
| 31 | 31 | import { type PushRecord, pushedByPerson, pushedCommits } from "./mission-control.ts"; | |
| 32 | 32 | import { settledLine } from "./inbox.ts"; | |
| 33 | − | import { itemPath, sessionPath } from "./work-items.ts"; | |
| 33 | + | import { itemPath, sessionPath, workPath } from "./work-items.ts"; | |
| 34 | 34 | ||
| 35 | 35 | const MINUTE = 60_000; | |
| 36 | 36 | const HOUR = 60 * MINUTE; | |
| ⋯ | |||
| 1070 | 1070 | * 3. a job waiting for a runner that is not there; | |
| 1071 | 1071 | * 4. an unread notification that is a warning or a failure; | |
| 1072 | 1072 | * 5. a mention or a direct message; | |
| 1073 | − | * 6. a work item in the plan's Now that is yours: assigned to you, or claimed by you. | |
| 1073 | + | * 6. a work item in the plan's Now that is yours: assigned to you, or claimed by you; | |
| 1074 | + | * 7. work items that have waited in Triage over a week, for whoever plans. | |
| 1074 | 1075 | * | |
| 1075 | 1076 | * `next`, the work item to take next, is never a row: Start here falls back | |
| 1076 | 1077 | * to it when nothing else ranks ([`attention`]). | |
| 1077 | 1078 | */ | |
| 1078 | − | export type AttentionKind = "deploy" | "pull_blocked" | "review" | "invitation" | "ready" | "runner" | "notification" | "chat" | "now" | "next"; | |
| 1079 | + | export type AttentionKind = "deploy" | "pull_blocked" | "review" | "invitation" | "ready" | "runner" | "notification" | "chat" | "now" | "triage" | "next"; | |
| 1079 | 1080 | ||
| 1080 | 1081 | export const TIER: Record<AttentionKind, number> = { | |
| 1081 | 1082 | deploy: 0, | |
| ⋯ | |||
| 1087 | 1088 | notification: 4, | |
| 1088 | 1089 | chat: 5, | |
| 1089 | 1090 | now: 6, | |
| 1090 | − | next: 7, | |
| 1091 | + | triage: 7, | |
| 1092 | + | next: 8, | |
| 1091 | 1093 | }; | |
| 1092 | 1094 | ||
| 1093 | 1095 | export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null }; | |
| ⋯ | |||
| 1152 | 1154 | notification: "It is the most pressing unread notification.", | |
| 1153 | 1155 | chat: "Nothing else is waiting, and this is the oldest message for you.", | |
| 1154 | 1156 | now: "Nothing else is waiting on you, and this is yours in Now.", | |
| 1157 | + | triage: "Nothing else is waiting on you, and new work has waited over a week to be placed in the plan.", | |
| 1155 | 1158 | next: "Nothing is waiting on you, so this is the work to take next.", | |
| 1156 | 1159 | }; | |
| 1157 | 1160 | ||
| ⋯ | |||
| 1200 | 1203 | } | |
| 1201 | 1204 | ||
| 1202 | 1205 | /** | |
| 1206 | + | * Work that has waited in Triage more than `TRIAGE_NUDGE_DAYS`, as a row of | |
| 1207 | + | * Your move for a viewer who plans (the Triage role or higher): after | |
| 1208 | + | * their own work in Now. None when nothing has waited that long. | |
| 1209 | + | */ | |
| 1210 | + | export function triageRow(slug: string, waiting: number): AttentionRow | null { | |
| 1211 | + | if (waiting <= 0) return null; | |
| 1212 | + | return { | |
| 1213 | + | key: `triage:${slug}`, | |
| 1214 | + | kind: "triage", | |
| 1215 | + | title: `${plural(waiting, "work item")} ${waiting === 1 ? "has" : "have"} waited in Triage over a week`, | |
| 1216 | + | detail: "Place each in the plan, or close it.", | |
| 1217 | + | stake: null, | |
| 1218 | + | action: { label: "Triage", to: workPath(slug, "triage") }, | |
| 1219 | + | owner: null, | |
| 1220 | + | at: 0, | |
| 1221 | + | from: "plan", | |
| 1222 | + | }; | |
| 1223 | + | } | |
| 1224 | + | ||
| 1225 | + | /** | |
| 1203 | 1226 | * Why the work item to take next (`nextWorkItem`) is the one: first in | |
| 1204 | 1227 | * Now with nobody on it (or urgent there), else the top of Next; nothing | |
| 1205 | 1228 | * open blocks it, nobody has claimed it, and it is assigned to nobody or | |
| ⋯ | |||
| 1389 | 1412 | notifications: InboxItem[] | null; | |
| 1390 | 1413 | chat: ChatEntryLike[] | null; | |
| 1391 | 1414 | now?: AttentionRow[] | null; | |
| 1415 | + | /** How many have waited in Triage over a week, for a viewer who plans; undefined for one who does not. */ | |
| 1416 | + | triageWaiting?: number | null; | |
| 1392 | 1417 | next?: { item: WorkItem; viewer: string } | null; | |
| 1393 | 1418 | }): Attention { | |
| 1394 | 1419 | const rows: AttentionRow[] = []; | |
| ⋯ | |||
| 1411 | 1436 | } else missing.push("Chat"); | |
| 1412 | 1437 | if (input.now) rows.push(...input.now); | |
| 1413 | 1438 | else if (input.now === null) missing.push("Work"); | |
| 1439 | + | const triage = input.triageWaiting ? triageRow(input.slug, input.triageWaiting) : null; | |
| 1440 | + | if (triage) rows.push(triage); | |
| 1414 | 1441 | const ranked = rankAttention(rows); | |
| 1415 | 1442 | const next = input.next ? { row: nextRow(input.next.item), why: nextWhy(input.next.item, input.next.viewer) } : null; | |
| 1416 | − | return { rows: ranked, start: startHere(ranked) ?? next, missing }; | |
| 1443 | + | // Work to take comes before a nudge to place new work. | |
| 1444 | + | const first = startHere(ranked); | |
| 1445 | + | const start = first?.row.kind === "triage" && next ? next : (first ?? next); | |
| 1446 | + | return { rows: ranked, start, missing }; | |
| 1417 | 1447 | } | |
| 1418 | 1448 | ||
| 1419 | 1449 | // --- Spend in the span ---------------------------------------------------------- | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | 7 | type PlanView, | |
| 8 | + | acceptForm, | |
| 8 | 9 | allItemsPath, | |
| 9 | 10 | applyMove, | |
| 10 | 11 | blockersToRead, | |
| 11 | 12 | budgetText, | |
| 13 | + | canAccept, | |
| 12 | 14 | capState, | |
| 15 | + | daysSince, | |
| 16 | + | duplicateComment, | |
| 13 | 17 | dropPosition, | |
| 18 | + | hillStep, | |
| 14 | 19 | listRedirect, | |
| 15 | 20 | menuHorizons, | |
| 16 | 21 | moreText, | |
| ⋯ | |||
| 22 | 27 | parseBudget, | |
| 23 | 28 | planInput, | |
| 24 | 29 | planPath, | |
| 30 | + | progressOf, | |
| 31 | + | quietLine, | |
| 32 | + | quietText, | |
| 33 | + | setAsideLine, | |
| 25 | 34 | setAsidePath, | |
| 35 | + | suggestionText, | |
| 36 | + | triageLine, | |
| 26 | 37 | triagePath, | |
| 27 | 38 | waitingLabel, | |
| 28 | 39 | waitingOn, | |
| ⋯ | |||
| 241 | 252 | assert.equal(moreText(10, 10), null); | |
| 242 | 253 | assert.equal(moreText(10, 1234), "1,224 more"); | |
| 243 | 254 | }); | |
| 255 | + | ||
| 256 | + | test("a hill's stage, in its four steps", () => { | |
| 257 | + | assert.deepEqual((["figuring_out", "decided", "building", "landing"] as const).map(hillStep), [0, 1, 2, 3]); | |
| 258 | + | }); | |
| 259 | + | ||
| 260 | + | test("a quiet row says for how long, from the last time anything happened on it", () => { | |
| 261 | + | const now = Date.parse("2026-10-19T12:00:00Z"); | |
| 262 | + | assert.equal(daysSince("2026-10-13T11:00:00Z", now), 6); | |
| 263 | + | assert.equal(daysSince(null, now), null); | |
| 264 | + | assert.equal(daysSince("2026-10-20T00:00:00Z", now), 0); | |
| 265 | + | assert.equal(quietText({ quiet: true, activeAt: "2026-10-13T11:00:00Z" }, now), "Quiet for 6 days"); | |
| 266 | + | assert.equal(quietText({ quiet: true, activeAt: "2026-10-18T11:00:00Z" }, now), "Quiet for 1 day"); | |
| 267 | + | assert.equal(quietText({ quiet: true, activeAt: null }, now), "Quiet"); | |
| 268 | + | assert.equal(quietText({ quiet: false, activeAt: "2026-10-01T00:00:00Z" }, now), null); | |
| 269 | + | }); | |
| 270 | + | ||
| 271 | + | test("the week: what went quiet, what was let go, and Triage waiting", () => { | |
| 272 | + | assert.equal(quietLine(0), null); | |
| 273 | + | assert.equal(quietLine(1), "1 in Now has gone quiet, with nothing happening on it for 5 days or more."); | |
| 274 | + | assert.equal(quietLine(3), "3 in Now have gone quiet, with nothing happening on them for 5 days or more."); | |
| 275 | + | assert.equal(setAsideLine(0), null); | |
| 276 | + | assert.equal(setAsideLine(4), "4 set aside in the last 30 days, untouched for 90 days in Later"); | |
| 277 | + | assert.equal(triageLine(1, 0), "1 new work item waits in Triage to be placed."); | |
| 278 | + | assert.equal(triageLine(5, 2), "5 new work items wait in Triage to be placed. 2 of them have waited more than a week."); | |
| 279 | + | assert.equal(triageLine(3, 3), "3 new work items wait in Triage to be placed. All have waited more than a week."); | |
| 280 | + | assert.equal(triageLine(1, 1), "1 new work item waits in Triage to be placed. It has waited more than a week."); | |
| 281 | + | assert.deepEqual(progressOf([{ number: 4, hill: "building" }]), { 4: { number: 4, hill: "building" } }); | |
| 282 | + | }); | |
| 283 | + | ||
| 284 | + | test("accepting a suggestion moves it where it says, at the bottom, with its appetite", () => { | |
| 285 | + | const next = { horizon: "next" as const, appetite: "medium" as const }; | |
| 286 | + | assert.equal(suggestionText(next), "Next · M"); | |
| 287 | + | assert.equal(suggestionText({ horizon: "later", appetite: null }), "Later"); | |
| 288 | + | assert.deepEqual(acceptForm(next), { horizon: "next", appetite: "medium" }); | |
| 289 | + | assert.deepEqual(acceptForm({ horizon: "later", appetite: null }), { horizon: "later" }); | |
| 290 | + | // It is a plan move as the form posts it: no position, so the horizon puts it at the bottom. | |
| 291 | + | assert.deepEqual(planInput(new URLSearchParams(acceptForm(next))), { horizon: "next", appetite: "medium" }); | |
| 292 | + | assert.equal(canAccept({ horizon: "now" }, false), false); | |
| 293 | + | assert.equal(canAccept({ horizon: "now" }, true), true); | |
| 294 | + | assert.equal(canAccept({ horizon: "next" }, false), true); | |
| 295 | + | assert.equal(duplicateComment(12), "Duplicate of #12"); | |
| 296 | + | }); | |
| 5 | 5 | * move looks like before the work service answers, and how full Now is. | |
| 6 | 6 | * Pure, so it is tested under Node. | |
| 7 | 7 | */ | |
| 8 | − | import type { Appetite, Claim, Horizon, Plan, PlanGroup, PlanWorkItemInput, WorkItem, WorkspaceSession } from "@g1t/contracts"; | |
| 8 | + | import type { Appetite, Claim, Hill, Horizon, Plan, PlanGroup, PlanSuggestion, PlanWorkItemInput, WorkItem, WorkspaceSession } from "@g1t/contracts"; | |
| 9 | 9 | ||
| 10 | 10 | import { workPath } from "./work-items.ts"; | |
| 11 | 11 | ||
| ⋯ | |||
| 16 | 16 | export const HORIZONS: readonly Horizon[] = ["triage", "now", "next", "later", "set_aside"]; | |
| 17 | 17 | /** Every appetite, smallest first. */ | |
| 18 | 18 | export const APPETITES: readonly Appetite[] = ["small", "medium", "large"]; | |
| 19 | + | /** Every stage of a hill, in the order work goes over it (the contracts' `HILLS`). */ | |
| 20 | + | export const HILL_STEPS: readonly Hill[] = ["figuring_out", "decided", "building", "landing"]; | |
| 19 | 21 | ||
| 20 | 22 | /** The Plan, Work's first page. */ | |
| 21 | 23 | export function planPath(workspace: string, query = ""): string { | |
| ⋯ | |||
| 312 | 314 | export function moreText(shown: number, total: number): string | null { | |
| 313 | 315 | return total > shown ? `${(total - shown).toLocaleString("en-US")} more` : null; | |
| 314 | 316 | } | |
| 317 | + | ||
| 318 | + | // --- How Now stands ----------------------------------------------------------- | |
| 319 | + | ||
| 320 | + | const DAY_MS = 86_400_000; | |
| 321 | + | ||
| 322 | + | /** Where `hill` is among its four stages: 0 (figuring it out) to 3 (landing). */ | |
| 323 | + | export function hillStep(hill: Hill): number { | |
| 324 | + | return Math.max(0, HILL_STEPS.indexOf(hill)); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /** Whole days since `at` (RFC 3339), never fewer than 0; null when there is no time. */ | |
| 328 | + | export function daysSince(at: string | null | undefined, now: number): number | null { | |
| 329 | + | const time = at ? Date.parse(at) : Number.NaN; | |
| 330 | + | if (Number.isNaN(time)) return null; | |
| 331 | + | return Math.max(0, Math.floor((now - time) / DAY_MS)); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | /** | |
| 335 | + | * What a quiet row says: "Quiet for 6 days", counted from the last time | |
| 336 | + | * anything happened on it; "Quiet" alone when that is not known (or is | |
| 337 | + | * today, as clocks differ). Null | |
| 338 | + | * for one that is not quiet. | |
| 339 | + | */ | |
| 340 | + | export function quietText(progress: { quiet: boolean; activeAt: string | null }, now: number): string | null { | |
| 341 | + | if (!progress.quiet) return null; | |
| 342 | + | const days = daysSince(progress.activeAt, now); | |
| 343 | + | if (days == null || days < 1) return "Quiet"; | |
| 344 | + | return `Quiet for ${days === 1 ? "1 day" : `${days.toLocaleString("en-US")} days`}`; | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | /** How each work item in Now stands, by number. */ | |
| 348 | + | export function progressOf<P extends { number: number }>(progress: readonly P[]): Record<number, P> { | |
| 349 | + | return Object.fromEntries(progress.map((one) => [one.number, one])); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /** Days with nothing happening before a work item in Now has gone quiet (the contracts' `QUIET_DAYS`). */ | |
| 353 | + | export const QUIET_AFTER_DAYS = 5; | |
| 354 | + | ||
| 355 | + | /** The week's line about Now going quiet; null for none. */ | |
| 356 | + | export function quietLine(count: number): string | null { | |
| 357 | + | if (count <= 0) return null; | |
| 358 | + | const one = count === 1; | |
| 359 | + | return `${count.toLocaleString("en-US")} in Now ${one ? "has" : "have"} gone quiet, with nothing happening on ${one ? "it" : "them"} for ${QUIET_AFTER_DAYS} days or more.`; | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /** The week's line about letting go; null for none set aside lately. */ | |
| 363 | + | export function setAsideLine(count: number): string | null { | |
| 364 | + | if (count <= 0) return null; | |
| 365 | + | return `${count.toLocaleString("en-US")} set aside in the last 30 days, untouched for 90 days in Later`; | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /** Triage's banner on the Plan: how many wait, and how many of them have waited more than a week. */ | |
| 369 | + | export function triageLine(total: number, waiting: number): string { | |
| 370 | + | const wait = total === 1 ? "1 new work item waits" : `${total.toLocaleString("en-US")} new work items wait`; | |
| 371 | + | const long = | |
| 372 | + | waiting <= 0 | |
| 373 | + | ? "" | |
| 374 | + | : waiting >= total | |
| 375 | + | ? total === 1 | |
| 376 | + | ? " It has waited more than a week." | |
| 377 | + | : " All have waited more than a week." | |
| 378 | + | : ` ${waiting.toLocaleString("en-US")} of them have waited more than a week.`; | |
| 379 | + | return `${wait} in Triage to be placed.${long}`; | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // --- Triage's suggestions ------------------------------------------------------- | |
| 383 | + | ||
| 384 | + | /** A suggestion in a few words: "Next · M". */ | |
| 385 | + | export function suggestionText(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): string { | |
| 386 | + | const horizon = { triage: "Triage", now: "Now", next: "Next", later: "Later", set_aside: "Set aside" }[suggestion.horizon]; | |
| 387 | + | const size = suggestion.appetite ? ` · ${suggestion.appetite === "small" ? "S" : suggestion.appetite === "medium" ? "M" : "L"}` : ""; | |
| 388 | + | return `${horizon}${size}`; | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | /** | |
| 392 | + | * Accepting a suggestion, as the form a fetcher posts (`planInput` reads | |
| 393 | + | * it): its horizon, where the horizon puts new work (the bottom of Next), | |
| 394 | + | * and its appetite when it has one. | |
| 395 | + | */ | |
| 396 | + | export function acceptForm(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): Record<string, string> { | |
| 397 | + | const form: Record<string, string> = { horizon: suggestion.horizon }; | |
| 398 | + | if (suggestion.appetite) form.appetite = suggestion.appetite; | |
| 399 | + | return form; | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | /** Whether the viewer may accept a suggestion: moving into Now takes more than placing work elsewhere. */ | |
| 403 | + | export function canAccept(suggestion: Pick<PlanSuggestion, "horizon">, canNow: boolean): boolean { | |
| 404 | + | return suggestion.horizon !== "triage" && (suggestion.horizon !== "now" || canNow); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | /** The comment a work item closed as a duplicate gets. */ | |
| 408 | + | export function duplicateComment(of: number): string { | |
| 409 | + | return `Duplicate of #${of}`; | |
| 410 | + | } | |
| 77 | 77 | import { page } from "../../lib/meta"; | |
| 78 | 78 | import { inbox, work } from "../../lib/services.server"; | |
| 79 | 79 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 80 | + | import { canItem } from "../../lib/work-access"; | |
| 80 | 81 | import { homePagePath } from "../../lib/workspace-nav"; | |
| 81 | 82 | import { Boundary } from "../../components/boundary"; | |
| 82 | 83 | ||
| ⋯ | |||
| 139 | 140 | const lastSeenP = windowKey === "last" ? loadLastVisit(viewer, slug).catch(() => undefined) : Promise.resolve(null); | |
| 140 | 141 | ||
| 141 | 142 | // Your own work in the plan's Now, and the work item to take next for when nothing else waits on you. | |
| 142 | − | const nowP = Promise.all([work.listWorkItems(slug, viewer, { horizon: "now" }), work.listClaims(slug, viewer, { state: "active" })]) | |
| 143 | − | .then(([now, claims]) => (now.ok && claims.ok ? nowRows(now.value, claims.value, viewer.username) : null)) | |
| 143 | + | // Whoever plans (the Triage role or higher) reads the plan instead of | |
| 144 | + | // the list: its Now is the same, and it says how many have waited in | |
| 145 | + | // Triage over a week, which they are asked to place. | |
| 146 | + | const plans = canItem(viewer, slug, null, "triage"); | |
| 147 | + | const planP = plans | |
| 148 | + | ? work | |
| 149 | + | .getPlan(slug, viewer) | |
| 150 | + | .then((plan) => (plan.ok ? plan.value : null)) | |
| 151 | + | .catch(() => null) | |
| 152 | + | : Promise.resolve(null); | |
| 153 | + | const nowItemsP = plans | |
| 154 | + | ? planP.then((plan) => plan?.now ?? null) | |
| 155 | + | : work | |
| 156 | + | .listWorkItems(slug, viewer, { horizon: "now" }) | |
| 157 | + | .then((now) => (now.ok ? now.value : null)) | |
| 158 | + | .catch(() => null); | |
| 159 | + | const nowP = Promise.all([nowItemsP, work.listClaims(slug, viewer, { state: "active" })]) | |
| 160 | + | .then(([now, claims]) => (now && claims.ok ? nowRows(now, claims.value, viewer.username) : null)) | |
| 144 | 161 | .catch(() => null); | |
| 162 | + | const triageWaitingP = planP.then((plan) => (plans ? (plan?.triageWaiting ?? null) : undefined)); | |
| 145 | 163 | const nextP = work | |
| 146 | 164 | .nextWorkItem(slug, viewer) | |
| 147 | 165 | .then((result) => (result.ok && result.value ? { item: result.value, viewer: viewer.username } : null)) | |
| 148 | 166 | .catch(() => null); | |
| 149 | − | const waiting: Promise<Attention> = Promise.all([missionP, notificationsP, chatP, nowP, nextP]) | |
| 150 | − | .then(([mission, notifications, sidebar, now, next]) => | |
| 151 | − | attention({ slug, code, codeNeeds: mission?.needs ?? null, notifications, chat: sidebar?.entries ?? null, now, next }), | |
| 167 | + | const waiting: Promise<Attention> = Promise.all([missionP, notificationsP, chatP, nowP, nextP, triageWaitingP]) | |
| 168 | + | .then(([mission, notifications, sidebar, now, next, triageWaiting]) => | |
| 169 | + | attention({ slug, code, codeNeeds: mission?.needs ?? null, notifications, chat: sidebar?.entries ?? null, now, triageWaiting, next }), | |
| 152 | 170 | ) | |
| 153 | 171 | .catch(() => ({ rows: [], start: null, missing: ["Code", "Notifications", "Chat", "Work"] })); | |
| 154 | 172 | ||
| 115 | 115 | const origin = originOf(item.repos); | |
| 116 | 116 | // Who may do what follows the item: the better of their role in the | |
| 117 | 117 | // workspace and on its origin repository (lib/work-access.ts). | |
| 118 | − | const [originRepo, links, subscription, sessionTrees] = await Promise.all([ | |
| 118 | + | const [originRepo, links, subscription, sessionTrees, progress] = await Promise.all([ | |
| 119 | 119 | origin ? repos.getById(origin.repoId, viewer).catch(() => null) : null, | |
| 120 | 120 | // What it is tied to outside g1t. Shown only once the item is known visible. | |
| 121 | 121 | part(integrations.links(workspace, number), "Links from outside g1t"), | |
| ⋯ | |||
| 128 | 128 | ), | |
| 129 | 129 | "Sessions", | |
| 130 | 130 | ), | |
| 131 | + | // How it stands while it is in Now, as the plan reads it, for the sidebar's Plan: it does without when it cannot be read. | |
| 132 | + | item.horizon === "now" && item.state === "open" | |
| 133 | + | ? work | |
| 134 | + | .getPlan(workspace, viewer) | |
| 135 | + | .then((plan) => (plan.ok ? (plan.value.progress.find((one) => one.number === number) ?? null) : null)) | |
| 136 | + | .catch(() => null) | |
| 137 | + | : null, | |
| 131 | 138 | ]); | |
| 132 | 139 | const ref = originRepo?.ok ? { id: originRepo.value.id, namespace: originRepo.value.namespace, isPrivate: originRepo.value.isPrivate } : null; | |
| 133 | 140 | const can = itemAbilities(viewer, workspace, ref); | |
| ⋯ | |||
| 151 | 158 | subscription, | |
| 152 | 159 | coordination: isFailed(coordination) ? coordination : coordination.ok ? coordination.value : null, | |
| 153 | 160 | sessionTrees, | |
| 161 | + | progress, | |
| 154 | 162 | // Only when it can start: the button is not shown otherwise. | |
| 155 | 163 | agentSetup: agentSetup?.ok && agentSetup.value.available ? agentSetup.value : null, | |
| 156 | 164 | endedClaims: ended | |
| ⋯ | |||
| 1017 | 1025 | ) : ( | |
| 1018 | 1026 | <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} /> | |
| 1019 | 1027 | )} | |
| 1020 | − | <PlanBox item={item} workspace={workspace} canEdit={can.triage} canNow={can.manage_labels} /> | |
| 1028 | + | <PlanBox item={item} workspace={workspace} canEdit={can.triage} canNow={can.manage_labels} progress={loaderData.progress} /> | |
| 1021 | 1029 | {viewer && | |
| 1022 | 1030 | (isFailed(loaderData.subscription) ? ( | |
| 1023 | 1031 | <SideUnavailable heading="Notifications" what="Your notification settings" /> | |
| 1 | − | import { ChevronRight, Inbox, ListOrdered, Plus } from "lucide-react"; | |
| 1 | + | import { CheckCircle2, ChevronRight, Inbox, ListOrdered, Plus } from "lucide-react"; | |
| 2 | 2 | import { type DragEvent, useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { Link, redirect, useFetcher } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/plan"; | |
| 8 | 8 | import { CapBar, PlanRow, PlanRowMenu, type RowDrag } from "../../../components/plan"; | |
| 9 | − | import { ButtonLink } from "../../../components/ui"; | |
| 9 | + | import { ButtonLink, TimeAgo } from "../../../components/ui"; | |
| 10 | 10 | import { Alert } from "../../../components/ui/alert"; | |
| 11 | 11 | import { Button } from "../../../components/ui/button"; | |
| 12 | 12 | import { Card } from "../../../components/ui/card"; | |
| ⋯ | |||
| 29 | 29 | onItems, | |
| 30 | 30 | planInput, | |
| 31 | 31 | planPath, | |
| 32 | + | progressOf, | |
| 33 | + | quietLine, | |
| 34 | + | setAsideLine, | |
| 32 | 35 | setAsidePath, | |
| 36 | + | triageLine, | |
| 33 | 37 | triagePath, | |
| 34 | 38 | waitingOn, | |
| 35 | 39 | } from "../../../lib/plan"; | |
| ⋯ | |||
| 39 | 43 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| 40 | 44 | import { useWorkspaceLive } from "../../../lib/use-repo-live"; | |
| 41 | 45 | import { canItem } from "../../../lib/work-access"; | |
| 42 | − | import { newItemPath, shortPath, workPath } from "../../../lib/work-items"; | |
| 46 | + | import { itemPath, newItemPath, shortPath, workPath } from "../../../lib/work-items"; | |
| 43 | 47 | ||
| 44 | 48 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 45 | 49 | return page(args, { title: `Plan · Work · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 220 | 224 | }); | |
| 221 | 225 | ||
| 222 | 226 | const cap = capState(plan.now.length, loaderData.plan.nowCap); | |
| 227 | + | // How each in Now stands, and the week: read with the plan, and left as they are while a move is on its way. | |
| 228 | + | const { landed, triageWaiting, setAsideRecent } = loaderData.plan; | |
| 229 | + | const progress = useMemo(() => progressOf(loaderData.plan.progress), [loaderData.plan.progress]); | |
| 230 | + | const quiet = quietLine(loaderData.plan.progress.filter((one) => one.quiet).length); | |
| 231 | + | const letGo = setAsideLine(setAsideRecent); | |
| 223 | 232 | const nextMore = moreText(plan.next.length, plan.nextTotal); | |
| 224 | 233 | const row = (lane: "now" | "next", item: WorkItem, index: number, rank: number) => ( | |
| 225 | 234 | <PlanRow | |
| ⋯ | |||
| 231 | 240 | waiting={waitingOn(item, closed)} | |
| 232 | 241 | drag={rowDrag(lane, item, index)} | |
| 233 | 242 | menu={canPlan ? <PlanRowMenu item={item} list={lists[lane]} canNow={canNow} onMove={(move) => submit(move, item)} /> : undefined} | |
| 243 | + | progress={lane === "now" ? progress[item.number] : undefined} | |
| 234 | 244 | /> | |
| 235 | 245 | ); | |
| 236 | 246 | ||
| ⋯ | |||
| 272 | 282 | className="mt-4 flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-sm text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 273 | 283 | > | |
| 274 | 284 | <Inbox size={15} className="shrink-0 text-accent" /> | |
| 275 | − | <span className="min-w-0 grow"> | |
| 276 | − | {plan.triageTotal === 1 ? "1 new work item waits" : `${plan.triageTotal.toLocaleString("en-US")} new work items wait`} in Triage to be placed. | |
| 277 | − | </span> | |
| 285 | + | <span className="min-w-0 grow">{triageLine(plan.triageTotal, Math.min(triageWaiting, plan.triageTotal))}</span> | |
| 278 | 286 | <ChevronRight size={15} className="shrink-0" /> | |
| 279 | 287 | </Link> | |
| 280 | 288 | )} | |
| ⋯ | |||
| 341 | 349 | </Card> | |
| 342 | 350 | ||
| 343 | 351 | <div className="grid min-w-0 content-start gap-4"> | |
| 352 | + | <Card asChild className="min-w-0"> | |
| 353 | + | <section aria-labelledby="plan-week"> | |
| 354 | + | <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2"> | |
| 355 | + | <h2 id="plan-week" className="text-sm font-semibold"> | |
| 356 | + | This week | |
| 357 | + | </h2> | |
| 358 | + | <span className="text-xs text-faint">what landed from Now</span> | |
| 359 | + | </header> | |
| 360 | + | {landed.length === 0 ? ( | |
| 361 | + | <p className="px-4 pb-2 text-xs text-faint">Nothing has landed from Now in the last 7 days.</p> | |
| 362 | + | ) : ( | |
| 363 | + | <ul className="px-2 pb-1"> | |
| 364 | + | {landed.map((item) => ( | |
| 365 | + | <li key={item.id}> | |
| 366 | + | <Link | |
| 367 | + | to={itemPath(workspace, item.number)} | |
| 368 | + | prefetch="intent" | |
| 369 | + | className="flex min-w-0 items-baseline gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-raised/60 hover:text-accent" | |
| 370 | + | > | |
| 371 | + | <CheckCircle2 size={13} className="shrink-0 translate-y-0.5 text-success" aria-hidden /> | |
| 372 | + | <span className="min-w-0 grow truncate"> | |
| 373 | + | <span className="mr-1.5 font-mono text-xs text-faint">#{item.number}</span> | |
| 374 | + | {item.title} | |
| 375 | + | </span> | |
| 376 | + | {item.closedAt && ( | |
| 377 | + | <span className="shrink-0 text-xs text-faint"> | |
| 378 | + | <TimeAgo at={item.closedAt} /> | |
| 379 | + | </span> | |
| 380 | + | )} | |
| 381 | + | </Link> | |
| 382 | + | </li> | |
| 383 | + | ))} | |
| 384 | + | </ul> | |
| 385 | + | )} | |
| 386 | + | {(quiet || letGo) && ( | |
| 387 | + | <div className="space-y-1 border-t border-line px-4 py-2.5 text-xs text-muted"> | |
| 388 | + | {quiet && <p>{quiet}</p>} | |
| 389 | + | {letGo && ( | |
| 390 | + | <p> | |
| 391 | + | <Link to={setAsidePath(workspace)} className="hover:text-fg"> | |
| 392 | + | {letGo} | |
| 393 | + | </Link> | |
| 394 | + | . | |
| 395 | + | </p> | |
| 396 | + | )} | |
| 397 | + | </div> | |
| 398 | + | )} | |
| 399 | + | {!quiet && !letGo && <div className="pb-2" />} | |
| 400 | + | </section> | |
| 401 | + | </Card> | |
| 344 | 402 | <Card asChild className={cn("min-w-0", over?.lane === "later" && "border-accent/60 bg-accent/5")}> | |
| 345 | 403 | <section aria-labelledby="plan-later" {...laneDrop("later")}> | |
| 346 | 404 | <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2"> | |
| 1 | − | import { Inbox, Plus, X } from "lucide-react"; | |
| 1 | + | import { Check, Copy, Inbox, Plus, Sparkles, X } from "lucide-react"; | |
| 2 | 2 | import { Link, useFetcher } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { APPETITES, LIST_PAGE, type WorkItem, appetiteLetter } from "@g1t/contracts"; | |
| ⋯ | |||
| 12 | 12 | import { RepoChips } from "../../../components/work-items"; | |
| 13 | 13 | import { page } from "../../../lib/meta"; | |
| 14 | 14 | import { isFailed, part } from "../../../lib/partial"; | |
| 15 | − | import { APPETITE_HINTS, APPETITE_SUMMARY, moreText, planInput, planPath } from "../../../lib/plan"; | |
| 15 | + | import { APPETITE_HINTS, APPETITE_SUMMARY, acceptForm, canAccept, duplicateComment, moreText, planInput, planPath, suggestionText } from "../../../lib/plan"; | |
| 16 | 16 | import { workPages } from "../../../lib/repo-live"; | |
| 17 | 17 | import { work } from "../../../lib/services.server"; | |
| 18 | 18 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| ⋯ | |||
| 53 | 53 | const form = await request.formData(); | |
| 54 | 54 | const number = Number(form.get("number")); | |
| 55 | 55 | if (!Number.isInteger(number) || number < 1) return { error: "Choose a work item.", number: null }; | |
| 56 | + | // A duplicate: said in a comment, then closed as not planned, as its own page would. | |
| 57 | + | if (form.get("action") === "close-duplicate") { | |
| 58 | + | const of = Number(form.get("of")); | |
| 59 | + | if (!Number.isInteger(of) || of < 1 || of === number) return { error: "Choose the work item it repeats.", number }; | |
| 60 | + | const said = await work.addComment(user, { workspace, number }, { body: duplicateComment(of) }); | |
| 61 | + | if (!said.ok) return { error: said.error.message, number }; | |
| 62 | + | const result = await work.closeWorkItem(user, workspace, number, "not_planned"); | |
| 63 | + | return result.ok ? { error: null, number } : { error: result.error.message, number }; | |
| 64 | + | } | |
| 56 | 65 | // Closing as not planned, as the item's own page does. | |
| 57 | 66 | if (form.get("action") === "close-not-planned") { | |
| 58 | 67 | const result = await work.closeWorkItem(user, workspace, number, "not_planned"); | |
| ⋯ | |||
| 66 | 75 | ||
| 67 | 76 | /** | |
| 68 | 77 | * Work's Triage: the work items nobody has placed in the plan yet, newest | |
| 69 | − | * first, each with the moves that place it. | |
| 78 | + | * first, each with the moves that place it, and where g1t suggests it goes | |
| 79 | + | * (drafted with the workspace's own AI provider), which one click accepts. | |
| 70 | 80 | */ | |
| 71 | 81 | export default function Triage({ loaderData }: Route.ComponentProps) { | |
| 72 | 82 | const { workspace, items, triageTotal, canPlan, canNow, member } = loaderData; | |
| 73 | 83 | useWorkspaceLive({ workspace, member, match: workPages.list() }); | |
| 74 | 84 | const more = moreText(items.length, triageTotal); | |
| 85 | + | // Only a workspace with an AI provider gets suggestions, so one shown says it has one. | |
| 86 | + | const suggesting = items.some((item) => item.suggestion); | |
| 75 | 87 | return ( | |
| 76 | 88 | <div> | |
| 77 | 89 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| ⋯ | |||
| 83 | 95 | <p className="mt-1 text-sm text-muted"> | |
| 84 | 96 | New work items land here until someone places them: in Now, in Next to be taken in turn, or in Later. Give each an appetite while you are at it. | |
| 85 | 97 | </p> | |
| 98 | + | {suggesting && ( | |
| 99 | + | <p className="mt-1 flex items-center gap-1.5 text-xs text-faint"> | |
| 100 | + | <Sparkles size={12} className="shrink-0" aria-hidden /> | |
| 101 | + | g1t suggests where new work goes using your workspace's AI provider; nothing moves until someone accepts. | |
| 102 | + | </p> | |
| 103 | + | )} | |
| 86 | 104 | </div> | |
| 87 | 105 | {loaderData.canOpen && ( | |
| 88 | 106 | <ButtonLink to={newItemPath(workspace)} className="max-sm:h-10"> | |
| ⋯ | |||
| 121 | 139 | const busy = fetcher.state !== "idle"; | |
| 122 | 140 | const posted = fetcher.formData; | |
| 123 | 141 | // Placed or closed: gone from Triage before the answer, back if it was refused. | |
| 124 | − | const leaving = busy && posted != null && (posted.has("horizon") || posted.get("action") === "close-not-planned"); | |
| 142 | + | const leaving = | |
| 143 | + | busy && posted != null && (posted.has("horizon") || posted.get("action") === "close-not-planned" || posted.get("action") === "close-duplicate"); | |
| 144 | + | const suggestion = item.suggestion ?? null; | |
| 125 | 145 | const appetite = busy && posted?.has("appetite") ? (String(posted.get("appetite")) as WorkItem["appetite"] | "none") : item.appetite; | |
| 126 | 146 | const post = (fields: Record<string, string>) => fetcher.submit({ number: String(item.number), ...fields }, { method: "post" }); | |
| 127 | 147 | if (leaving) return null; | |
| ⋯ | |||
| 139 | 159 | <RepoChips repos={item.repos} workspace={workspace} /> | |
| 140 | 160 | </div> | |
| 141 | 161 | </div> | |
| 162 | + | {suggestion && ( | |
| 163 | + | <Card tone="plain" radius="lg" className="flex min-w-0 flex-col gap-2 border-accent/20 bg-accent/5 px-3 py-2 sm:flex-row sm:items-center"> | |
| 164 | + | <div className="min-w-0 grow text-xs"> | |
| 165 | + | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"> | |
| 166 | + | <Sparkles size={12} className="shrink-0 text-accent" aria-hidden /> | |
| 167 | + | <span className="font-medium text-fg">Suggested: {suggestionText(suggestion)}</span> | |
| 168 | + | {suggestion.duplicateOf != null && ( | |
| 169 | + | <> | |
| 170 | + | <span className="text-faint">·</span> | |
| 171 | + | <span className="text-muted"> | |
| 172 | + | Looks like{" "} | |
| 173 | + | <Link to={itemPath(workspace, suggestion.duplicateOf)} className="font-mono text-fg underline-offset-4 hover:underline"> | |
| 174 | + | #{suggestion.duplicateOf} | |
| 175 | + | </Link> | |
| 176 | + | </span> | |
| 177 | + | </> | |
| 178 | + | )} | |
| 179 | + | </p> | |
| 180 | + | <p className="mt-0.5 text-muted">{suggestion.why}</p> | |
| 181 | + | </div> | |
| 182 | + | {canPlan && ( | |
| 183 | + | <div className="flex shrink-0 flex-wrap items-center gap-2"> | |
| 184 | + | {canAccept(suggestion, canNow) && ( | |
| 185 | + | <Button size="sm" variant="accent" disabled={busy} onClick={() => post(acceptForm(suggestion))}> | |
| 186 | + | <Check size={14} /> | |
| 187 | + | Accept | |
| 188 | + | </Button> | |
| 189 | + | )} | |
| 190 | + | {suggestion.duplicateOf != null && ( | |
| 191 | + | <Button size="sm" variant="outline" disabled={busy} onClick={() => post({ action: "close-duplicate", of: String(suggestion.duplicateOf) })}> | |
| 192 | + | <Copy size={14} /> | |
| 193 | + | Close as duplicate | |
| 194 | + | </Button> | |
| 195 | + | )} | |
| 196 | + | </div> | |
| 197 | + | )} | |
| 198 | + | </Card> | |
| 199 | + | )} | |
| 142 | 200 | {canPlan && ( | |
| 143 | 201 | <div className="flex flex-wrap items-center gap-2"> | |
| 144 | 202 | {canNow && ( | |
| 178 | 178 | /// placed. | |
| 179 | 179 | #[serde(default)] | |
| 180 | 180 | pub planned_at: Option<String>, | |
| 181 | + | /// While it is in Triage: where g1t suggests it goes. | |
| 182 | + | #[serde(default)] | |
| 183 | + | pub suggestion: Option<super::PlanSuggestion>, | |
| 181 | 184 | } | |
| 182 | 185 | ||
| 183 | 186 | impl WorkItem { |
| 120 | 120 | pub const NEXT_SHOWN: u32 = 10; | |
| 121 | 121 | /// The most spend budget a work item may be given, in dollars. | |
| 122 | 122 | pub const MAX_BUDGET_USD: f64 = 100_000.0; | |
| 123 | + | /// A work item in Triage this many days is waiting too long: Home says so | |
| 124 | + | /// to those who plan. | |
| 125 | + | pub const TRIAGE_NUDGE_DAYS: u32 = 7; | |
| 126 | + | /// A work item in Later untouched this many days is set aside. | |
| 127 | + | pub const SET_ASIDE_DAYS: u32 = 90; | |
| 128 | + | /// A work item in Now with nothing happening on it this many days has gone | |
| 129 | + | /// quiet. | |
| 130 | + | pub const QUIET_DAYS: u32 = 5; | |
| 131 | + | /// How far back the plan's week looks, in days. | |
| 132 | + | pub const WEEK_DAYS: u32 = 7; | |
| 133 | + | ||
| 134 | + | /// What g1t suggests for a work item in Triage: drafted by the | |
| 135 | + | /// workspace's own AI provider from the work item and the plan, for a | |
| 136 | + | /// person to accept or not. | |
| 137 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 138 | + | #[serde(rename_all = "camelCase")] | |
| 139 | + | pub struct PlanSuggestion { | |
| 140 | + | pub horizon: Horizon, | |
| 141 | + | #[serde(default)] | |
| 142 | + | pub appetite: Option<Appetite>, | |
| 143 | + | /// An open work item it seems to repeat, by number. | |
| 144 | + | #[serde(default)] | |
| 145 | + | pub duplicate_of: Option<u32>, | |
| 146 | + | /// Why, in a sentence. | |
| 147 | + | pub why: String, | |
| 148 | + | /// RFC 3339. | |
| 149 | + | pub at: String, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// How far along a work item in Now is, as its record says: what its | |
| 153 | + | /// sessions, decisions and pull requests show, never set by hand. | |
| 154 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] | |
| 155 | + | #[serde(rename_all = "snake_case")] | |
| 156 | + | pub enum Hill { | |
| 157 | + | /// Nothing decided yet: sessions on it, or nothing at all. | |
| 158 | + | FiguringOut, | |
| 159 | + | /// A decision is recorded on it: the approach is settled. | |
| 160 | + | Decided, | |
| 161 | + | /// A draft pull request, or commits made for it. | |
| 162 | + | Building, | |
| 163 | + | /// A pull request in review or merged, or landed in some of its | |
| 164 | + | /// targets. | |
| 165 | + | Landing, | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | impl Hill { | |
| 169 | + | pub fn as_str(self) -> &'static str { | |
| 170 | + | match self { | |
| 171 | + | Hill::FiguringOut => "figuring_out", | |
| 172 | + | Hill::Decided => "decided", | |
| 173 | + | Hill::Building => "building", | |
| 174 | + | Hill::Landing => "landing", | |
| 175 | + | } | |
| 176 | + | } | |
| 123 | 177 | ||
| 178 | + | pub fn label(self) -> &'static str { | |
| 179 | + | match self { | |
| 180 | + | Hill::FiguringOut => "Figuring it out", | |
| 181 | + | Hill::Decided => "Decided", | |
| 182 | + | Hill::Building => "Building", | |
| 183 | + | Hill::Landing => "Landing", | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// What a work item's record says of it, for its hill. | |
| 189 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 190 | + | pub struct HillFacts { | |
| 191 | + | pub decisions: u32, | |
| 192 | + | pub draft_pulls: u32, | |
| 193 | + | pub commits: u32, | |
| 194 | + | pub open_pulls: u32, | |
| 195 | + | pub merged_pulls: u32, | |
| 196 | + | pub landed_targets: u32, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// Where a work item with `facts` is on its hill: the furthest stage any | |
| 200 | + | /// fact reaches. | |
| 201 | + | pub fn hill_of(facts: &HillFacts) -> Hill { | |
| 202 | + | if facts.open_pulls > 0 || facts.merged_pulls > 0 || facts.landed_targets > 0 { | |
| 203 | + | Hill::Landing | |
| 204 | + | } else if facts.draft_pulls > 0 || facts.commits > 0 { | |
| 205 | + | Hill::Building | |
| 206 | + | } else if facts.decisions > 0 { | |
| 207 | + | Hill::Decided | |
| 208 | + | } else { | |
| 209 | + | Hill::FiguringOut | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// How a work item in Now stands. | |
| 214 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 215 | + | #[serde(rename_all = "camelCase")] | |
| 216 | + | pub struct Progress { | |
| 217 | + | pub number: u32, | |
| 218 | + | pub hill: Hill, | |
| 219 | + | /// RFC 3339: the last time anything happened on it (a session, a | |
| 220 | + | /// decision, a pull request, a change to it). | |
| 221 | + | #[serde(default)] | |
| 222 | + | pub active_at: Option<String>, | |
| 223 | + | /// Nothing has happened on it for [`QUIET_DAYS`]. | |
| 224 | + | #[serde(default)] | |
| 225 | + | pub quiet: bool, | |
| 226 | + | /// Where it stands, in a sentence or two, drafted by g1t from its | |
| 227 | + | /// record with the workspace's AI provider; none until it has one. | |
| 228 | + | #[serde(default)] | |
| 229 | + | pub standing: Option<String>, | |
| 230 | + | /// RFC 3339: when `standing` was drafted. | |
| 231 | + | #[serde(default)] | |
| 232 | + | pub standing_at: Option<String>, | |
| 233 | + | } | |
| 234 | + | ||
| 124 | 235 | /// What `Some(None)` means in a change: the field is cleared. Absent | |
| 125 | 236 | /// leaves it as it is; JSON `null` clears it. | |
| 126 | 237 | fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error> | |
| ⋯ | |||
| 202 | 313 | /// the workspace), the largest group first. | |
| 203 | 314 | pub later: Vec<PlanGroup>, | |
| 204 | 315 | pub set_aside_total: u32, | |
| 316 | + | /// How each work item in Now stands, in Now's order. | |
| 317 | + | #[serde(default)] | |
| 318 | + | pub progress: Vec<Progress>, | |
| 319 | + | /// What closed from Now in the last [`WEEK_DAYS`], the most recent | |
| 320 | + | /// first; at most 10. | |
| 321 | + | #[serde(default)] | |
| 322 | + | pub landed: Vec<WorkItem>, | |
| 323 | + | /// How many have waited in Triage more than [`TRIAGE_NUDGE_DAYS`]. | |
| 324 | + | #[serde(default)] | |
| 325 | + | pub triage_waiting: u32, | |
| 326 | + | /// How many were set aside in the last 30 days. | |
| 327 | + | #[serde(default)] | |
| 328 | + | pub set_aside_recent: u32, | |
| 205 | 329 | } | |
| 206 | 330 | ||
| 207 | 331 | /// Work items of one horizon filed in one repository. | |
| ⋯ | |||
| 323 | 447 | } | |
| 324 | 448 | ||
| 325 | 449 | #[test] | |
| 450 | + | fn the_hill_is_the_furthest_stage_the_record_reaches() { | |
| 451 | + | assert_eq!(hill_of(&HillFacts::default()), Hill::FiguringOut); | |
| 452 | + | assert_eq!(hill_of(&HillFacts { decisions: 2, ..HillFacts::default() }), Hill::Decided); | |
| 453 | + | assert_eq!(hill_of(&HillFacts { decisions: 1, commits: 3, ..HillFacts::default() }), Hill::Building); | |
| 454 | + | assert_eq!(hill_of(&HillFacts { draft_pulls: 1, ..HillFacts::default() }), Hill::Building); | |
| 455 | + | assert_eq!(hill_of(&HillFacts { open_pulls: 1, ..HillFacts::default() }), Hill::Landing); | |
| 456 | + | assert_eq!(hill_of(&HillFacts { landed_targets: 1, draft_pulls: 1, ..HillFacts::default() }), Hill::Landing); | |
| 457 | + | assert_eq!(serde_json::to_value(Hill::FiguringOut).unwrap(), "figuring_out"); | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | #[test] | |
| 326 | 461 | fn horizons_and_appetites_read_as_people_write_them() { | |
| 327 | 462 | assert_eq!(Horizon::parse("Set aside"), Some(Horizon::SetAside)); | |
| 328 | 463 | assert_eq!(Horizon::parse("set-aside"), Some(Horizon::SetAside)); | |
| 1 | 1 | [package] | |
| 2 | 2 | name = "g1t" | |
| 3 | − | version = "0.4.2" | |
| 3 | + | version = "0.4.3" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | 6 | repository.workspace = true |
| 815 | 815 | } | |
| 816 | 816 | } | |
| 817 | 817 | ||
| 818 | + | /// A hill's stage, as people say it. | |
| 819 | + | fn hill_label(name: &str) -> &'static str { | |
| 820 | + | match name { | |
| 821 | + | "decided" => "Decided", | |
| 822 | + | "building" => "Building", | |
| 823 | + | "landing" => "Landing", | |
| 824 | + | _ => "Figuring it out", | |
| 825 | + | } | |
| 826 | + | } | |
| 827 | + | ||
| 818 | 828 | /// An appetite as given: `S`, `small` and so on, or `none` to clear it. | |
| 819 | 829 | pub fn appetite_field(text: &str) -> Result<Value> { | |
| 820 | 830 | match text.trim().to_ascii_lowercase().as_str() { | |
| ⋯ | |||
| 935 | 945 | if now.is_empty() { | |
| 936 | 946 | println!("{}", out.dim(" Nothing in Now. Move what you're taking on: g1t work plan <number> --now")); | |
| 937 | 947 | } else { | |
| 938 | − | let rows: Vec<Vec<Cell>> = now.iter().enumerate().map(|(i, item)| plan_row(out, i + 1, item)).collect(); | |
| 939 | − | out.table(&["", "number", "title", "appetite", "", "waits"], &rows)?; | |
| 948 | + | let progress = list_of(plan, "progress"); | |
| 949 | + | let of = |item: &Value| progress.iter().find(|p| p["number"] == item["number"]); | |
| 950 | + | let rows: Vec<Vec<Cell>> = now | |
| 951 | + | .iter() | |
| 952 | + | .enumerate() | |
| 953 | + | .map(|(i, item)| { | |
| 954 | + | let mut row = plan_row(out, i + 1, item); | |
| 955 | + | let stage = of(item).map(|p| hill_label(s(p, "hill"))).unwrap_or_default(); | |
| 956 | + | let quiet = if of(item).and_then(|p| p["quiet"].as_bool()) == Some(true) { " · quiet" } else { "" }; | |
| 957 | + | let stage = format!("{stage}{quiet}"); | |
| 958 | + | row.insert(3, Cell::styled(stage.clone(), out.dim(&stage))); | |
| 959 | + | row | |
| 960 | + | }) | |
| 961 | + | .collect(); | |
| 962 | + | out.table(&["", "number", "title", "hill", "appetite", "", "waits"], &rows)?; | |
| 963 | + | // Where each stands, as g1t drafted it from its record. | |
| 964 | + | for item in now { | |
| 965 | + | if let Some(standing) = of(item).and_then(|p| p["standing"].as_str()) { | |
| 966 | + | println!("{}", out.dim(&format!(" #{}: {standing}", item["number"]))); | |
| 967 | + | } | |
| 968 | + | } | |
| 969 | + | } | |
| 970 | + | let landed = list_of(plan, "landed"); | |
| 971 | + | if !landed.is_empty() { | |
| 972 | + | let list: Vec<String> = landed.iter().map(|item| format!("#{}", item["number"])).collect(); | |
| 973 | + | println!("{}", out.dim(&format!(" Landed from Now this week: {}", list.join(", ")))); | |
| 940 | 974 | } | |
| 941 | 975 | println!(); | |
| 942 | 976 | println!("{}", out.bold(&format!("Next · {}", count("next_total")))); | |
| ⋯ | |||
| 960 | 994 | count("set_aside_total") | |
| 961 | 995 | )) | |
| 962 | 996 | ); | |
| 997 | + | let waiting = count("triage_waiting"); | |
| 998 | + | if waiting > 0 { | |
| 999 | + | println!("{}", out.dim(&format!("{waiting} waited in Triage over a week: g1t work list --horizon triage"))); | |
| 1000 | + | } | |
| 963 | 1001 | Ok(()) | |
| 964 | 1002 | } | |
| 965 | 1003 | ||
| 113 | 113 | urgent: boolean; | |
| 114 | 114 | /** RFC 3339: when it was last moved in the plan; null for one never placed. */ | |
| 115 | 115 | plannedAt: string | null; | |
| 116 | + | /** While it is in Triage: where g1t suggests it goes. */ | |
| 117 | + | suggestion?: PlanSuggestion | null; | |
| 118 | + | }; | |
| 119 | + | ||
| 120 | + | /** What g1t suggests for a work item in Triage, drafted by the workspace's own AI provider, for a person to accept or not. */ | |
| 121 | + | export type PlanSuggestion = { | |
| 122 | + | horizon: Horizon; | |
| 123 | + | appetite: Appetite | null; | |
| 124 | + | /** An open work item it seems to repeat. */ | |
| 125 | + | duplicateOf: number | null; | |
| 126 | + | why: string; | |
| 127 | + | /** RFC 3339. */ | |
| 128 | + | at: string; | |
| 129 | + | }; | |
| 130 | + | ||
| 131 | + | /** How far along a work item in Now is, as its record says; never set by hand. */ | |
| 132 | + | export type Hill = "figuring_out" | "decided" | "building" | "landing"; | |
| 133 | + | ||
| 134 | + | export const HILLS: Hill[] = ["figuring_out", "decided", "building", "landing"]; | |
| 135 | + | ||
| 136 | + | export function hillLabel(hill: Hill): string { | |
| 137 | + | return { figuring_out: "Figuring it out", decided: "Decided", building: "Building", landing: "Landing" }[hill]; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /** How a work item in Now stands. */ | |
| 141 | + | export type Progress = { | |
| 142 | + | number: number; | |
| 143 | + | hill: Hill; | |
| 144 | + | /** RFC 3339: the last time anything happened on it. */ | |
| 145 | + | activeAt: string | null; | |
| 146 | + | /** Nothing has happened on it for `QUIET_DAYS`. */ | |
| 147 | + | quiet: boolean; | |
| 148 | + | /** Where it stands, drafted by g1t from its record; null until it has one. */ | |
| 149 | + | standing: string | null; | |
| 150 | + | standingAt: string | null; | |
| 116 | 151 | }; | |
| 117 | 152 | ||
| 153 | + | /** Days in Triage before Home says a work item is waiting too long. */ | |
| 154 | + | export const TRIAGE_NUDGE_DAYS = 7; | |
| 155 | + | /** Days untouched in Later before a work item is set aside. */ | |
| 156 | + | export const SET_ASIDE_DAYS = 90; | |
| 157 | + | /** Days with nothing happening before a work item in Now has gone quiet. */ | |
| 158 | + | export const QUIET_DAYS = 5; | |
| 159 | + | /** How far back the plan's week looks. */ | |
| 160 | + | export const WEEK_DAYS = 7; | |
| 161 | + | ||
| 118 | 162 | /** | |
| 119 | 163 | * Where an open work item stands in its workspace's plan. New ones start | |
| 120 | 164 | * in Triage; within Now, Next and Later the order is the priority. Mirrors | |
| ⋯ | |||
| 161 | 205 | /** Later, by the repository each is filed in (null: filed in the workspace), the largest group first. */ | |
| 162 | 206 | later: PlanGroup[]; | |
| 163 | 207 | setAsideTotal: number; | |
| 208 | + | /** How each work item in Now stands, in Now's order. */ | |
| 209 | + | progress: Progress[]; | |
| 210 | + | /** What closed from Now in the last `WEEK_DAYS`, the most recent first; at most 10. */ | |
| 211 | + | landed: WorkItem[]; | |
| 212 | + | /** How many have waited in Triage more than `TRIAGE_NUDGE_DAYS`. */ | |
| 213 | + | triageWaiting: number; | |
| 214 | + | /** How many were set aside in the last 30 days. */ | |
| 215 | + | setAsideRecent: number; | |
| 164 | 216 | }; | |
| 165 | 217 | ||
| 166 | 218 | export type PlanGroup = { repo: string | null; count: number }; | |
| 1 | + | -- The plan reads itself (items/plan_settle.rs). For a work item in Triage, | |
| 2 | + | -- where g1t suggests it goes (suggestion: JSON { horizon, appetite, | |
| 3 | + | -- duplicate_of, why }), asked of the workspace's AI provider once | |
| 4 | + | -- (suggested_at, set even when there was no answer). For one in Now, where it | |
| 5 | + | -- stands in a sentence or two (standing), drafted when something new has | |
| 6 | + | -- happened on it since standing_at. | |
| 7 | + | ALTER TABLE work_items ADD COLUMN suggestion TEXT; | |
| 8 | + | ALTER TABLE work_items ADD COLUMN suggested_at TEXT; | |
| 9 | + | ALTER TABLE work_items ADD COLUMN standing TEXT; | |
| 10 | + | ALTER TABLE work_items ADD COLUMN standing_at TEXT; | |
| 11 | + | ||
| 12 | + | CREATE INDEX work_items_unsuggested ON work_items (state, horizon, suggested_at) WHERE state = 'open'; |
| 55 | 55 | mod milestones; | |
| 56 | 56 | mod numbering; | |
| 57 | 57 | mod plan; | |
| 58 | + | mod plan_settle; | |
| 58 | 59 | mod reading; | |
| 59 | 60 | mod refs; | |
| 60 | 61 | mod rows; |
| 69 | 69 | (SELECT min(rank) FROM work_items WHERE workspace = ?1 AND state = 'open' AND COALESCE(horizon, 'triage') = ?2 | |
| 70 | 70 | AND id <> ?3 AND rank IS NOT NULL AND (?5 IS NULL OR rank > ?5)) AS behind"; | |
| 71 | 71 | ||
| 72 | + | /// What the record says of each work item in the JSON array of ids `?1`: | |
| 73 | + | /// its hill's facts, when anything last happened on it, and where g1t | |
| 74 | + | /// last said it stands. | |
| 75 | + | pub(crate) const FACTS: &str = "SELECT w.number, | |
| 76 | + | (SELECT count(*) FROM decisions d WHERE d.item_id = w.id | |
| 77 | + | AND d.kind NOT IN ('claim', 'merge', 'integration_failure', 'integration_passed')) AS decisions, | |
| 78 | + | (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'draft') AS draft_pulls, | |
| 79 | + | (SELECT count(*) FROM session_touches t JOIN sessions s ON s.id = t.session_id | |
| 80 | + | WHERE s.item_id = w.id AND t.kind = 'commit') AS commits, | |
| 81 | + | (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'open') AS open_pulls, | |
| 82 | + | (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'merged') AS merged_pulls, | |
| 83 | + | (SELECT count(*) FROM work_item_repos r WHERE r.item_id = w.id AND r.landed_at IS NOT NULL) AS landed_targets, | |
| 84 | + | max(w.updated_at, | |
| 85 | + | COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.item_id = w.id), ''), | |
| 86 | + | COALESCE((SELECT max(d.created_at) FROM decisions d WHERE d.item_id = w.id), ''), | |
| 87 | + | COALESCE((SELECT max(p.updated_at) FROM pulls p WHERE p.item_id = w.id), '')) AS active_at, | |
| 88 | + | w.standing, w.standing_at | |
| 89 | + | FROM work_items w WHERE w.id IN (SELECT value FROM json_each(?1))"; | |
| 90 | + | ||
| 91 | + | #[derive(Deserialize)] | |
| 92 | + | pub(crate) struct FactsRow { | |
| 93 | + | pub number: u32, | |
| 94 | + | #[serde(default)] | |
| 95 | + | pub decisions: u32, | |
| 96 | + | #[serde(default)] | |
| 97 | + | pub draft_pulls: u32, | |
| 98 | + | #[serde(default)] | |
| 99 | + | pub commits: u32, | |
| 100 | + | #[serde(default)] | |
| 101 | + | pub open_pulls: u32, | |
| 102 | + | #[serde(default)] | |
| 103 | + | pub merged_pulls: u32, | |
| 104 | + | #[serde(default)] | |
| 105 | + | pub landed_targets: u32, | |
| 106 | + | #[serde(default)] | |
| 107 | + | pub active_at: Option<String>, | |
| 108 | + | #[serde(default)] | |
| 109 | + | pub standing: Option<String>, | |
| 110 | + | #[serde(default)] | |
| 111 | + | pub standing_at: Option<String>, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | impl FactsRow { | |
| 115 | + | pub(crate) fn hill(&self) -> Hill { | |
| 116 | + | hill_of(&HillFacts { | |
| 117 | + | decisions: self.decisions, | |
| 118 | + | draft_pulls: self.draft_pulls, | |
| 119 | + | commits: self.commits, | |
| 120 | + | open_pulls: self.open_pulls, | |
| 121 | + | merged_pulls: self.merged_pulls, | |
| 122 | + | landed_targets: self.landed_targets, | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// How it stands, quiet when nothing has happened since `quiet_before` | |
| 127 | + | /// (RFC 3339). | |
| 128 | + | pub(crate) fn progress(self, quiet_before: &str) -> Progress { | |
| 129 | + | let hill = self.hill(); | |
| 130 | + | let active_at = self.active_at.filter(|at| !at.is_empty()); | |
| 131 | + | Progress { | |
| 132 | + | number: self.number, | |
| 133 | + | hill, | |
| 134 | + | quiet: active_at.as_deref().is_some_and(|at| at < quiet_before), | |
| 135 | + | active_at, | |
| 136 | + | standing: self.standing, | |
| 137 | + | standing_at: self.standing_at, | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// The RFC 3339 time `days` before `now_ms`. | |
| 143 | + | pub(crate) fn days_before(now_ms: u64, days: u32) -> String { | |
| 144 | + | rfc3339(now_ms.saturating_sub(u64::from(days) * 86_400_000)) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// What closed from Now since `?5`, the most recently closed first. | |
| 148 | + | const LANDED: &str = "SELECT {ITEM_COLUMNS} FROM work_items | |
| 149 | + | WHERE work_items.workspace = ?1 AND work_items.state = 'closed' AND work_items.number IS NOT NULL | |
| 150 | + | AND work_items.horizon = 'now' AND work_items.closed_at >= ?5 | |
| 151 | + | AND (?4 IS NULL OR EXISTS (SELECT 1 FROM work_item_repos r WHERE r.item_id = work_items.id AND r.repo_id = ?4)) | |
| 152 | + | AND (?2 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id | |
| 153 | + | WHERE o.item_id = work_items.id AND o.role = 'origin' | |
| 154 | + | AND (rw.private = 0 OR o.repo_id IN (SELECT value FROM json_each(?3))))) | |
| 155 | + | ORDER BY work_items.closed_at DESC LIMIT 10"; | |
| 156 | + | ||
| 157 | + | /// How many have waited in Triage since before `?5`, and how many were set | |
| 158 | + | /// aside since `?6`. | |
| 159 | + | const WAITING: &str = "SELECT | |
| 160 | + | (SELECT count(*) FROM work_items WHERE {OPEN_VISIBLE} AND COALESCE(work_items.horizon, 'triage') = 'triage' | |
| 161 | + | AND work_items.created_at < ?5) AS triage_waiting, | |
| 162 | + | (SELECT count(*) FROM work_items WHERE {OPEN_VISIBLE} AND work_items.horizon = 'set_aside' | |
| 163 | + | AND work_items.planned_at >= ?6) AS set_aside_recent"; | |
| 164 | + | ||
| 165 | + | #[derive(Deserialize)] | |
| 166 | + | struct Waiting { | |
| 167 | + | #[serde(default)] | |
| 168 | + | triage_waiting: u32, | |
| 169 | + | #[serde(default)] | |
| 170 | + | set_aside_recent: u32, | |
| 171 | + | } | |
| 172 | + | ||
| 72 | 173 | #[derive(Deserialize)] | |
| 73 | 174 | struct Neighbours { | |
| 74 | 175 | ahead: Option<String>, | |
| ⋯ | |||
| 356 | 457 | values | |
| 357 | 458 | }; | |
| 358 | 459 | let shown = if a.all { crate::LIST_PAGE } else { NEXT_SHOWN }; | |
| 460 | + | let now_ms = now_ms(); | |
| 461 | + | let week = days_before(now_ms, WEEK_DAYS); | |
| 462 | + | let mut waiting_values = bound(Some(days_before(now_ms, TRIAGE_NUDGE_DAYS).into())); | |
| 463 | + | waiting_values.push(days_before(now_ms, 30).into()); | |
| 359 | 464 | let statements = vec![ | |
| 360 | 465 | self.db.prepare(open_items(&format!("{HORIZON} = 'now'"), crate::LIST_PAGE)).bind(&bound(None))?, | |
| 361 | 466 | self.db.prepare(open_items(&format!("{HORIZON} = 'next'"), shown)).bind(&bound(None))?, | |
| 362 | 467 | self.db.prepare(COUNTS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?, | |
| 363 | 468 | self.db.prepare(LATER_GROUPS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?, | |
| 469 | + | self.db.prepare(LANDED.replace("{ITEM_COLUMNS}", ITEM_COLUMNS)).bind(&bound(Some(week.as_str().into())))?, | |
| 470 | + | self.db.prepare(WAITING.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&waiting_values)?, | |
| 364 | 471 | ]; | |
| 365 | − | let results = self.timing.db(4, self.db.batch(statements)).await?; | |
| 472 | + | let results = self.timing.db(6, self.db.batch(statements)).await?; | |
| 366 | 473 | let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into())); | |
| 367 | 474 | let now = shown_rows(rows(0)?.results::<ItemRow>()?, &a.viewer); | |
| 368 | 475 | let next = shown_rows(rows(1)?.results::<ItemRow>()?, &a.viewer); | |
| ⋯ | |||
| 371 | 478 | counts.iter().filter(|row| row.horizon.as_deref().and_then(Horizon::parse).unwrap_or_default() == horizon).map(|row| row.count).sum() | |
| 372 | 479 | }; | |
| 373 | 480 | let later = rows(3)?.results::<Group>()?.into_iter().map(|group| PlanGroup { repo: group.repo, count: group.count }).collect(); | |
| 481 | + | let landed = shown_rows(rows(4)?.results::<ItemRow>()?, &a.viewer); | |
| 482 | + | let waiting = rows(5)?.results::<Waiting>()?.into_iter().next().unwrap_or(Waiting { triage_waiting: 0, set_aside_recent: 0 }); | |
| 483 | + | // How each in Now stands, in Now's order. | |
| 484 | + | let ids: Vec<&str> = now.iter().map(|item| item.id.as_str()).collect(); | |
| 485 | + | let mut facts: Vec<FactsRow> = if ids.is_empty() { | |
| 486 | + | Vec::new() | |
| 487 | + | } else { | |
| 488 | + | self.db.prepare(FACTS).bind(&[serde_json::to_string(&ids)?.into()])?.all().await?.results::<FactsRow>()? | |
| 489 | + | }; | |
| 490 | + | let quiet_before = days_before(now_ms, QUIET_DAYS); | |
| 491 | + | let progress = now | |
| 492 | + | .iter() | |
| 493 | + | .filter_map(|item| { | |
| 494 | + | let at = facts.iter().position(|row| row.number == item.number)?; | |
| 495 | + | Some(facts.swap_remove(at).progress(&quiet_before)) | |
| 496 | + | }) | |
| 497 | + | .collect(); | |
| 374 | 498 | Ok(Outcome::Ok(Plan { | |
| 499 | + | progress, | |
| 500 | + | landed, | |
| 501 | + | triage_waiting: waiting.triage_waiting, | |
| 502 | + | set_aside_recent: waiting.set_aside_recent, | |
| 375 | 503 | now_cap: self.now_cap(&workspace).await?, | |
| 376 | 504 | now, | |
| 377 | 505 | next, | |
| ⋯ | |||
| 595 | 723 | assert_eq!(groups, [(Some("acme/web".into()), 2)]); | |
| 596 | 724 | } | |
| 597 | 725 | ||
| 726 | + | /// The facts `FACTS` reads for work item `number`. | |
| 727 | + | fn facts(db: &Connection, number: u32) -> FactsRow { | |
| 728 | + | let ids = format!("[\"wki_{number}\"]"); | |
| 729 | + | db.query_row(FACTS, [ids], |row| { | |
| 730 | + | Ok(FactsRow { | |
| 731 | + | number: row.get("number")?, | |
| 732 | + | decisions: row.get("decisions")?, | |
| 733 | + | draft_pulls: row.get("draft_pulls")?, | |
| 734 | + | commits: row.get("commits")?, | |
| 735 | + | open_pulls: row.get("open_pulls")?, | |
| 736 | + | merged_pulls: row.get("merged_pulls")?, | |
| 737 | + | landed_targets: row.get("landed_targets")?, | |
| 738 | + | active_at: row.get("active_at")?, | |
| 739 | + | standing: row.get("standing")?, | |
| 740 | + | standing_at: row.get("standing_at")?, | |
| 741 | + | }) | |
| 742 | + | }) | |
| 743 | + | .unwrap() | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | #[test] | |
| 747 | + | fn the_record_says_how_far_along_each_is() { | |
| 748 | + | let db = acme(); | |
| 749 | + | db.execute_batch( | |
| 750 | + | "UPDATE work_items SET horizon = 'now', updated_at = '2026-10-01T00:00:00Z' WHERE number IN (1, 3, 4); | |
| 751 | + | INSERT INTO decisions (id, repo_id, item_id, kind, confidence, summary, created_at) | |
| 752 | + | VALUES ('dec_1', 'rep_web', 'wki_3', 'proposal', 'approved', 'Rank by fractional keys', '2026-10-10T00:00:00Z'), | |
| 753 | + | ('dec_2', 'rep_web', 'wki_1', 'claim', 'approved', 'Scope: tests', '2026-10-11T00:00:00Z'); | |
| 754 | + | INSERT INTO sessions (id, repo_id, actor_id, actor_name, harness, state, started_at, last_seen_at, item_id) | |
| 755 | + | VALUES ('ses_1', 'rep_web', 'usr_1', 'ana', 'claude_code', 'active', '2026-10-12T00:00:00Z', '2026-10-15T00:00:00Z', 'wki_4'); | |
| 756 | + | INSERT INTO session_touches (session_id, repo_id, kind, value, at) VALUES ('ses_1', 'rep_web', 'commit', 'abc1234', '2026-10-15T00:00:00Z');", | |
| 757 | + | ) | |
| 758 | + | .unwrap(); | |
| 759 | + | // A claim's note is not a decision; nothing else has happened on #1. | |
| 760 | + | let one = facts(&db, 1); | |
| 761 | + | assert_eq!((one.hill(), one.active_at.as_deref()), (Hill::FiguringOut, Some("2026-10-11T00:00:00Z"))); | |
| 762 | + | assert_eq!(facts(&db, 3).hill(), Hill::Decided); | |
| 763 | + | let four = facts(&db, 4); | |
| 764 | + | assert_eq!((four.hill(), four.active_at.as_deref()), (Hill::Building, Some("2026-10-15T00:00:00Z"))); | |
| 765 | + | // Quiet: nothing since before the line. | |
| 766 | + | assert!(facts(&db, 3).progress("2026-10-14T00:00:00Z").quiet); | |
| 767 | + | assert!(!facts(&db, 4).progress("2026-10-14T00:00:00Z").quiet); | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | #[test] | |
| 771 | + | fn the_plan_says_what_waits_too_long() { | |
| 772 | + | let db = acme(); | |
| 773 | + | db.execute_batch( | |
| 774 | + | "UPDATE work_items SET created_at = '2026-10-01T00:00:00Z' WHERE number IN (1, 3); | |
| 775 | + | UPDATE work_items SET created_at = '2026-10-18T00:00:00Z' WHERE number = 4; | |
| 776 | + | UPDATE work_items SET horizon = 'set_aside', planned_at = '2026-10-15T00:00:00Z' WHERE number = 5; | |
| 777 | + | UPDATE work_items SET horizon = 'set_aside', planned_at = '2026-08-01T00:00:00Z' WHERE number = 6;", | |
| 778 | + | ) | |
| 779 | + | .unwrap(); | |
| 780 | + | let values: [&dyn ToSql; 6] = [&"acme", &1, &"[]", &None::<String>, &"2026-10-12T00:00:00Z", &"2026-09-20T00:00:00Z"]; | |
| 781 | + | let (waiting, recent): (u32, u32) = db | |
| 782 | + | .query_row(&WAITING.replace("{OPEN_VISIBLE}", OPEN_VISIBLE), &values[..], |row| Ok((row.get(0)?, row.get(1)?))) | |
| 783 | + | .unwrap(); | |
| 784 | + | // #1 and #3 (and #2, made with no date) in Triage since before the line; #5 set aside lately. | |
| 785 | + | assert_eq!((waiting, recent), (3, 1)); | |
| 786 | + | } | |
| 787 | + | ||
| 598 | 788 | #[test] | |
| 599 | 789 | fn a_move_says_what_changed() { | |
| 600 | 790 | assert_eq!(move_note(Horizon::Triage, Horizon::Next, false, false).as_deref(), Some("moved this from Triage to Next")); | |
| 1 | + | //! The plan reads itself (migrations/0057_plan_reads_itself.sql), on the | |
| 2 | + | //! service's cron, a little at a time: | |
| 3 | + | //! | |
| 4 | + | //! - A work item in Triage gets a suggestion of where it goes: a horizon, | |
| 5 | + | //! an appetite, an open work item it seems to repeat, and why, asked once | |
| 6 | + | //! of the workspace's own AI provider through integrations' `ai_brief` | |
| 7 | + | //! (g1t runs no model of its own). A person accepts it or not. | |
| 8 | + | //! - A work item in Now gets a sentence or two on where it stands, drafted | |
| 9 | + | //! from its record (sessions, decisions, pull requests) whenever | |
| 10 | + | //! something new has happened on it. | |
| 11 | + | //! - A work item in Later with nothing happening on it for | |
| 12 | + | //! [`SET_ASIDE_DAYS`] is set aside, saying so in its conversation. | |
| 13 | + | //! | |
| 14 | + | //! A workspace with no provider chosen gets no suggestions and no drafts; | |
| 15 | + | //! the rest of the plan works the same without them. | |
| 16 | + | ||
| 17 | + | use std::collections::HashSet; | |
| 18 | + | ||
| 19 | + | use g1t_contracts::ai::{AiBriefArgs, MAX_BRIEF_INPUT_CHARS}; | |
| 20 | + | use g1t_contracts::events::WorkItemEvent; | |
| 21 | + | use g1t_contracts::time::rfc3339; | |
| 22 | + | use g1t_contracts::work::*; | |
| 23 | + | use g1t_contracts::Outcome; | |
| 24 | + | use g1t_kit::now_ms; | |
| 25 | + | use serde::Deserialize; | |
| 26 | + | use worker::Result; | |
| 27 | + | ||
| 28 | + | use super::plan::days_before; | |
| 29 | + | use crate::Work; | |
| 30 | + | ||
| 31 | + | /// The most the provider is asked in one run of the cron, across | |
| 32 | + | /// workspaces: suggestions and drafts together. | |
| 33 | + | const CALLS_PER_RUN: u32 = 12; | |
| 34 | + | /// The most set aside in one run. | |
| 35 | + | const SET_ASIDE_PER_RUN: u32 = 50; | |
| 36 | + | /// The most of the plan a suggestion is asked with. | |
| 37 | + | const PLAN_LINES: u32 = 80; | |
| 38 | + | /// The longest suggestion reason and standing kept, in characters. | |
| 39 | + | const WHY_CHARS: usize = 240; | |
| 40 | + | const STANDING_CHARS: usize = 320; | |
| 41 | + | ||
| 42 | + | const SUGGEST_SYSTEM: &str = "You place a new work item in a software team's plan. \ | |
| 43 | + | The horizons: now (what the team is on; only for something urgent or blocking), \ | |
| 44 | + | next (ranked, taken soon), later (wanted, not soon). \ | |
| 45 | + | The appetites: small (one session), medium (a few sessions, about a week), large (a phase to break into smaller items). \ | |
| 46 | + | If it repeats one of the open work items listed, give that one's number. \ | |
| 47 | + | Answer in exactly four lines and nothing else:\n\ | |
| 48 | + | horizon: now|next|later\n\ | |
| 49 | + | appetite: small|medium|large\n\ | |
| 50 | + | duplicate: <number or none>\n\ | |
| 51 | + | why: <one plain sentence>"; | |
| 52 | + | ||
| 53 | + | const STANDING_SYSTEM: &str = "From the record given, say where this piece of work stands now and what comes next, \ | |
| 54 | + | in one or two plain sentences. No preamble, no lists, no headings. Name pull requests and work items as #N."; | |
| 55 | + | ||
| 56 | + | /// What one run may still ask the provider, and the workspaces that have | |
| 57 | + | /// none (asked again on the next run). | |
| 58 | + | struct Budget { | |
| 59 | + | calls: u32, | |
| 60 | + | without: HashSet<String>, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// A suggestion read from the provider's answer: none when it does not | |
| 64 | + | /// say a horizon it may suggest. `known` are the open work items it may | |
| 65 | + | /// say this one repeats. | |
| 66 | + | pub(crate) fn read_suggestion(answer: &str, known: &[u32], at: &str) -> Option<PlanSuggestion> { | |
| 67 | + | let mut horizon = None; | |
| 68 | + | let mut appetite = None; | |
| 69 | + | let mut duplicate_of = None; | |
| 70 | + | let mut why = String::new(); | |
| 71 | + | for line in answer.lines() { | |
| 72 | + | let Some((key, value)) = line.split_once(':') else { | |
| 73 | + | continue; | |
| 74 | + | }; | |
| 75 | + | let value = value.trim().trim_matches(|c: char| c == '*' || c == '`' || c == '"').trim(); | |
| 76 | + | match key.trim().trim_matches(|c: char| c == '*' || c == '-').trim().to_ascii_lowercase().as_str() { | |
| 77 | + | "horizon" => horizon = Horizon::parse(value).filter(|h| matches!(h, Horizon::Now | Horizon::Next | Horizon::Later)), | |
| 78 | + | "appetite" => appetite = Appetite::parse(value), | |
| 79 | + | "duplicate" => { | |
| 80 | + | duplicate_of = value.trim_start_matches('#').parse::<u32>().ok().filter(|number| known.contains(number)); | |
| 81 | + | } | |
| 82 | + | "why" => why = clip(value, WHY_CHARS), | |
| 83 | + | _ => {} | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | Some(PlanSuggestion { horizon: horizon?, appetite, duplicate_of, why, at: at.to_owned() }) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// `text` on one line, at most `limit` characters, cut at a word. | |
| 90 | + | pub(crate) fn clip(text: &str, limit: usize) -> String { | |
| 91 | + | let flat = text.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 92 | + | if flat.chars().count() <= limit { | |
| 93 | + | return flat; | |
| 94 | + | } | |
| 95 | + | let cut: String = flat.chars().take(limit).collect(); | |
| 96 | + | let cut = cut.rsplit_once(' ').map_or(cut.as_str(), |(head, _)| head).trim_end_matches([',', ';', ':', '.']); | |
| 97 | + | format!("{cut}…") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// A drafted standing as kept: one paragraph, bounded; none when the | |
| 101 | + | /// answer has nothing in it. | |
| 102 | + | pub(crate) fn read_standing(answer: &str) -> Option<String> { | |
| 103 | + | // A list marker or bold the model put in front, never a #N it begins with. | |
| 104 | + | let trimmed = answer.trim(); | |
| 105 | + | let trimmed = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")).unwrap_or(trimmed).trim_matches('*'); | |
| 106 | + | let text = clip(trimmed, STANDING_CHARS); | |
| 107 | + | (!text.is_empty()).then_some(text) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[derive(Deserialize)] | |
| 111 | + | struct Pending { | |
| 112 | + | id: String, | |
| 113 | + | workspace: String, | |
| 114 | + | number: u32, | |
| 115 | + | title: String, | |
| 116 | + | body: String, | |
| 117 | + | #[serde(default)] | |
| 118 | + | origin: Option<String>, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | #[derive(Deserialize)] | |
| 122 | + | struct PlanLine { | |
| 123 | + | number: u32, | |
| 124 | + | horizon: String, | |
| 125 | + | title: String, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[derive(Deserialize)] | |
| 129 | + | struct Line { | |
| 130 | + | #[serde(default)] | |
| 131 | + | title: Option<String>, | |
| 132 | + | #[serde(default)] | |
| 133 | + | state: Option<String>, | |
| 134 | + | #[serde(default)] | |
| 135 | + | result: Option<String>, | |
| 136 | + | #[serde(default)] | |
| 137 | + | summary: Option<String>, | |
| 138 | + | #[serde(default)] | |
| 139 | + | number: Option<u32>, | |
| 140 | + | #[serde(default)] | |
| 141 | + | status: Option<String>, | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[derive(Deserialize)] | |
| 145 | + | struct Stale { | |
| 146 | + | id: String, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | impl Work { | |
| 150 | + | /// One run of the plan's upkeep: sets aside what has waited too long in | |
| 151 | + | /// Later, then suggests and drafts within the run's budget. | |
| 152 | + | pub(crate) async fn settle_plan(&self) -> Result<()> { | |
| 153 | + | self.set_aside_stale().await?; | |
| 154 | + | let mut budget = Budget { calls: CALLS_PER_RUN, without: HashSet::new() }; | |
| 155 | + | self.suggest_triage(&mut budget).await?; | |
| 156 | + | self.draft_standings(&mut budget).await?; | |
| 157 | + | Ok(()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Asks the workspace's provider, within `budget`. None when it has no | |
| 161 | + | /// provider, or could not answer (said in the log). | |
| 162 | + | async fn brief(&self, workspace: &str, system: &str, input: String, max_tokens: u32, budget: &mut Budget) -> Option<String> { | |
| 163 | + | if budget.calls == 0 || budget.without.contains(workspace) { | |
| 164 | + | return None; | |
| 165 | + | } | |
| 166 | + | let integrations = self.integrations.as_ref()?; | |
| 167 | + | budget.calls -= 1; | |
| 168 | + | let input: String = input.chars().take(MAX_BRIEF_INPUT_CHARS).collect(); | |
| 169 | + | let args = AiBriefArgs { workspace: workspace.to_owned(), system: system.to_owned(), input, max_tokens }; | |
| 170 | + | match g1t_kit::call::<_, Outcome<Option<String>>>(integrations, "ai_brief", &args).await { | |
| 171 | + | Ok(Outcome::Ok(Some(answer))) => Some(answer), | |
| 172 | + | Ok(Outcome::Ok(None)) => { | |
| 173 | + | budget.without.insert(workspace.to_owned()); | |
| 174 | + | None | |
| 175 | + | } | |
| 176 | + | Ok(Outcome::Fail(failure)) => { | |
| 177 | + | worker::console_error!("{workspace}: the plan got no answer from the AI provider: {}", failure.message); | |
| 178 | + | budget.without.insert(workspace.to_owned()); | |
| 179 | + | None | |
| 180 | + | } | |
| 181 | + | Err(error) => { | |
| 182 | + | worker::console_error!("{workspace}: the plan got no answer from the AI provider: {error}"); | |
| 183 | + | budget.without.insert(workspace.to_owned()); | |
| 184 | + | None | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// Sets aside what has had nothing happen on it in Later for | |
| 190 | + | /// [`SET_ASIDE_DAYS`]. | |
| 191 | + | async fn set_aside_stale(&self) -> Result<()> { | |
| 192 | + | let now = now_ms(); | |
| 193 | + | let before = days_before(now, SET_ASIDE_DAYS); | |
| 194 | + | let stale = self | |
| 195 | + | .db | |
| 196 | + | .prepare( | |
| 197 | + | "SELECT id FROM work_items | |
| 198 | + | WHERE state = 'open' AND horizon = 'later' AND number IS NOT NULL | |
| 199 | + | AND max(updated_at, COALESCE(planned_at, '')) < ?1 | |
| 200 | + | LIMIT ?2", | |
| 201 | + | ) | |
| 202 | + | .bind(&[before.as_str().into(), SET_ASIDE_PER_RUN.into()])? | |
| 203 | + | .all() | |
| 204 | + | .await? | |
| 205 | + | .results::<Stale>()?; | |
| 206 | + | let at = rfc3339(now); | |
| 207 | + | for row in stale { | |
| 208 | + | self.db | |
| 209 | + | .prepare("UPDATE work_items SET horizon = 'set_aside', rank = NULL, planned_at = ?1 WHERE id = ?2 AND horizon = 'later'") | |
| 210 | + | .bind(&[at.as_str().into(), row.id.as_str().into()])? | |
| 211 | + | .run() | |
| 212 | + | .await?; | |
| 213 | + | let Some(held) = self.item_by_id(&row.id).await? else { | |
| 214 | + | continue; | |
| 215 | + | }; | |
| 216 | + | self.publish_item( | |
| 217 | + | "work_item.planned", | |
| 218 | + | &held, | |
| 219 | + | None, | |
| 220 | + | WorkItemEvent { | |
| 221 | + | horizon: Some(Horizon::SetAside), | |
| 222 | + | from_horizon: Some(Horizon::Later), | |
| 223 | + | appetite: held.item.appetite, | |
| 224 | + | urgent: Some(false), | |
| 225 | + | ..Self::item_event(&held) | |
| 226 | + | }, | |
| 227 | + | ) | |
| 228 | + | .await?; | |
| 229 | + | self.note_item( | |
| 230 | + | &row.id, | |
| 231 | + | (g1t_contracts::identity::AGENT_ID, "g1t"), | |
| 232 | + | &format!("set this aside: nothing happened on it in Later for {SET_ASIDE_DAYS} days"), | |
| 233 | + | ) | |
| 234 | + | .await?; | |
| 235 | + | } | |
| 236 | + | Ok(()) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// Suggests where new work items in Triage go, the newest first. | |
| 240 | + | async fn suggest_triage(&self, budget: &mut Budget) -> Result<()> { | |
| 241 | + | let pending = self | |
| 242 | + | .db | |
| 243 | + | .prepare( | |
| 244 | + | "SELECT w.id, w.workspace, w.number, w.title, w.body, | |
| 245 | + | (SELECT COALESCE(rw.path, o.path) FROM work_item_repos o LEFT JOIN repo_workspaces rw ON rw.repo_id = o.repo_id | |
| 246 | + | WHERE o.item_id = w.id AND o.role = 'origin') AS origin | |
| 247 | + | FROM work_items w | |
| 248 | + | WHERE w.state = 'open' AND COALESCE(w.horizon, 'triage') = 'triage' AND w.suggested_at IS NULL | |
| 249 | + | AND w.number IS NOT NULL | |
| 250 | + | ORDER BY w.created_at DESC LIMIT ?1", | |
| 251 | + | ) | |
| 252 | + | .bind(&[budget.calls.into()])? | |
| 253 | + | .all() | |
| 254 | + | .await? | |
| 255 | + | .results::<Pending>()?; | |
| 256 | + | for item in pending { | |
| 257 | + | if budget.calls == 0 { | |
| 258 | + | break; | |
| 259 | + | } | |
| 260 | + | let plan = self | |
| 261 | + | .db | |
| 262 | + | .prepare( | |
| 263 | + | "SELECT number, COALESCE(horizon, 'triage') AS horizon, title FROM work_items | |
| 264 | + | WHERE workspace = ?1 AND state = 'open' AND number IS NOT NULL AND id <> ?2 | |
| 265 | + | ORDER BY CASE COALESCE(horizon, 'triage') WHEN 'now' THEN 0 WHEN 'next' THEN 1 WHEN 'later' THEN 2 | |
| 266 | + | WHEN 'triage' THEN 3 ELSE 4 END, rank IS NULL, rank, number DESC | |
| 267 | + | LIMIT ?3", | |
| 268 | + | ) | |
| 269 | + | .bind(&[item.workspace.as_str().into(), item.id.as_str().into(), PLAN_LINES.into()])? | |
| 270 | + | .all() | |
| 271 | + | .await? | |
| 272 | + | .results::<PlanLine>()?; | |
| 273 | + | let known: Vec<u32> = plan.iter().map(|line| line.number).collect(); | |
| 274 | + | let listed: Vec<String> = plan.iter().map(|line| format!("#{} [{}] {}", line.number, line.horizon, line.title)).collect(); | |
| 275 | + | let input = format!( | |
| 276 | + | "New work item #{}{}: {}\n\n{}\n\nOpen work items:\n{}", | |
| 277 | + | item.number, | |
| 278 | + | item.origin.as_deref().map(|path| format!(" (filed in {path})")).unwrap_or_default(), | |
| 279 | + | item.title, | |
| 280 | + | clip(&item.body, 1_500), | |
| 281 | + | listed.join("\n") | |
| 282 | + | ); | |
| 283 | + | let at = rfc3339(now_ms()); | |
| 284 | + | let answer = self.brief(&item.workspace, SUGGEST_SYSTEM, input, 120, budget).await; | |
| 285 | + | if answer.is_none() && !budget.without.contains(&item.workspace) { | |
| 286 | + | // Out of calls for this run: asked again on the next. | |
| 287 | + | break; | |
| 288 | + | } | |
| 289 | + | let suggestion = answer.as_deref().and_then(|answer| read_suggestion(answer, &known, &at)); | |
| 290 | + | // Asked once: with no provider, or no usable answer, it is not | |
| 291 | + | // asked again. | |
| 292 | + | self.db | |
| 293 | + | .prepare("UPDATE work_items SET suggestion = ?1, suggested_at = ?2 WHERE id = ?3") | |
| 294 | + | .bind(&[ | |
| 295 | + | suggestion.as_ref().map(serde_json::to_string).transpose()?.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 296 | + | at.as_str().into(), | |
| 297 | + | item.id.as_str().into(), | |
| 298 | + | ])? | |
| 299 | + | .run() | |
| 300 | + | .await?; | |
| 301 | + | } | |
| 302 | + | Ok(()) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Drafts where work items in Now stand, those with something new | |
| 306 | + | /// since their last draft first. | |
| 307 | + | async fn draft_standings(&self, budget: &mut Budget) -> Result<()> { | |
| 308 | + | #[derive(Deserialize)] | |
| 309 | + | struct Due { | |
| 310 | + | id: String, | |
| 311 | + | workspace: String, | |
| 312 | + | number: u32, | |
| 313 | + | title: String, | |
| 314 | + | } | |
| 315 | + | let due = self | |
| 316 | + | .db | |
| 317 | + | .prepare( | |
| 318 | + | "SELECT id, workspace, number, title FROM ( | |
| 319 | + | SELECT w.id, w.workspace, w.number, w.title, w.standing_at, | |
| 320 | + | max(COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.item_id = w.id), ''), | |
| 321 | + | COALESCE((SELECT max(d.created_at) FROM decisions d WHERE d.item_id = w.id), ''), | |
| 322 | + | COALESCE((SELECT max(p.updated_at) FROM pulls p WHERE p.item_id = w.id), '')) AS active_at | |
| 323 | + | FROM work_items w WHERE w.state = 'open' AND w.horizon = 'now' AND w.number IS NOT NULL) | |
| 324 | + | WHERE active_at <> '' AND (standing_at IS NULL OR standing_at < active_at) | |
| 325 | + | ORDER BY active_at DESC LIMIT ?1", | |
| 326 | + | ) | |
| 327 | + | .bind(&[budget.calls.into()])? | |
| 328 | + | .all() | |
| 329 | + | .await? | |
| 330 | + | .results::<Due>()?; | |
| 331 | + | for item in due { | |
| 332 | + | if budget.calls == 0 { | |
| 333 | + | break; | |
| 334 | + | } | |
| 335 | + | let key = [worker::wasm_bindgen::JsValue::from(item.id.as_str())]; | |
| 336 | + | let statements = vec![ | |
| 337 | + | self.db | |
| 338 | + | .prepare("SELECT title, state, result FROM sessions WHERE item_id = ?1 ORDER BY last_seen_at DESC LIMIT 5") | |
| 339 | + | .bind(&key)?, | |
| 340 | + | self.db.prepare("SELECT summary FROM decisions WHERE item_id = ?1 ORDER BY created_at DESC LIMIT 5").bind(&key)?, | |
| 341 | + | self.db.prepare("SELECT number, status, title FROM pulls WHERE item_id = ?1 ORDER BY created_at DESC LIMIT 5").bind(&key)?, | |
| 342 | + | ]; | |
| 343 | + | let results = self.db.batch(statements).await?; | |
| 344 | + | let lines = |index: usize| -> Result<Vec<Line>> { | |
| 345 | + | results.get(index).map_or_else(|| Ok(Vec::new()), |result| result.results::<Line>()) | |
| 346 | + | }; | |
| 347 | + | let sessions: Vec<String> = lines(0)? | |
| 348 | + | .into_iter() | |
| 349 | + | .map(|line| { | |
| 350 | + | let result = line.result.as_deref().map(|text| format!(": {}", clip(text, 300))).unwrap_or_default(); | |
| 351 | + | format!("- {} ({}){result}", line.title.unwrap_or_else(|| "Untitled session".into()), line.state.unwrap_or_default()) | |
| 352 | + | }) | |
| 353 | + | .collect(); | |
| 354 | + | let decisions: Vec<String> = lines(1)?.into_iter().filter_map(|line| line.summary).map(|summary| format!("- {}", clip(&summary, 300))).collect(); | |
| 355 | + | let pulls: Vec<String> = lines(2)? | |
| 356 | + | .into_iter() | |
| 357 | + | .map(|line| format!("- #{} {} ({})", line.number.unwrap_or_default(), line.title.unwrap_or_default(), line.status.unwrap_or_default())) | |
| 358 | + | .collect(); | |
| 359 | + | let none = || "- none".to_owned(); | |
| 360 | + | let input = format!( | |
| 361 | + | "Work item #{}: {}\n\nSessions, newest first:\n{}\n\nDecisions, newest first:\n{}\n\nPull requests:\n{}", | |
| 362 | + | item.number, | |
| 363 | + | item.title, | |
| 364 | + | if sessions.is_empty() { none() } else { sessions.join("\n") }, | |
| 365 | + | if decisions.is_empty() { none() } else { decisions.join("\n") }, | |
| 366 | + | if pulls.is_empty() { none() } else { pulls.join("\n") }, | |
| 367 | + | ); | |
| 368 | + | let answer = self.brief(&item.workspace, STANDING_SYSTEM, input, 120, budget).await; | |
| 369 | + | if answer.is_none() && !budget.without.contains(&item.workspace) { | |
| 370 | + | break; | |
| 371 | + | } | |
| 372 | + | // With no provider, the time is still kept, so it is asked again | |
| 373 | + | // only when something new happens. | |
| 374 | + | let standing = answer.as_deref().and_then(read_standing); | |
| 375 | + | self.db | |
| 376 | + | .prepare("UPDATE work_items SET standing = COALESCE(?1, standing), standing_at = ?2 WHERE id = ?3") | |
| 377 | + | .bind(&[ | |
| 378 | + | standing.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 379 | + | rfc3339(now_ms()).into(), | |
| 380 | + | item.id.as_str().into(), | |
| 381 | + | ])? | |
| 382 | + | .run() | |
| 383 | + | .await?; | |
| 384 | + | } | |
| 385 | + | Ok(()) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[cfg(test)] | |
| 390 | + | mod tests { | |
| 391 | + | use super::*; | |
| 392 | + | ||
| 393 | + | #[test] | |
| 394 | + | fn a_suggestion_is_read_from_the_four_lines() { | |
| 395 | + | let at = "2026-10-20T00:00:00Z"; | |
| 396 | + | let answer = "horizon: next\nappetite: small\nduplicate: #12\nwhy: It is a small fix to Home that others wait on."; | |
| 397 | + | let read = read_suggestion(answer, &[12, 14], at).unwrap(); | |
| 398 | + | assert_eq!(read.horizon, Horizon::Next); | |
| 399 | + | assert_eq!(read.appetite, Some(Appetite::Small)); | |
| 400 | + | assert_eq!(read.duplicate_of, Some(12)); | |
| 401 | + | assert_eq!(read.why, "It is a small fix to Home that others wait on."); | |
| 402 | + | // Markdown around it, and a number not in the plan. | |
| 403 | + | let read = read_suggestion("**Horizon:** Later\n- Appetite: L\nDuplicate: 99\nWhy: Big.", &[12], at).unwrap(); | |
| 404 | + | assert_eq!((read.horizon, read.appetite, read.duplicate_of), (Horizon::Later, Some(Appetite::Large), None)); | |
| 405 | + | // A horizon it may not suggest, or none: no suggestion. | |
| 406 | + | assert_eq!(read_suggestion("horizon: set_aside\nwhy: old", &[], at), None); | |
| 407 | + | assert_eq!(read_suggestion("I think this should go next.", &[], at), None); | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | #[test] | |
| 411 | + | fn what_is_kept_is_one_bounded_line() { | |
| 412 | + | assert_eq!(clip("Two words\nhere", 50), "Two words here"); | |
| 413 | + | let long = "word ".repeat(100); | |
| 414 | + | let clipped = clip(&long, 30); | |
| 415 | + | assert!(clipped.chars().count() <= 31 && clipped.ends_with('…'), "{clipped}"); | |
| 416 | + | assert_eq!(read_standing(" \n "), None); | |
| 417 | + | assert_eq!(read_standing("- #205 merged; the plan is live. Next: place the backlog.").as_deref(), Some("#205 merged; the plan is live. Next: place the backlog.")); | |
| 418 | + | } | |
| 419 | + | } |
| 82 | 82 | pub urgent: u8, | |
| 83 | 83 | #[serde(default)] | |
| 84 | 84 | pub planned_at: Option<String>, | |
| 85 | + | /// JSON [`PlanSuggestion`] (plan_settle.rs). | |
| 86 | + | #[serde(default)] | |
| 87 | + | pub suggestion: Option<String>, | |
| 85 | 88 | } | |
| 86 | 89 | ||
| 87 | 90 | /// A link as [`ITEM_COLUMNS`] reads it. | |
| ⋯ | |||
| 288 | 291 | budget_usd: self.budget_usd, | |
| 289 | 292 | urgent: self.urgent != 0, | |
| 290 | 293 | planned_at: self.planned_at, | |
| 294 | + | // Only while it waits to be placed. | |
| 295 | + | suggestion: self | |
| 296 | + | .suggestion | |
| 297 | + | .as_deref() | |
| 298 | + | .filter(|_| self.horizon.unwrap_or_default() == Horizon::Triage && self.state == State::Open) | |
| 299 | + | .and_then(|json| serde_json::from_str(json).ok()), | |
| 291 | 300 | }, | |
| 292 | 301 | origin, | |
| 293 | 302 | known, | |
Comments
Sign in to comment.