| 1 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 2 | import { myTaskJson, tasksFor, type MyTask } from "./tasks.js"; |
| 3 | import type { Env } from "../env.js"; |
| 4 | import type { Account, Directory, Repo } from "../lib/directory.js"; |
| 5 | import { repoStub } from "../http/common.js"; |
| 6 | import { atLeast } from "../repo/types.js"; |
| 7 | import 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. */ |
| 20 | export const MAX_REPOS = 100; |
| 21 | /** Repositories with no activity for this long are skipped. */ |
| 22 | export const ACTIVE_DAYS = 30; |
| 23 | /** "Since yesterday" looks back this far. */ |
| 24 | export const SINCE_HOURS = 24; |
| 25 | |
| 26 | export interface TodayRepo { |
| 27 | id: string; |
| 28 | fullName: string; |
| 29 | url: string; |
| 30 | defaultBranch: string; |
| 31 | visibility: string; |
| 32 | } |
| 33 | |
| 34 | export type NeedsKind = "review_requested" | "agent_approval" | "changes_requested" | "checks_failing" | "conflicting"; |
| 35 | |
| 36 | export type AgentPullState = "draft" | "in_base" | "conflicting" | "landing" | "checks_failing" | "changes_requested" | "approved" | "checks_running" | "waiting_for_approval"; |
| 37 | |
| 38 | export interface NeedsItem { |
| 39 | kind: NeedsKind; |
| 40 | repo: TodayRepo; |
| 41 | pull: TodayPull; |
| 42 | } |
| 43 | |
| 44 | export interface AgentItem { |
| 45 | state: AgentPullState; |
| 46 | repo: TodayRepo; |
| 47 | pull: TodayPull; |
| 48 | } |
| 49 | |
| 50 | export 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). */ |
| 68 | export 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 | |
| 76 | export 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 | |
| 88 | export 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 | |
| 96 | export 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 | |
| 107 | const isAgent = (p: TodayPerson) => p.kind === "agent"; |
| 108 | const 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 | */ |
| 115 | export 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. */ |
| 128 | export 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 | |
| 132 | const waitsForPerson = (p: TodayPull) => ["waiting_for_approval", "checks_running"].includes(agentPullState(p)); |
| 133 | |
| 134 | export 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. */ |
| 177 | export 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 | |
| 188 | const 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 | */ |
| 194 | export 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. */ |
| 259 | export 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 | |
| 272 | const person = (p: TodayPerson) => ({ login: p.login, type: p.kind === "agent" ? "Bot" : p.kind === "user" ? "User" : p.kind }); |
| 273 | |
| 274 | function 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 | |
| 299 | export 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 | } |