Commit

Dark gray base with lavender as an accent, and a live outcome view for plans

The palette moves to near-neutral dark grays; lavender and peach are accents for art and status, mint for what is live or passed. An applied plan's page now shows the outcome converging: each issue as a card in a dependency graph with where it stands (blocked, waiting, being worked, in review, in the queue, landed, needing you), how many have landed, how many agents are at work, and what they have cost.

syntaqxcommitted Parent0d4781dBrowse files
8 files+464−320/8 viewed
+11−13
186186 }
187187
188188 /*
189− * The sky after dusk: deep indigo overhead, a lavender and peach glow at
190− * the horizon, a few stars. Behind the landing page's opening.
189+ * Behind the landing page's opening: the same dark gray, with a faint
190+ * lavender and peach glow low on the horizon and a few stars.
191191 */
192192 .dusk {
193193 background:
194− radial-gradient(1px 1px at 12% 18%, rgb(255 255 255 / 0.5), transparent),
195− radial-gradient(1px 1px at 27% 8%, rgb(255 255 255 / 0.35), transparent),
196− radial-gradient(1px 1px at 44% 22%, rgb(255 255 255 / 0.3), transparent),
197− radial-gradient(1px 1px at 63% 12%, rgb(255 255 255 / 0.45), transparent),
198− radial-gradient(1px 1px at 81% 26%, rgb(255 255 255 / 0.3), transparent),
199− radial-gradient(1.5px 1.5px at 90% 9%, rgb(255 255 255 / 0.5), transparent),
200− radial-gradient(70% 45% at 50% 100%, rgb(255 189 140 / 0.22), transparent 70%),
201− radial-gradient(90% 60% at 50% 95%, rgb(182 168 255 / 0.28), transparent 75%),
202− linear-gradient(to bottom, #0b0c18 0%, #15163a 55%, #2a2556 100%);
194+ radial-gradient(1px 1px at 12% 18%, rgb(255 255 255 / 0.35), transparent),
195+ radial-gradient(1px 1px at 27% 8%, rgb(255 255 255 / 0.25), transparent),
196+ radial-gradient(1px 1px at 63% 12%, rgb(255 255 255 / 0.3), transparent),
197+ radial-gradient(1.5px 1.5px at 90% 9%, rgb(255 255 255 / 0.35), transparent),
198+ radial-gradient(60% 35% at 50% 100%, rgb(255 189 140 / 0.08), transparent 70%),
199+ radial-gradient(80% 45% at 50% 100%, rgb(182 168 255 / 0.1), transparent 75%),
200+ var(--color-bg);
203201 }
204202 /* A planet rising behind the horizon, its lit edge catching the glow. */
205203 .dusk-planet {
206− background: radial-gradient(circle at 70% 30%, rgb(182 168 255 / 0.35), rgb(255 189 140 / 0.12) 45%, transparent 70%);
207− box-shadow: inset 18px -10px 60px rgb(255 214 186 / 0.18);
204+ background: radial-gradient(circle at 70% 30%, rgb(182 168 255 / 0.12), rgb(255 189 140 / 0.04) 45%, transparent 70%);
205+ box-shadow: inset 14px -8px 50px rgb(255 214 186 / 0.07);
208206 }
+280−0
1+import {
2+ Check,
3+ CircleDashed,
4+ Eye,
5+ Hand,
6+ Layers,
7+ Lock,
8+ Loader2,
9+ Sparkles,
10+ Terminal,
11+ X,
12+} from "lucide-react";
13+import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
14+import { Link } from "react-router";
15+
16+import type { IssueProgress, Plan } from "@g1t/contracts";
17+
18+import { Avatar } from "./ui";
19+
20+type Look = { label: string; icon: ReactNode; ring: string; text: string; bar: string; live?: boolean };
21+
22+/** How each state looks: its colour, icon and a word for it. */
23+const LOOK: Record<string, Look> = {
24+ blocked: {
25+ label: "Blocked",
26+ icon: <Lock size={13} />,
27+ ring: "ring-line border-dashed",
28+ text: "text-faint",
29+ bar: "bg-line-strong",
30+ },
31+ open: { label: "Open", icon: <CircleDashed size={13} />, ring: "ring-line", text: "text-muted", bar: "bg-line-strong" },
32+ waiting: {
33+ label: "Waiting for an agent",
34+ icon: <CircleDashed size={13} />,
35+ ring: "ring-line-strong",
36+ text: "text-muted",
37+ bar: "bg-faint",
38+ },
39+ working: {
40+ label: "Agent working",
41+ icon: <Sparkles size={13} />,
42+ ring: "ring-merged/60",
43+ text: "text-merged",
44+ bar: "bg-merged",
45+ live: true,
46+ },
47+ revising: {
48+ label: "Revising",
49+ icon: <Sparkles size={13} />,
50+ ring: "ring-merged/60",
51+ text: "text-merged",
52+ bar: "bg-merged",
53+ live: true,
54+ },
55+ catching_up: {
56+ label: "Catching up",
57+ icon: <Loader2 size={13} className="animate-spin" />,
58+ ring: "ring-merged/60",
59+ text: "text-merged",
60+ bar: "bg-merged",
61+ live: true,
62+ },
63+ checking: {
64+ label: "Checking",
65+ icon: <Terminal size={13} />,
66+ ring: "ring-info/60",
67+ text: "text-info",
68+ bar: "bg-info",
69+ live: true,
70+ },
71+ reviewing: {
72+ label: "In review",
73+ icon: <Eye size={13} />,
74+ ring: "ring-info/60",
75+ text: "text-info",
76+ bar: "bg-info",
77+ live: true,
78+ },
79+ queued: {
80+ label: "In the merge queue",
81+ icon: <Layers size={13} />,
82+ ring: "ring-accent/60",
83+ text: "text-accent",
84+ bar: "bg-accent/60",
85+ live: true,
86+ },
87+ ready: { label: "Ready to merge", icon: <Check size={13} />, ring: "ring-accent/60", text: "text-accent", bar: "bg-accent/60" },
88+ needs_you: { label: "Needs you", icon: <Hand size={13} />, ring: "ring-warn/70", text: "text-warn", bar: "bg-warn" },
89+ landed: { label: "Landed", icon: <Check size={13} />, ring: "ring-accent/30", text: "text-accent", bar: "bg-accent" },
90+ closed: { label: "Closed", icon: <X size={13} />, ring: "ring-line", text: "text-faint", bar: "bg-line" },
91+};
92+
93+const look = (state: string) => LOOK[state] ?? LOOK.open!;
94+
95+/** Columns by how deep each issue sits in the plan's dependencies. */
96+function columns(plan: Plan): { position: number; item: IssueProgress }[][] {
97+ const byNumber = new Map(plan.progress.map((item) => [item.number, item]));
98+ const depth = new Map<number, number>();
99+ const depthOf = (position: number, seen = new Set<number>()): number => {
100+ if (depth.has(position)) return depth.get(position)!;
101+ if (seen.has(position)) return 0;
102+ seen.add(position);
103+ const deps = plan.issues[position - 1]?.dependsOn ?? [];
104+ const value = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => depthOf(dep, seen)));
105+ depth.set(position, value);
106+ return value;
107+ };
108+ const result: { position: number; item: IssueProgress }[][] = [];
109+ plan.issues.forEach((issue, index) => {
110+ const item = issue.number != null ? byNumber.get(issue.number) : undefined;
111+ if (!item) return;
112+ const column = depthOf(index + 1);
113+ (result[column] ??= []).push({ position: index + 1, item });
114+ });
115+ return result.filter(Boolean);
116+}
117+
118+function Node({ item, base }: { item: IssueProgress; base: string }) {
119+ const style = look(item.state);
120+ const to = item.pull != null ? `${base}/pull/${item.pull}` : `${base}/issues/${item.number}`;
121+ return (
122+ <Link
123+ to={to}
124+ prefetch="intent"
125+ data-node={item.number}
126+ className={`group relative block rounded-2xl border border-transparent bg-surface p-4 ring-1 transition-all hover:-translate-y-0.5 hover:bg-raised ${style.ring}`}
127+ >
128+ {style.live && (
129+ <span aria-hidden="true" className={`absolute top-3 right-3 size-2 animate-pulse rounded-full ${style.bar}`} />
130+ )}
131+ <p className={`flex items-center gap-1.5 text-xs font-medium ${style.text}`}>
132+ {style.icon}
133+ {style.label}
134+ </p>
135+ <p className={`mt-2 text-sm leading-snug font-medium ${item.state === "closed" ? "text-faint line-through" : ""}`}>
136+ {item.title} <span className="font-normal text-faint">#{item.number}</span>
137+ </p>
138+ {item.detail && <p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted">{item.detail}</p>}
139+ {item.agent && (
140+ <p className="mt-2.5 flex items-center gap-1.5 font-mono text-[0.6875rem] text-faint">
141+ <Avatar name={item.agent} size={14} />
142+ {item.agent}
143+ {item.pull != null && <span>· #{item.pull}</span>}
144+ </p>
145+ )}
146+ </Link>
147+ );
148+}
149+
150+/** Lines from each issue to the ones that depend on it, measured from the page. */
151+function Edges({ plan, container }: { plan: Plan; container: React.RefObject<HTMLDivElement | null> }) {
152+ const [paths, setPaths] = useState<{ d: string; done: boolean }[]>([]);
153+ useLayoutEffect(() => {
154+ const element = container.current;
155+ if (!element) return;
156+ const draw = () => {
157+ const box = element.getBoundingClientRect();
158+ const at = (number: number) =>
159+ element.querySelector<HTMLElement>(`[data-node="${number}"]`)?.getBoundingClientRect();
160+ const next: { d: string; done: boolean }[] = [];
161+ plan.issues.forEach((issue) => {
162+ if (issue.number == null) return;
163+ const to = at(issue.number);
164+ for (const dep of issue.dependsOn) {
165+ const fromNumber = plan.issues[dep - 1]?.number;
166+ if (fromNumber == null) continue;
167+ const from = at(fromNumber);
168+ if (!from || !to) continue;
169+ const x1 = from.right - box.left;
170+ const y1 = from.top + from.height / 2 - box.top;
171+ const x2 = to.left - box.left;
172+ const y2 = to.top + to.height / 2 - box.top;
173+ const mid = (x1 + x2) / 2;
174+ const done = plan.progress.find((item) => item.number === fromNumber)?.state === "landed";
175+ next.push({ d: `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}`, done });
176+ }
177+ });
178+ setPaths(next);
179+ };
180+ draw();
181+ const observer = new ResizeObserver(draw);
182+ observer.observe(element);
183+ return () => observer.disconnect();
184+ }, [plan, container]);
185+ return (
186+ <svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full overflow-visible">
187+ {paths.map((path, index) => (
188+ <path
189+ key={index}
190+ d={path.d}
191+ fill="none"
192+ stroke={path.done ? "var(--color-accent)" : "var(--color-line-strong)"}
193+ strokeOpacity={path.done ? 0.7 : 1}
194+ strokeWidth={1.5}
195+ strokeDasharray={path.done ? undefined : "4 5"}
196+ className={path.done ? undefined : "art-flow"}
197+ />
198+ ))}
199+ </svg>
200+ );
201+}
202+
203+/**
204+ * An outcome being converged: the plan's issues as a graph, each showing
205+ * where it stands now, and how far the whole has come.
206+ */
207+export function Outcome({ plan, base, costMicros }: { plan: Plan; base: string; costMicros: number | null }) {
208+ const graph = useRef<HTMLDivElement>(null);
209+ const total = plan.progress.length;
210+ const count = (states: string[]) => plan.progress.filter((item) => states.includes(item.state)).length;
211+ const landed = count(["landed"]);
212+ const live = plan.progress.filter((item) => look(item.state).live).length;
213+ const needsYou = count(["needs_you"]);
214+ const blocked = count(["blocked", "waiting", "open"]);
215+ const order = ["landed", "queued", "ready", "reviewing", "checking", "working", "revising", "catching_up", "needs_you", "waiting", "blocked", "open", "closed"];
216+ const sorted = [...plan.progress].sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state));
217+
218+ return (
219+ <div>
220+ <div className="grid gap-4 sm:grid-cols-4">
221+ {[
222+ ["Landed", `${landed} of ${total}`, "text-accent"],
223+ ["Agents at work", String(live), "text-merged"],
224+ ["Needs you", String(needsYou), needsYou > 0 ? "text-warn" : "text-muted"],
225+ [
226+ "Agents have cost",
227+ costMicros == null ? "—" : `$${(costMicros / 1_000_000).toFixed(2)}`,
228+ "text-fg",
229+ ],
230+ ].map(([label, value, tone]) => (
231+ <div key={label} className="rounded-2xl bg-surface px-4 py-3 ring-1 ring-line">
232+ <p className="text-xs text-muted">{label}</p>
233+ <p className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>{value}</p>
234+ </div>
235+ ))}
236+ </div>
237+ {/* Every issue, as one bar. */}
238+ <div className="mt-4 flex h-2 gap-0.5 overflow-hidden rounded-full" role="img" aria-label={`${landed} of ${total} landed`}>
239+ {sorted.map((item) => (
240+ <span key={item.number} className={`grow transition-colors duration-700 ${look(item.state).bar}`} />
241+ ))}
242+ </div>
243+ <p className="mt-2 text-xs text-muted">
244+ {landed === total
245+ ? "Everything in the plan has landed."
246+ : `${live} in flight, ${blocked} waiting their turn${needsYou ? `, ${needsYou} waiting on you` : ""}.`}
247+ </p>
248+
249+ {columns(plan).length === 1 ? (
250+ // Nothing depends on anything: every issue starts at once, side by side.
251+ <div className="mt-10">
252+ <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">
253+ All start at once
254+ </p>
255+ <div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
256+ {columns(plan)[0]!.map(({ item }) => (
257+ <Node key={item.number} item={item} base={base} />
258+ ))}
259+ </div>
260+ </div>
261+ ) : (
262+ <div className="mt-10 overflow-x-auto pb-4">
263+ <div ref={graph} className="relative flex min-w-max gap-14">
264+ <Edges plan={plan} container={graph} />
265+ {columns(plan).map((column, index) => (
266+ <div key={index} className="flex w-64 flex-col justify-center gap-4">
267+ <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">
268+ {index === 0 ? "Starts at once" : `Then, step ${index + 1}`}
269+ </p>
270+ {column.map(({ item }) => (
271+ <Node key={item.number} item={item} base={base} />
272+ ))}
273+ </div>
274+ ))}
275+ </div>
276+ </div>
277+ )}
278+ </div>
279+ );
280+}
+1−1
332332 <head>
333333 <meta charSet="utf-8" />
334334 <meta name="viewport" content="width=device-width, initial-scale=1" />
335− <meta name="theme-color" content="#0b0c18" />
335+ <meta name="theme-color" content="#0f0f11" />
336336 <Meta />
337337 <Links />
338338 </head>
+30−6
55
66 import type { Route } from "./+types/plan";
77 import { Markdown } from "../../components/markdown";
8+import { Outcome } from "../../components/outcome";
89 import { Button, ErrorText, TimeAgo } from "../../components/ui";
910 import { Label } from "../../components/work";
10−import { work } from "../../lib/services.server";
11+import { billing, work } from "../../lib/services.server";
1112 import {
1213 assertSameOrigin,
1314 getViewer,
2728 // Members only; to anyone else the page does not exist.
2829 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2930 const path = { namespace: params.owner, name: params.repo };
30− return { plan: unwrap(await work.getPlan(path, viewer, params.id)) };
31+ const [plan, ledger] = await Promise.all([
32+ work.getPlan(path, viewer, params.id),
33+ billing.ledger(params.owner, viewer),
34+ ]);
35+ const found = unwrap(plan);
36+ // What the agents working on this outcome have cost so far.
37+ const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
38+ const repo = `${params.owner}/${params.repo}`;
39+ const costMicros = ledger.ok
40+ ? ledger.value
41+ .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
42+ .reduce((sum, entry) => sum - entry.amountMicros, 0)
43+ : null;
44+ return { plan: found, costMicros };
3145 }
3246
3347 export async function action({ request, params, context }: Route.ActionArgs) {
4357 }
4458
4559 export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
46− const { plan } = loaderData;
60+ const { plan, costMicros } = loaderData;
4761 const base = `/${params.owner}/${params.repo}`;
4862 const applying = useNavigation().state === "submitting";
4963
5064 // The agent is still writing it.
5165 const revalidator = useRevalidator();
5266 const planning = plan.status === "planning";
67+ // Planning, or agents still converging what was applied.
68+ const moving =
69+ planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
5370 useEffect(() => {
54− if (!planning) return;
71+ if (!moving) return;
5572 const timer = setInterval(() => {
5673 if (document.visibilityState === "visible") revalidator.revalidate();
5774 }, REFRESH_MS);
5875 return () => clearInterval(timer);
59− }, [planning, revalidator]);
76+ }, [moving, revalidator]);
77+ const converging = plan.status === "applied" && plan.progress.length > 0;
6078
6179 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
6280 return (
63− <div className="max-w-4xl">
81+ <div className={converging ? "" : "max-w-4xl"}>
6482 <p className="text-sm">
6583 <Link to={`${base}/plans`} className="text-muted hover:text-fg">
6684 Plans
7492 Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
7593 </p>
7694
95+ {converging && (
96+ <section className="mt-8">
97+ <Outcome plan={plan} base={base} costMicros={costMicros} />
98+ </section>
99+ )}
100+
77101 {planning && (
78102 <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
79103 <span className="size-2 animate-pulse rounded-full bg-accent" />
+25−0
792792 pub created_at: String,
793793 /// RFC 3339.
794794 pub finished_at: Option<String>,
795+ /// Once applied: where each issue it opened stands now, in plan order.
796+ /// Filled in by `get_plan` only.
797+ #[serde(default)]
798+ pub progress: Vec<IssueProgress>,
799+}
800+
801+/// Where one issue of an applied plan stands.
802+#[derive(Clone, Debug, Serialize, Deserialize)]
803+#[serde(rename_all = "camelCase")]
804+pub struct IssueProgress {
805+ pub number: u32,
806+ pub title: String,
807+ /// `blocked` (waiting on issues it depends on), `waiting` (for an
808+ /// agent), `open` (nobody on it), one of the lifecycle stages
809+ /// (`working`, `checking`, `reviewing`, `revising`, `catching_up`,
810+ /// `queued`, `ready`, `needs_you`), `landed` or `closed`.
811+ pub state: String,
812+ /// One sentence about where it stands.
813+ pub detail: String,
814+ /// The issues it is waiting on that are still open.
815+ pub blocked_by: Vec<u32>,
816+ /// The pull request carrying it, the newest if several.
817+ pub pull: Option<u32>,
818+ /// Who or what is working on it, e.g. `g1t-agent`.
819+ pub agent: Option<String>,
795820 }
796821
797822 /// `start_plan`: records an outcome to plan for. Members of the
+18−0
377377 createdAt: string;
378378 /** RFC 3339. */
379379 finishedAt: string | null;
380+ /** Once applied: where each issue it opened stands now, in plan order. */
381+ progress: IssueProgress[];
380382 };
381383
382384 /** What a sandbox needs to write a plan. */
678680
679681
680682 /** Where a pull request in a merge queue stands. */
683+/** Where one issue of an applied plan stands. */
684+export type IssueProgress = {
685+ number: number;
686+ title: string;
687+ /**
688+ * `blocked`, `waiting` (for an agent), `open`, a lifecycle stage,
689+ * `landed` or `closed`.
690+ */
691+ state: string;
692+ detail: string;
693+ /** The issues it is waiting on that are still open. */
694+ blockedBy: number[];
695+ pull: number | null;
696+ agent: string | null;
697+};
698+
681699 export type QueueState = "waiting" | "testing" | "passed" | "failed" | "landed" | "removed";
682700
683701 /** One pull request's place in a merge queue. */
+11−11
88 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
99 --g1t-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1010
11− /* Indigo night, three elevations: the sky after dusk. */
12− --g1t-bg: #0b0c18;
13− --g1t-surface: #121327;
14− --g1t-raised: #1a1b33;
15− --g1t-line: #252743;
16− --g1t-line-strong: #363960;
11+ /* Very dark gray, nearly neutral, three elevations. */
12+ --g1t-bg: #0f0f11;
13+ --g1t-surface: #161618;
14+ --g1t-raised: #1e1e21;
15+ --g1t-line: #28282c;
16+ --g1t-line-strong: #38383e;
1717
18− --g1t-fg: #eeedf8;
19− --g1t-fg-soft: #dddcf0;
20− --g1t-muted: #a3a2c3;
21− --g1t-faint: #6c6c90;
18+ --g1t-fg: #ededef;
19+ --g1t-fg-soft: #dcdce0;
20+ --g1t-muted: #a0a0a8;
21+ --g1t-faint: #6a6a72;
2222
2323 /* Mint: what is live, what passed, what was added. */
2424 --g1t-accent: #86efc4;
2525 --g1t-accent-dim: #4fb38e;
2626 --g1t-accent-low: #10302a;
2727
28− /* The dusk: lavender and peach, for art and for status. */
28+ /* Accents: lavender and peach, for art and for status. */
2929 --g1t-info: #8ab4ff;
3030 --g1t-merged: #b6a8ff;
3131 --g1t-warn: #ffbd8c;
+88−1
7373 author: user(row.author_id, row.author_name),
7474 created_at: row.created_at,
7575 finished_at: row.finished_at,
76+ progress: Vec::new(),
7677 }
7778 }
7879 }
7980
81+#[derive(Deserialize)]
82+struct LatestPull {
83+ number: u32,
84+ agent: String,
85+ status: String,
86+ stage: Option<String>,
87+ stage_detail: Option<String>,
88+}
89+
8090 /// An issue waiting for a g1t agent, as selected.
8191 #[derive(Deserialize)]
8292 struct QueuedRow {
248258 .await?
249259 .filter(|row| row.repo_id == repo.id)
250260 {
251− Some(row) => Outcome::Ok(row.into()),
261+ Some(row) => {
262+ let mut plan: Plan = row.into();
263+ if plan.status == PlanStatus::Applied {
264+ plan.progress = self.progress(&repo.id, &plan).await?;
265+ }
266+ Outcome::Ok(plan)
267+ }
252268 None => Outcome::fail(FailureCode::NotFound, "Plan not found."),
253269 },
254270 )
255271 }
256272
273+ /// Where each issue an applied plan opened stands now, all at once.
274+ async fn progress(&self, repo_id: &str, plan: &Plan) -> Result<Vec<IssueProgress>> {
275+ let numbers: Vec<u32> = plan.issues.iter().filter_map(|issue| issue.number).collect();
276+ let issues = futures_util::future::try_join_all(
277+ numbers.iter().map(|number| self.issue(repo_id, *number)),
278+ )
279+ .await?;
280+ let pulls = futures_util::future::try_join_all(numbers.iter().map(|number| async move {
281+ self.db
282+ .prepare(
283+ "SELECT number, agent, status, stage, stage_detail FROM pulls
284+ WHERE repo_id = ? AND issue_number = ? AND status != 'closed'
285+ ORDER BY number DESC LIMIT 1",
286+ )
287+ .bind(&[repo_id.into(), (*number).into()])?
288+ .first::<LatestPull>(None)
289+ .await
290+ }))
291+ .await?;
292+ let open: std::collections::HashSet<u32> = issues
293+ .iter()
294+ .flatten()
295+ .filter(|issue| issue.state == State::Open)
296+ .map(|issue| issue.number)
297+ .collect();
298+ Ok(issues
299+ .into_iter()
300+ .zip(pulls)
301+ .filter_map(|(issue, pull)| {
302+ let issue = issue?;
303+ let blocked_by: Vec<u32> = issue
304+ .blocked_by
305+ .iter()
306+ .copied()
307+ .filter(|number| open.contains(number))
308+ .collect();
309+ let (state, detail) = if issue.state == State::Closed {
310+ match (issue.reason, issue.resolved_by) {
311+ (Some(IssueReason::Completed), Some(by)) => {
312+ ("landed".to_owned(), format!("Landed with #{by}."))
313+ }
314+ _ => ("closed".to_owned(), "Closed without landing.".to_owned()),
315+ }
316+ } else if let Some(pull) = pull.as_ref().filter(|pull| pull.status != "merged") {
317+ (
318+ pull.stage.clone().unwrap_or_else(|| {
319+ if pull.status == "draft" { "working" } else { "ready" }.to_owned()
320+ }),
321+ pull.stage_detail.clone().unwrap_or_default(),
322+ )
323+ } else if !blocked_by.is_empty() {
324+ let named: Vec<String> = blocked_by.iter().map(|n| format!("#{n}")).collect();
325+ ("blocked".to_owned(), format!("Waiting for {} to land.", named.join(", ")))
326+ } else if issue.queued {
327+ ("waiting".to_owned(), "Waiting for an agent to be free.".to_owned())
328+ } else {
329+ ("open".to_owned(), "Nobody is working on it.".to_owned())
330+ };
331+ Some(IssueProgress {
332+ number: issue.number,
333+ title: issue.title,
334+ state,
335+ detail,
336+ blocked_by,
337+ pull: pull.as_ref().map(|pull| pull.number),
338+ agent: pull.map(|pull| pull.agent).or(issue.agent),
339+ })
340+ })
341+ .collect())
342+ }
343+
257344 pub(crate) async fn list_plans(&self, a: ViewArgs) -> Result<Outcome<Vec<Plan>>> {
258345 let repo = match self.repo(&a.repo, &a.viewer).await? {
259346 Outcome::Ok(repo) => repo,