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.

313 lines · 15.6 KBRaw History
1import type { DiffineryEvent } from "@diffinery/shared";
2import { myTaskJson, tasksFor, type MyTask } from "./tasks.js";
3import type { Env } from "../env.js";
4import type { Account, Directory, Repo } from "../lib/directory.js";
5import { repoStub } from "../http/common.js";
6import { atLeast } from "../repo/types.js";
7import type { RepoToday, TodayDeployment, TodayMerged, TodayPerson, TodayPull, TodayRelease, TodayRun } from "./today-repo.js";
8
9/**
10 * Today: one person's morning view across every repository they can see.
11 * What needs them (reviews, agent pull requests waiting for a human), what
12 * their agents are doing, and what landed or failed in the last day.
13 *
14 * Each repository's Durable Object summarises itself (./today-repo.ts); this
15 * file picks the repositories, gathers their summaries and sorts the items
16 * for one viewer. `classify` is pure so the rules are unit tested.
17 */
18
19/** Visit at most this many repositories, the most recently active first. */
20export const MAX_REPOS = 100;
21/** Repositories with no activity for this long are skipped. */
22export const ACTIVE_DAYS = 30;
23/** "Since yesterday" looks back this far. */
24export const SINCE_HOURS = 24;
25
26export interface TodayRepo {
27 id: string;
28 fullName: string;
29 url: string;
30 defaultBranch: string;
31 visibility: string;
32}
33
34export type NeedsKind = "review_requested" | "agent_approval" | "changes_requested" | "checks_failing" | "conflicting";
35
36export type AgentPullState = "draft" | "in_base" | "conflicting" | "landing" | "checks_failing" | "changes_requested" | "approved" | "checks_running" | "waiting_for_approval";
37
38export interface NeedsItem {
39 kind: NeedsKind;
40 repo: TodayRepo;
41 pull: TodayPull;
42}
43
44export interface AgentItem {
45 state: AgentPullState;
46 repo: TodayRepo;
47 pull: TodayPull;
48}
49
50export interface Today {
51 generatedAt: string;
52 since: string;
53 /** How many repositories were read, and whether some active ones were left out or failed to answer. */
54 repositories: number;
55 incomplete: boolean;
56 needsYou: NeedsItem[];
57 agentsAtWork: AgentItem[];
58 landed: Array<{ repo: TodayRepo; pull: TodayMerged }>;
59 releases: Array<{ repo: TodayRepo; release: TodayRelease }>;
60 failures: Array<{ repo: TodayRepo; run: TodayRun }>;
61 /** Deployments waiting for the viewer as one of an environment's required reviewers. */
62 deployments: Array<{ repo: TodayRepo; deployment: TodayDeployment }>;
63 /** Agent tasks you started (an agent: the tasks it runs) that are running or changed since `since`. */
64 tasks: MyTask[];
65}
66
67/** Labels and badge tones, shared by the Today page (web.tsx) and the morning digest email (digest.ts). */
68export const NEEDS_TEXT: Record<NeedsKind, { label: string; tone: string }> = {
69 review_requested: { label: "Review requested", tone: "blue" },
70 agent_approval: { label: "Agent PR needs a person's approval", tone: "purple" },
71 changes_requested: { label: "Changes requested", tone: "amber" },
72 checks_failing: { label: "Checks failing", tone: "red" },
73 conflicting: { label: "Merge conflicts", tone: "amber" },
74};
75
76export const AGENT_TEXT: Record<AgentPullState, { label: string; tone: string }> = {
77 draft: { label: "Draft", tone: "" },
78 in_base: { label: "Already in its base branch", tone: "" },
79 conflicting: { label: "Merge conflicts", tone: "amber" },
80 landing: { label: "Landing", tone: "green" },
81 checks_failing: { label: "Checks failing", tone: "red" },
82 changes_requested: { label: "Changes requested", tone: "amber" },
83 approved: { label: "Approved", tone: "green" },
84 checks_running: { label: "Checks running", tone: "blue" },
85 waiting_for_approval: { label: "Waiting for approval", tone: "purple" },
86};
87
88export const TASK_TEXT: Record<MyTask["state"], { label: string; tone: string }> = {
89 queued: { label: "Queued", tone: "" },
90 running: { label: "Running", tone: "blue" },
91 ready: { label: "Ready", tone: "green" },
92 failed: { label: "Failed", tone: "red" },
93 stopped: { label: "Stopped", tone: "" },
94};
95
96export interface ClassifyInput {
97 me: { id: string; login: string };
98 /** Agents the viewer or their organizations own (and the viewer itself, when an agent asks). */
99 agentIds: Set<string>;
100 /** Agents the viewer owns themselves: a run one of them started is the viewer's own for prevent_self_review. */
101 ownAgentIds?: Set<string>;
102 /** Repositories where the viewer can approve (write or more). */
103 writable: Set<string>;
104 repos: Array<{ repo: TodayRepo; data: RepoToday }>;
105}
106
107const isAgent = (p: TodayPerson) => p.kind === "agent";
108const byNewest = <T>(at: (x: T) => string) => (a: T, b: T) => at(b).localeCompare(at(a));
109
110/**
111 * Where an agent's open pull request stands, in the order a person would act on it. One whose head is
112 * already in its base branch has nothing left to merge, and one with conflicts waits for its agent to
113 * update it (auto-merge can't land it either), so neither waits for a person's approval.
114 */
115export function agentPullState(p: TodayPull): AgentPullState {
116 if (p.draft) return "draft";
117 if (p.mergeState === "in_base") return "in_base";
118 if (p.mergeState === "conflicting") return "conflicting";
119 if (p.queued || (p.autoMerge && !p.awaitingApproval)) return "landing";
120 if (p.checks === "failure") return "checks_failing";
121 if (p.changesRequestedBy.length) return "changes_requested";
122 if (p.approvedHead) return "approved";
123 if (p.checks === "pending") return "checks_running";
124 return "waiting_for_approval";
125}
126
127/** Repositories with an agent pull request that may be waiting for a person: only these need the viewer's role. */
128export function approvalCandidates(repos: ClassifyInput["repos"], me: { id: string }): string[] {
129 return repos.filter((r) => r.data.pulls.some((p) => isAgent(p.author) && p.author.id !== me.id && waitsForPerson(p))).map((r) => r.repo.id);
130}
131
132const waitsForPerson = (p: TodayPull) => ["waiting_for_approval", "checks_running"].includes(agentPullState(p));
133
134export function classify(input: ClassifyInput): Omit<Today, "generatedAt" | "since" | "repositories" | "incomplete" | "tasks"> {
135 const me = input.me.login.toLowerCase();
136 const needsYou: NeedsItem[] = [];
137 const agentsAtWork: AgentItem[] = [];
138 const landed: Today["landed"] = [];
139 const releases: Today["releases"] = [];
140 const failures: Today["failures"] = [];
141 const deployments: Today["deployments"] = [];
142 for (const { repo, data } of input.repos) {
143 for (const deployment of data.deployments ?? []) {
144 const own = deployment.selfBlocked.some((id) => id === input.me.id || input.ownAgentIds?.has(id));
145 if (deployment.reviewerIds.includes(input.me.id) && !own) deployments.push({ repo, deployment });
146 }
147 for (const pull of data.pulls) {
148 const mine = pull.author.id === input.me.id;
149 let need: NeedsKind | null = null;
150 if (!pull.draft && !mine && pull.requestedReviewers.some((r) => r.toLowerCase() === me)) need = "review_requested";
151 else if (!mine && isAgent(pull.author) && input.writable.has(repo.id) && waitsForPerson(pull)) need = "agent_approval";
152 else if (mine && pull.changesRequestedBy.length) need = "changes_requested";
153 else if (mine && pull.checks === "failure") need = "checks_failing";
154 else if (mine && !pull.draft && pull.mergeState === "conflicting") need = "conflicting";
155 if (need) needsYou.push({ kind: need, repo, pull });
156 if (input.agentIds.has(pull.author.id)) agentsAtWork.push({ state: agentPullState(pull), repo, pull });
157 }
158 for (const pull of data.merged) landed.push({ repo, pull });
159 for (const release of data.releases) releases.push({ repo, release });
160 const defaultRef = `refs/heads/${repo.defaultBranch}`;
161 for (const run of data.failedRuns) {
162 const ours = run.actor.id === input.me.id || input.agentIds.has(run.actor.id);
163 const onDefault = (run.ref === defaultRef || run.ref === repo.defaultBranch) && run.event !== "pull_request";
164 if (ours || onDefault) failures.push({ repo, run });
165 }
166 }
167 needsYou.sort(byNewest((x) => x.pull.updatedAt));
168 agentsAtWork.sort(byNewest((x) => x.pull.updatedAt));
169 landed.sort(byNewest((x) => x.pull.mergedAt));
170 releases.sort(byNewest((x) => x.release.publishedAt));
171 failures.sort(byNewest((x) => x.run.at));
172 deployments.sort(byNewest((x) => x.deployment.since));
173 return { needsYou, agentsAtWork, landed: landed.slice(0, 30), releases: releases.slice(0, 20), failures: failures.slice(0, 20), deployments: deployments.slice(0, 20) };
174}
175
176/** The repositories Today visits: not archived, active in the last ACTIVE_DAYS, most recent first. */
177export function pickRepos(repos: Repo[], activity: Map<string, string>, now: Date): { repos: Repo[]; skipped: number } {
178 const cutoff = new Date(now.getTime() - ACTIVE_DAYS * 86400_000).toISOString();
179 const lastActive = (r: Repo) => [r.pushed_at ?? "", activity.get(r.id) ?? "", r.created_at].reduce((a, b) => (b > a ? b : a), "");
180 const active = repos
181 .filter((r) => !r.archived && lastActive(r) >= cutoff)
182 .map((r) => ({ r, at: lastActive(r) }))
183 .sort((a, b) => b.at.localeCompare(a.at))
184 .map((x) => x.r);
185 return { repos: active.slice(0, MAX_REPOS), skipped: Math.max(0, active.length - MAX_REPOS) };
186}
187
188const repoRef = (r: Repo): TodayRepo => ({ id: r.id, fullName: `${r.owner_login}/${r.name}`, url: `/${r.owner_login}/${r.name}`, defaultBranch: r.default_branch, visibility: r.visibility });
189
190/**
191 * Gather Today for one account (a person, or an agent asking over the API or MCP).
192 * `repoIds` limits it to the repositories a token is restricted to.
193 */
194export async function gatherToday(
195 env: Env,
196 dir: Directory,
197 me: Account,
198 opts: { repoIds?: string[] | null; now?: Date; since?: string; visible?: Promise<Repo[]>; orgs?: ReturnType<Directory["orgsFor"]> } = {},
199): Promise<Today> {
200 const now = opts.now ?? new Date();
201 // The morning digest passes its own start: the last digest, which can be days ago after a weekend.
202 const since = opts.since ?? new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString();
203 const cutoff = new Date(now.getTime() - ACTIVE_DAYS * 86400_000).toISOString();
204 // The caller (the dashboard) may already be reading these for itself.
205 const [visible, orgs] = await Promise.all([opts.visible ?? dir.reposVisibleTo(me), me.kind === "user" ? (opts.orgs ?? dir.orgsFor(me.id)) : Promise.resolve([])]);
206 // Activity of these repositories only, not of every repository active on the instance; the agents at the same time.
207 const activity = new Map<string, string>();
208 const chunks: string[][] = [];
209 for (let i = 0; i < visible.length; i += 90) chunks.push(visible.slice(i, i + 90).map((r) => r.id));
210 const owners = me.kind === "agent" ? [] : [me.id, ...orgs.map((o) => o.id)];
211 const [found, agents] = await Promise.all([
212 Promise.all(
213 chunks.map((ids) =>
214 env.DB.prepare(`SELECT repo_id, at FROM repo_activity WHERE repo_id IN (${ids.map(() => "?").join(", ")}) AND at >= ?`)
215 .bind(...ids, cutoff)
216 .all<{ repo_id: string; at: string }>(),
217 ),
218 ),
219 dir.agentsOwnedBy(owners),
220 ]);
221 for (const rows of found) for (const r of rows.results) activity.set(r.repo_id, r.at);
222 const allowed = opts.repoIds ? visible.filter((r) => opts.repoIds!.includes(r.id)) : visible;
223 const { repos, skipped } = pickRepos(allowed, activity, now);
224 const agentIds = new Set(agents.map((a) => a.id));
225 const ownAgentIds = new Set(agents.filter((a) => a.owner_id === me.id).map((a) => a.id));
226 if (me.kind === "agent") agentIds.add(me.id);
227
228 const tasks = tasksFor(env, dir, me, { repoIds: opts.repoIds, since, activeOrSince: true, limit: 20 }).catch((e) => {
229 console.error("today: tasks failed", e);
230 return [] as MyTask[];
231 });
232 const settled = await Promise.allSettled(repos.map(async (r) => ({ repo: repoRef(r), data: await repoStub(env, r.id).todaySummary(since) })));
233 const answered = settled.flatMap((s, i) => {
234 if (s.status === "fulfilled") return [s.value];
235 console.error("today: repository summary failed", repos[i]!.id, s.reason);
236 return [];
237 });
238
239 const byId = new Map(repos.map((r) => [r.id, r]));
240 const writable = new Set<string>();
241 await Promise.all(
242 // An agent's approval never counts, so nothing waits on an agent to approve.
243 (me.kind === "agent" ? [] : approvalCandidates(answered, me)).map(async (id) => {
244 if (atLeast(await dir.roleFor(me, byId.get(id)!), "write")) writable.add(id);
245 }),
246 );
247
248 return {
249 generatedAt: now.toISOString(),
250 since,
251 repositories: answered.length,
252 incomplete: skipped > 0 || answered.length < repos.length,
253 ...classify({ me, agentIds, ownAgentIds, writable, repos: answered }),
254 tasks: await tasks,
255 };
256}
257
258/** Remember when a repository last had activity (from the events queue), so Today finds it. Never throws. */
259export async function recordActivity(env: Env, ev: DiffineryEvent): Promise<void> {
260 if (ev.type === "audit" || !ev.repoId) return;
261 try {
262 await env.DB.prepare("INSERT INTO repo_activity (repo_id, at) VALUES (?, ?) ON CONFLICT(repo_id) DO UPDATE SET at = excluded.at WHERE excluded.at > repo_activity.at")
263 .bind(ev.repoId, ev.at)
264 .run();
265 } catch (e) {
266 console.error("today: recording activity failed", ev.repoId, e);
267 }
268}
269
270// ---------------------------------------------------------------- JSON for the REST API and MCP
271
272const person = (p: TodayPerson) => ({ login: p.login, type: p.kind === "agent" ? "Bot" : p.kind === "user" ? "User" : p.kind });
273
274function pullJson(base: string, repo: TodayRepo, p: TodayPull) {
275 return {
276 repository: repo.fullName,
277 number: p.number,
278 title: p.title,
279 html_url: `${base}${repo.url}/pull/${p.number}`,
280 user: person(p.author),
281 head: p.head,
282 base: p.base,
283 draft: p.draft,
284 intent: p.intent,
285 requested_reviewers: p.requestedReviewers,
286 approved_by: p.approvedBy,
287 approved_head: p.approvedHead,
288 changes_requested_by: p.changesRequestedBy,
289 checks: p.checks,
290 failing_checks: p.failingChecks,
291 auto_merge: p.autoMerge,
292 in_merge_queue: p.queued,
293 merge_state: p.mergeState ?? null,
294 created_at: p.createdAt,
295 updated_at: p.updatedAt,
296 };
297}
298
299export function todayJson(base: string, t: Today) {
300 return {
301 generated_at: t.generatedAt,
302 since: t.since,
303 repositories: t.repositories,
304 incomplete_results: t.incomplete,
305 needs_you: t.needsYou.map((x) => ({ reason: x.kind, ...pullJson(base, x.repo, x.pull) })),
306 agents_at_work: t.agentsAtWork.map((x) => ({ state: x.state, ...pullJson(base, x.repo, x.pull) })),
307 landed: t.landed.map((x) => ({ repository: x.repo.fullName, number: x.pull.number, title: x.pull.title, html_url: `${base}${x.repo.url}/pull/${x.pull.number}`, user: person(x.pull.author), merged_by: x.pull.mergedBy, merged_at: x.pull.mergedAt })),
308 releases: t.releases.map((x) => ({ repository: x.repo.fullName, tag_name: x.release.tag, name: x.release.name, prerelease: x.release.prerelease, html_url: `${base}${x.repo.url}/releases/tag/${encodeURIComponent(x.release.tag)}`, author: person(x.release.author), published_at: x.release.publishedAt })),
309 tasks: t.tasks.map((x) => myTaskJson(base, x)),
310 deployments_waiting: t.deployments.map((x) => ({ repository: x.repo.fullName, environment: x.deployment.environment, run_id: x.deployment.runId, run_number: x.deployment.runNumber, workflow: x.deployment.workflow, title: x.deployment.title, ref: x.deployment.ref, actor: person(x.deployment.actor), html_url: `${base}${x.repo.url}/actions/runs/${x.deployment.runId}`, waiting_since: x.deployment.since })),
311 failures: t.failures.map((x) => ({ repository: x.repo.fullName, run_id: x.run.id, run_number: x.run.runNumber, workflow: x.run.workflow, title: x.run.title, ref: x.run.ref, event: x.run.event, conclusion: x.run.conclusion, html_url: `${base}${x.repo.url}/actions/runs/${x.run.id}`, actor: person(x.run.actor), pull_request: x.run.prNumber, completed_at: x.run.at })),
312 };
313}