Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

280 lines · 11.3 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child, FC } from "hono/jsx";
4import { ICONS } from "../web/assets.js";
5import { Actor, Status, Time } from "../web/ui.js";
6import { AGENT_TEXT, NEEDS_TEXT, TASK_TEXT, type AgentItem, type NeedsItem, type Today } from "./today.js";
7import type { TodayPull } from "./today-repo.js";
8import type { MyTask } from "./tasks.js";
9
10/**
11 * The Today view's sections on the dashboard (web/account.tsx): what needs you, your agents, and the last day.
12 * Each section folds away (the site script remembers which in COLLAPSED_COOKIE, so the page renders them that
13 * way) and shows its newest SHOWN rows, with the rest behind "Show N more".
14 */
15
16/** Rows a section shows before "Show N more". */
17export const SHOWN = 3;
18/** Which sections are folded away: their ids, joined with dots. Written by the site script (web/assets.ts). */
19export const COLLAPSED_COOKIE = "today_collapsed";
20const SECTIONS = ["needs-you", "deployments", "agents", "tasks", "landed", "notifications"];
21
22/** The sections a COLLAPSED_COOKIE value folds away. */
23export function collapsedSections(cookie: string | undefined): Set<string> {
24 return new Set((cookie ?? "").split(".").filter((id) => SECTIONS.includes(id)));
25}
26
27const ChecksIcon: FC<{ p: TodayPull }> = ({ p }) =>
28 p.checks === "none" ? (
29 <span class="st faint" title="No checks">{raw(ICONS.pr)}</span>
30 ) : p.checks === "pending" ? (
31 <Status status="in_progress" />
32 ) : (
33 <Status conclusion={p.checks} />
34 );
35
36const PullLine: FC<{ repo: { fullName: string; url: string }; p: TodayPull; badge: { label: string; tone: string } }> = ({ repo, p, badge }) => (
37 <li>
38 <ChecksIcon p={p} />
39 <div class="grow">
40 <a class="title" href={`${repo.url}/pull/${p.number}`}>
41 {p.title}
42 </a>{" "}
43 <span class={`badge ${badge.tone}`}>{badge.label}</span>
44 <div class="meta">
45 <a href={repo.url} class="muted">
46 {repo.fullName}
47 </a>{" "}
48 #{p.number} by <Actor login={p.author.login} kind={p.author.kind} avatar={false} /> · updated <Time at={p.updatedAt} />
49 {p.failingChecks.length ? <> · failing: {p.failingChecks.slice(0, 3).join(", ")}</> : null}
50 {p.changesRequestedBy.length ? <> · changes requested by {p.changesRequestedBy.join(", ")}</> : null}
51 </div>
52 {p.intent ? <div class="meta">Intent: {p.intent.length > 160 ? `${p.intent.slice(0, 157)}…` : p.intent}</div> : null}
53 </div>
54 </li>
55);
56
57const Tile: FC<{ n: number; label: string; href: string; tone?: string }> = ({ n, label, href, tone }) => (
58 <a class={`tile ${n && tone ? tone : ""}`} href={href}>
59 <span class="num">{n}</span>
60 <span class="lbl">{label}</span>
61 </a>
62);
63
64export const TodayView: FC<{ today: Today; name: string; collapsed?: Set<string> }> = ({ today, name, collapsed = new Set() }) => {
65 const working = today.agentsAtWork.filter((x) => x.state !== "draft").length;
66 const landedByAgents = today.landed.filter((x) => x.pull.author.kind === "agent").length;
67 return (
68 <div>
69 <div class="row between mb">
70 <h1 class="m0" style="font-size:22px">Today</h1>
71 <span class="muted small">
72 {name} · {today.repositories} active {today.repositories === 1 ? "repository" : "repositories"}
73 {today.incomplete ? " (some left out)" : ""}
74 </span>
75 </div>
76 <div class="tiles">
77 <Tile n={today.needsYou.length} label="need you" href="#needs-you" tone="purple" />
78 <Tile n={working} label="agent pull requests open" href="#agents" />
79 <Tile n={today.landed.length} label={`merged since yesterday${landedByAgents ? ` (${landedByAgents} by agents)` : ""}`} href="#landed" />
80 <Tile n={today.failures.length} label="failed runs" href="#landed" tone="red" />
81 </div>
82 <NeedsYou items={today.needsYou} collapsed={collapsed} />
83 {today.deployments.length ? <Deployments items={today.deployments} collapsed={collapsed} /> : null}
84 <Agents items={today.agentsAtWork} collapsed={collapsed} />
85 {today.tasks.length ? <Tasks items={today.tasks} collapsed={collapsed} /> : null}
86 <SinceYesterday today={today} collapsed={collapsed} />
87 </div>
88 );
89};
90
91/** A Today section that folds away from its header (the dashboard's unread notifications use it too). */
92export const Section: FC<{ id: string; icon: string; title: string; count: number; note?: string; collapsed: Set<string>; action?: Child; children?: Child }> = (p) => (
93 <details class="box today-sec" id={p.id} data-today-sec={p.id} open={!p.collapsed.has(p.id)}>
94 <summary class="head">
95 <span class="tog">{raw(ICONS.caret)}</span>
96 <h2>
97 {raw(p.icon)} {p.title}
98 </h2>
99 {p.count ? <span class="count">{p.count}</span> : null}
100 {p.note ? <span class="muted small note">{p.note}</span> : null}
101 {p.action}
102 </summary>
103 {p.children}
104 </details>
105);
106
107/** The first SHOWN rows, and the rest behind "Show N more". */
108export function Rows<T>({ items, row }: { items: T[]; row: (x: T) => Child }) {
109 const rest = items.slice(SHOWN);
110 return (
111 <>
112 <ul class="list">{items.slice(0, SHOWN).map(row)}</ul>
113 {rest.length ? (
114 <details class="today-more">
115 <summary>
116 <span class="btn sm">
117 Show {rest.length} more {raw(ICONS.caret)}
118 </span>
119 </summary>
120 <ul class="list">{rest.map(row)}</ul>
121 <div class="fewer" hidden>
122 <button type="button" class="btn sm" data-show-fewer>
123 Show fewer
124 </button>
125 </div>
126 </details>
127 ) : null}
128 </>
129 );
130}
131
132const Tasks: FC<{ items: MyTask[]; collapsed: Set<string> }> = ({ items, collapsed }) => (
133 <Section id="tasks" icon={ICONS.zap} title="Your agent tasks" count={items.length} note="Running, or changed since yesterday" collapsed={collapsed}>
134 <Rows
135 items={items}
136 row={(t) => (
137 <li>
138 <div class="grow">
139 <a href={`/${t.repository}/tasks/${t.number}`}>
140 <b>{t.title}</b>
141 </a>{" "}
142 <span class={`badge ${TASK_TEXT[t.state].tone}`}>{TASK_TEXT[t.state].label}</span>
143 <div class="muted small">
144 {t.repository} · task #{t.number} · {t.agent}
145 {t.prNumber ? (
146 <>
147 {" "}
148 · <a href={`/${t.repository}/pull/${t.prNumber}`}>#{t.prNumber}</a>
149 </>
150 ) : null}
151 </div>
152 </div>
153 <span class="muted small">
154 <Time at={t.updatedAt} />
155 </span>
156 </li>
157 )}
158 />
159 </Section>
160);
161
162const Deployments: FC<{ items: Today["deployments"]; collapsed: Set<string> }> = ({ items, collapsed }) => (
163 <Section id="deployments" icon={ICONS.rocket} title="Deployments waiting for you" count={items.length} note="You're a required reviewer for these environments" collapsed={collapsed}>
164 <Rows
165 items={items}
166 row={({ repo, deployment: d }) => (
167 <li>
168 <div class="grow">
169 <a href={`${repo.url}/actions/runs/${d.runId}`}>
170 <b>{d.environment}</b>: {d.title || d.workflow}
171 </a>
172 <div class="muted small">
173 {repo.fullName} · {d.workflow} #{d.runNumber} on {d.ref.replace(/^refs\/(heads|tags)\//, "")} · started by {d.actor.login}
174 </div>
175 </div>
176 <span class="muted small">
177 <Time at={d.since} />
178 </span>
179 </li>
180 )}
181 />
182 </Section>
183);
184
185const NeedsYou: FC<{ items: NeedsItem[]; collapsed: Set<string> }> = ({ items, collapsed }) => (
186 <Section id="needs-you" icon={ICONS.eye} title="Needs you" count={items.length} note="Reviews you were asked for, agent work waiting for a person, and your own blocked pull requests" collapsed={collapsed}>
187 {items.length ? <Rows items={items.slice(0, 30)} row={(x) => <PullLine repo={x.repo} p={x.pull} badge={NEEDS_TEXT[x.kind]} />} /> : <div class="empty">Nothing is waiting on you.</div>}
188 </Section>
189);
190
191const Agents: FC<{ items: AgentItem[]; collapsed: Set<string> }> = ({ items, collapsed }) => (
192 <Section
193 id="agents"
194 icon={ICONS.bot}
195 title="Agents at work"
196 count={items.length}
197 note="Open pull requests from agents you and your organizations own"
198 collapsed={collapsed}
199 action={
200 <a class="small manage" href="/settings/agents">
201 Manage agents
202 </a>
203 }
204 >
205 {items.length ? (
206 <Rows items={items.slice(0, 30)} row={(x) => <PullLine repo={x.repo} p={x.pull} badge={AGENT_TEXT[x.state]} />} />
207 ) : (
208 <div class="empty">
209 Your agents have no open pull requests. <a href="/docs/agents/overview">Set up a coding agent</a>
210 </div>
211 )}
212 </Section>
213);
214
215type Happened = { at: string; row: () => Child };
216
217const SinceYesterday: FC<{ today: Today; collapsed: Set<string> }> = ({ today, collapsed }) => {
218 // One list, newest first, so the first rows are the latest whatever they are.
219 const items: Happened[] = [
220 ...today.releases.map((x) => ({
221 at: x.release.publishedAt,
222 row: () => (
223 <li>
224 <span class="st ok">{raw(ICONS.tag)}</span>
225 <div class="grow">
226 <a class="title" href={`${x.repo.url}/releases/tag/${encodeURIComponent(x.release.tag)}`}>
227 {x.release.name}
228 </a>{" "}
229 <span class="badge green">{x.release.prerelease ? "Pre-release" : "Released"}</span>
230 <div class="meta">
231 {x.repo.fullName} · by <Actor login={x.release.author.login} kind={x.release.author.kind} avatar={false} /> · <Time at={x.release.publishedAt} />
232 </div>
233 </div>
234 </li>
235 ),
236 })),
237 ...today.failures.map((x) => ({
238 at: x.run.at,
239 row: () => (
240 <li>
241 <span class="st bad">{raw(ICONS.failure)}</span>
242 <div class="grow">
243 <a class="title" href={`${x.repo.url}/actions/runs/${x.run.id}`}>
244 {x.run.title || x.run.workflow}
245 </a>{" "}
246 <span class="badge red">{x.run.workflow} failed</span>
247 <div class="meta">
248 {x.repo.fullName} · {x.run.ref.replace(/^refs\/heads\//, "")} · <Actor login={x.run.actor.login} kind={x.run.actor.kind} avatar={false} /> · <Time at={x.run.at} />
249 </div>
250 </div>
251 </li>
252 ),
253 })),
254 ...today.landed.map((x) => ({
255 at: x.pull.mergedAt,
256 row: () => (
257 <li>
258 <span class="st" style="color:var(--purple)">
259 {raw(ICONS.prMerged)}
260 </span>
261 <div class="grow">
262 <a class="title" href={`${x.repo.url}/pull/${x.pull.number}`}>
263 {x.pull.title}
264 </a>{" "}
265 <span class="badge purple">Merged</span>
266 <div class="meta">
267 {x.repo.fullName} #{x.pull.number} by <Actor login={x.pull.author.login} kind={x.pull.author.kind} avatar={false} />
268 {x.pull.mergedBy ? <> · merged by {x.pull.mergedBy}</> : null} · <Time at={x.pull.mergedAt} />
269 </div>
270 </div>
271 </li>
272 ),
273 })),
274 ].sort((a, b) => b.at.localeCompare(a.at));
275 return (
276 <Section id="landed" icon={ICONS.history} title="Since yesterday" count={items.length} note="Merged, released and failed in the last 24 hours" collapsed={collapsed}>
277 {items.length ? <Rows items={items} row={(x) => x.row()} /> : <div class="empty">Nothing merged, released or failed in the last 24 hours.</div>}
278 </Section>
279 );
280};