28 files+1469−420/28 viewed
| 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, | |
Loading the diff…
Comments
Sign in to comment.