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.

Today: a morning view of agent work across repositories (#53)

* WIP: Today view, per-repository summary and activity table

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* WIP: Today view on the dashboard, REST and MCP

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* Today: a morning view of agent work across repositories

The dashboard opens on Today: pull requests that need you (review
requests, agent pull requests waiting for a person, your own blocked
ones), your agents' open pull requests with where each stands, and what
merged, was released or failed in the last 24 hours. The same view is
GET /api/v3/diffinery/today and the read-only MCP tool today.

Each repository summarises itself in one Durable Object call; a new D1
table records each repository's latest activity from the events queue
so Today visits the recently active ones first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 565cd54commit e52c37b6756b3d968e49d359edd26ac088d81348Browse files
19 files changed+1184−5
Files (19)
modifiedapps/diffinery/docs/agents/mcp.md+1−0View file
@@ -130,6 +130,7 @@
130130 | Tool | Read-only | What it does | Minimum role |
131131 | --- | --- | --- | --- |
132132 | `whoami` | Yes | The account the token acts as (`login`, `kind`) and, for an agent, `on_behalf_of` | Any token |
133+| `today` | Yes | Your morning view: pull requests that need you, your agents' open pull requests and where each stands, and what merged, was released or failed in the last 24 hours. See [Today](/docs/agents/today) | Any token |
133134 | `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token |
134135 | `get_organization` | Yes | An organization's `login` and `name`; to its members, its agents, agents of its members and site administrators also `default_repository_permission` (the base permission members get on its repositories) | Any token |
135136 | `list_invitations` | Yes | Pending invitations to a repository (`owner` and `repo`), an organization (`org`) or the instance (neither), each with an `id` | Admin on the repository; organization owner or admin; site administrator |
addedapps/diffinery/docs/agents/today.md+130−0View file
@@ -1,0 +1,130 @@
1+---
2+title: Today
3+description: Your morning view of agent work across every repository you can see, with what needs you, what your agents are doing and what landed or failed since yesterday.
4+---
5+
6+**Today** is the first page you see after signing in to Diffinery. It answers three questions across every repository you can see: what needs you, what your agents are doing, and what happened since yesterday. Open it in the morning to review the night's agent work, or any time you want to catch up.
7+
8+The same view is available to scripts and agents from the REST API and the `today` MCP tool, so your own coding agent can read your morning too.
9+
10+## What it shows
11+
12+At the top, four numbers link to the sections below them: how many items need you, how many agent pull requests are open, how many pull requests merged since yesterday (and how many of them were by agents), and how many workflow runs failed.
13+
14+### Needs you
15+
16+Pull requests waiting on you, newest activity first:
17+
18+| Label | When it appears |
19+| --- | --- |
20+| **Review requested** | Someone asked you to review a pull request that isn't a draft. It leaves the list once you submit a review. |
21+| **Agent PR waiting for a person** | A pull request opened by an agent, in a repository where you have write access or more, that no person has approved at its latest commit. Agent pull requests always need a human approval (see [Guardrails](/docs/agents/overview#guardrails)). It isn't listed while it is a draft, while its checks are failing, while someone has asked for changes, or once auto-merge or the merge queue has it. |
22+| **Changes requested** | Your own pull request, where a reviewer's latest vote asks for changes. |
23+| **Checks failing** | Your own pull request, where a check or commit status on its latest commit failed. |
24+
25+A pull request opened by an agent you own is listed here too when it is waiting for a person: you are usually the person who approves it.
26+
27+### Agents at work
28+
29+Open pull requests from agents that you own or that your organizations own, each with where it stands:
30+
31+| State | Meaning |
32+| --- | --- |
33+| **Draft** | The agent is still working on it. |
34+| **Checks running** | Checks on the latest commit haven't finished. |
35+| **Checks failing** | A check failed; the agent should push a fix. |
36+| **Changes requested** | A reviewer asked for changes. |
37+| **Waiting for approval** | Checks passed and it needs a person's approval. |
38+| **Approved** | A person approved the latest commit. |
39+| **Landing** | Auto-merge is on or it is in the merge queue. |
40+
41+Each row shows the repository, who opened it, when it last changed, failing check names, who asked for changes, and the pull request's intent.
42+
43+### Since yesterday
44+
45+What happened in the last 24 hours:
46+
47+- **Releases** published, with their tag.
48+- **Failed workflow runs** that you or your agents started, and failed runs on a repository's default branch (pull request runs only when they are yours or your agents').
49+- **Merged pull requests**, with who opened and who merged them.
50+
51+## Which repositories it covers
52+
53+Today looks at the repositories you can see that had activity in the last 30 days, such as a push, a pull request, a review, a workflow run or a release, most recent first, up to 100 of them. Archived repositories are left out. When some active repositories were left out, the page says so next to the repository count, and the API sets `incomplete_results`.
54+
55+For each repository it reads up to 50 open pull requests, the most recently updated first.
56+
57+## Read it with the REST API
58+
59+```sh
60+curl https://diffinery.com/api/v3/diffinery/today \
61+ -H "Authorization: Bearer dfy_..."
62+```
63+
64+```json
65+{
66+ "generated_at": "2026-10-14T07:30:12.000Z",
67+ "since": "2026-10-13T07:30:12.000Z",
68+ "repositories": 12,
69+ "incomplete_results": false,
70+ "needs_you": [
71+ {
72+ "reason": "agent_approval",
73+ "repository": "acme/website",
74+ "number": 42,
75+ "title": "Fix the Contact sales link",
76+ "html_url": "https://diffinery.com/acme/website/pull/42",
77+ "user": { "login": "review-bot", "type": "Bot" },
78+ "head": "fix-pricing-link",
79+ "base": "main",
80+ "draft": false,
81+ "intent": "Point the Contact sales link at /contact; no other changes.",
82+ "requested_reviewers": [],
83+ "approved_by": [],
84+ "approved_head": false,
85+ "changes_requested_by": [],
86+ "checks": "success",
87+ "failing_checks": [],
88+ "auto_merge": false,
89+ "in_merge_queue": false,
90+ "created_at": "2026-10-13T22:04:51.000Z",
91+ "updated_at": "2026-10-13T22:31:09.000Z"
92+ }
93+ ],
94+ "agents_at_work": [],
95+ "landed": [],
96+ "releases": [],
97+ "failures": []
98+}
99+```
100+
101+| Field | Meaning |
102+| --- | --- |
103+| `needs_you[].reason` | `review_requested`, `agent_approval`, `changes_requested` or `checks_failing` |
104+| `agents_at_work[].state` | `draft`, `checks_running`, `checks_failing`, `changes_requested`, `waiting_for_approval`, `approved` or `landing` |
105+| `checks` | The latest commit's checks and statuses together: `success`, `failure`, `pending` or `none` |
106+| `approved_head` | Whether a person approved the latest commit (agent approvals never count) |
107+| `landed[]` | Pull requests merged since `since`, with `merged_by` and `merged_at` |
108+| `releases[]` | Releases published since `since` |
109+| `failures[]` | Failed runs since `since`, with `workflow`, `ref`, `event`, `actor` and `pull_request` |
110+
111+Any token works (a `read` token is enough). A token limited to repositories sees only those repositories. When an agent's token calls it, `agents_at_work` lists that agent's own open pull requests, and `needs_you` never contains agent approvals, because an agent's approval doesn't count.
112+
113+## Read it over MCP
114+
115+The `today` tool takes no arguments and returns the same JSON:
116+
117+```json
118+{ "name": "today", "arguments": {} }
119+```
120+
121+It is read-only, so MCP clients can run it without asking. A good first instruction for a personal assistant agent: "Call `today` and tell me what needs me first."
122+
123+## Troubleshooting
124+
125+| What you see | Cause and fix |
126+| --- | --- |
127+| A repository's pull requests don't appear | The repository had no activity in the last 30 days, it is archived, or more than 100 repositories were active and it wasn't among the most recent. Open the repository's **Pull requests** tab. |
128+| An agent pull request isn't under **Needs you** | You need write access or more on its repository, and it must not be a draft, failing, waiting on requested changes or already landing. It shows under **Agents at work** if the agent is yours. |
129+| Your agent's pull request isn't under **Agents at work** | The agent must be owned by you or by an organization you belong to. Check **Settings → Agents**. |
130+| `401 sign in required` | Send a token, or sign in. |
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -69,6 +69,7 @@
6969 "title": "Agents and AI",
7070 "pages": [
7171 "agents/overview",
72+ "agents/today",
7273 "agents/mcp",
7374 "agents/sandboxes",
7475 "agents/docs-for-agents"
addedapps/diffinery/migrations/0012_repo_activity.sql+8−0View file
@@ -1,0 +1,8 @@
1+-- When each repository last had pull request, review, push, run or release activity
2+-- (agentic/today.ts). The Today view visits the recently active repositories first;
3+-- repos.pushed_at alone misses work done through the API and MCP.
4+CREATE TABLE repo_activity (
5+ repo_id TEXT PRIMARY KEY,
6+ at TEXT NOT NULL
7+);
8+CREATE INDEX repo_activity_at ON repo_activity(at);
modifiedapps/diffinery/package.json+1−1View file
@@ -9,7 +9,7 @@
99 "typecheck": "tsc --noEmit && tsc --noEmit -p islands",
1010 "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
12- "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security.test.mjs",
12+ "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev",
1414 "build": "node islands/build.mjs && node scripts/build-docs.mjs",
1515 "predev": "node islands/build.mjs && node scripts/build-docs.mjs",
addedapps/diffinery/src/agentic/api.ts+19−0View file
@@ -1,0 +1,19 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
4+import { gatherToday, todayJson } from "./today.js";
5+
6+/** Diffinery-only REST endpoints for agentic work, mounted under /api/v3 (see http/api.ts). */
7+export const agenticApi = new Hono<{ Bindings: Env; Variables: AppVars }>();
8+
9+/** The caller's token restriction to repositories, if any. */
10+export const tokenRepoIds = (c: AppContext): string[] | null => {
11+ const p = c.get("principal");
12+ return p.type === "account" ? p.repoIds : null;
13+};
14+
15+agenticApi.get("/diffinery/today", async (c) => {
16+ const me = requireAccount(c);
17+ const today = await gatherToday(c.env, c.get("dir"), me, { repoIds: tokenRepoIds(c) });
18+ return c.json(todayJson(c.env.PUBLIC_URL.replace(/\/$/, ""), today));
19+});
addedapps/diffinery/src/agentic/mcp.ts+15−0View file
@@ -1,0 +1,15 @@
1+import { requireAccount } from "../auth/principal.js";
2+import type { Tool } from "../mcp/server.js";
3+import { tokenRepoIds } from "./api.js";
4+import { gatherToday, todayJson } from "./today.js";
5+
6+/** MCP tools for agentic work (merged into mcp/server.ts). */
7+export const AGENTIC_TOOLS: Record<string, Tool> = {
8+ today: {
9+ description:
10+ "Your morning view across every repository you can see: pull requests that need you (review requests, agent pull requests waiting for a person's approval, your own blocked ones), what your agents are working on and where each stands, and what merged, was released or failed in the last 24 hours.",
11+ input: {},
12+ readOnly: true,
13+ run: async (c) => todayJson(c.env.PUBLIC_URL.replace(/\/$/, ""), await gatherToday(c.env, c.get("dir"), requireAccount(c), { repoIds: tokenRepoIds(c) })),
14+ },
15+};
addedapps/diffinery/src/agentic/today-repo.ts+207−0View file
@@ -1,0 +1,207 @@
1+import type { Check } from "../repo/collab.js";
2+
3+/**
4+ * One repository's part of the Today view (the morning view of agent work),
5+ * read inside its Durable Object in one call: open pull requests with their
6+ * review and check state, and what landed or failed since a point in time.
7+ * Who the viewer is doesn't matter here; agentic/today.ts sorts the items
8+ * into "needs you", "agents at work" and "since yesterday" per person.
9+ */
10+
11+export type ChecksState = "success" | "failure" | "pending" | "none";
12+
13+export interface TodayPerson {
14+ id: string;
15+ login: string;
16+ kind: string;
17+}
18+
19+export interface TodayPull {
20+ number: number;
21+ title: string;
22+ author: TodayPerson;
23+ head: string;
24+ base: string;
25+ draft: boolean;
26+ requestedReviewers: string[];
27+ intent: string | null;
28+ createdAt: string;
29+ updatedAt: string;
30+ /** People whose latest vote is an approval, and whether one of them approved the current head. */
31+ approvedBy: string[];
32+ approvedHead: boolean;
33+ /** People (and agents) whose latest vote asks for changes. */
34+ changesRequestedBy: string[];
35+ /** Ids of everyone whose latest vote is an approval or a change request. */
36+ reviewerIds: string[];
37+ checks: ChecksState;
38+ failingChecks: string[];
39+ autoMerge: boolean;
40+ queued: boolean;
41+}
42+
43+export interface TodayMerged {
44+ number: number;
45+ title: string;
46+ author: TodayPerson;
47+ mergedAt: string;
48+ mergedBy: string | null;
49+}
50+
51+export interface TodayRelease {
52+ tag: string;
53+ name: string;
54+ author: TodayPerson;
55+ publishedAt: string;
56+ prerelease: boolean;
57+}
58+
59+export interface TodayRun {
60+ id: string;
61+ runNumber: number;
62+ workflow: string;
63+ title: string;
64+ ref: string;
65+ event: string;
66+ actor: TodayPerson;
67+ conclusion: string;
68+ prNumber: number | null;
69+ at: string;
70+}
71+
72+export interface RepoToday {
73+ pulls: TodayPull[];
74+ merged: TodayMerged[];
75+ releases: TodayRelease[];
76+ failedRuns: TodayRun[];
77+}
78+
79+/** At most this many open pull requests per repository (most recently updated first). */
80+export const MAX_PULLS = 50;
81+const MAX_RECENT = 20;
82+
83+const FAILED = new Set(["failure", "timed_out", "startup_failure", "action_required", "cancelled"]);
84+
85+/** One state for a commit's checks and statuses: any failure wins, then anything unfinished. */
86+export function checksState(checks: Pick<Check, "name" | "status" | "conclusion">[]): { state: ChecksState; failing: string[] } {
87+ if (!checks.length) return { state: "none", failing: [] };
88+ const failing = checks.filter((c) => c.status === "completed" && FAILED.has(c.conclusion ?? "")).map((c) => c.name);
89+ if (failing.length) return { state: "failure", failing: [...new Set(failing)] };
90+ return { state: checks.some((c) => c.status !== "completed") ? "pending" : "success", failing: [] };
91+}
92+
93+type PullJoin = {
94+ number: number;
95+ title: string;
96+ author_id: string;
97+ author_login: string;
98+ author_kind: string;
99+ created_at: string;
100+ updated_at: string;
101+ head_ref: string;
102+ head_sha: string;
103+ base_ref: string;
104+ draft: number;
105+ requested_reviewers: string;
106+ intent: string | null;
107+ auto_merge: string | null;
108+ queued: number;
109+};
110+
111+type ReviewRow = { number: number; author_id: string; author_login: string; author_kind: string; state: string; commit_id: string; imported: string | null };
112+
113+export function repoToday(sql: SqlStorage, checksFor: (sha: string) => Check[], since: string): RepoToday {
114+ const rows = sql
115+ .exec<PullJoin>(
116+ `SELECT i.number, i.title, i.author_id, i.author_login, i.author_kind, i.created_at, i.updated_at,
117+ p.head_ref, p.head_sha, p.base_ref, p.draft, p.requested_reviewers, p.intent, p.auto_merge,
118+ EXISTS (SELECT 1 FROM merge_queue q WHERE q.number = i.number) AS queued
119+ FROM issues i JOIN pulls p ON p.number = i.number
120+ WHERE i.kind = 'pull' AND i.state = 'open'
121+ ORDER BY i.updated_at DESC LIMIT ?`,
122+ MAX_PULLS,
123+ )
124+ .toArray();
125+ const numbers = rows.map((r) => r.number);
126+ const reviews = numbers.length
127+ ? sql
128+ .exec<ReviewRow>(
129+ `SELECT number, author_id, author_login, author_kind, state, commit_id, imported FROM reviews
130+ WHERE number IN (${numbers.map(() => "?").join(",")}) ORDER BY submitted_at`,
131+ ...numbers,
132+ )
133+ .toArray()
134+ : [];
135+ const pulls = rows.map((r): TodayPull => {
136+ // Each reviewer's latest vote, as the merge rules count them: comments don't vote, imported reviews are history.
137+ const latest = new Map<string, ReviewRow>();
138+ for (const rv of reviews) if (rv.number === r.number && rv.state !== "COMMENTED" && !rv.imported && rv.author_kind !== "github") latest.set(rv.author_id, rv);
139+ const votes = [...latest.values()];
140+ const approvals = votes.filter((v) => v.state === "APPROVED" && v.author_kind === "user");
141+ const checks = checksState(checksFor(r.head_sha));
142+ return {
143+ number: r.number,
144+ title: r.title,
145+ author: { id: r.author_id, login: r.author_login, kind: r.author_kind },
146+ head: r.head_ref,
147+ base: r.base_ref,
148+ draft: !!r.draft,
149+ requestedReviewers: JSON.parse(r.requested_reviewers) as string[],
150+ intent: r.intent,
151+ createdAt: r.created_at,
152+ updatedAt: r.updated_at,
153+ approvedBy: approvals.map((v) => v.author_login),
154+ approvedHead: approvals.some((v) => v.commit_id === r.head_sha),
155+ changesRequestedBy: votes.filter((v) => v.state === "CHANGES_REQUESTED").map((v) => v.author_login),
156+ reviewerIds: votes.filter((v) => v.state === "APPROVED" || v.state === "CHANGES_REQUESTED").map((v) => v.author_id),
157+ checks: checks.state,
158+ failingChecks: checks.failing,
159+ autoMerge: !!r.auto_merge,
160+ queued: !!r.queued,
161+ };
162+ });
163+
164+ const merged = sql
165+ .exec<{ number: number; title: string; author_id: string; author_login: string; author_kind: string; merged_at: string; merged_by: string | null }>(
166+ `SELECT i.number, i.title, i.author_id, i.author_login, i.author_kind, p.merged_at, p.merged_by
167+ FROM pulls p JOIN issues i ON i.number = p.number
168+ WHERE p.merged = 1 AND p.merged_at >= ? AND p.imported IS NULL
169+ ORDER BY p.merged_at DESC LIMIT ?`,
170+ since,
171+ MAX_RECENT,
172+ )
173+ .toArray()
174+ .map((m) => ({ number: m.number, title: m.title, author: { id: m.author_id, login: m.author_login, kind: m.author_kind }, mergedAt: m.merged_at, mergedBy: m.merged_by }));
175+
176+ const releases = sql
177+ .exec<{ tag_name: string; name: string; author_id: string; author_login: string; author_kind: string; published_at: string; prerelease: number }>(
178+ `SELECT tag_name, name, author_id, author_login, author_kind, published_at, prerelease FROM releases
179+ WHERE draft = 0 AND published_at >= ? ORDER BY published_at DESC LIMIT ?`,
180+ since,
181+ MAX_RECENT,
182+ )
183+ .toArray()
184+ .map((x) => ({ tag: x.tag_name, name: x.name || x.tag_name, author: { id: x.author_id, login: x.author_login, kind: x.author_kind }, publishedAt: x.published_at, prerelease: !!x.prerelease }));
185+
186+ const failedRuns = sql
187+ .exec<{ id: string; run_number: number; workflow_name: string; display_title: string; ref: string; event: string; actor: string; conclusion: string; pr_number: number | null; updated_at: string }>(
188+ `SELECT id, run_number, workflow_name, display_title, ref, event, actor, conclusion, pr_number, updated_at FROM runs
189+ WHERE status = 'completed' AND conclusion IN ('failure', 'timed_out', 'startup_failure') AND updated_at >= ?
190+ ORDER BY updated_at DESC LIMIT ?`,
191+ since,
192+ MAX_RECENT,
193+ )
194+ .toArray()
195+ .map((x) => {
196+ let actor: TodayPerson = { id: "", login: "unknown", kind: "system" };
197+ try {
198+ const a = JSON.parse(x.actor) as Partial<TodayPerson>;
199+ actor = { id: a.id ?? "", login: a.login ?? "unknown", kind: a.kind ?? "user" };
200+ } catch {
201+ // An unreadable actor shows as unknown.
202+ }
203+ return { id: x.id, runNumber: x.run_number, workflow: x.workflow_name, title: x.display_title, ref: x.ref, event: x.event, actor, conclusion: x.conclusion, prNumber: x.pr_number, at: x.updated_at };
204+ });
205+
206+ return { pulls, merged, releases, failedRuns };
207+}
addedapps/diffinery/src/agentic/today.ts+238−0View file
@@ -1,0 +1,238 @@
1+import type { DiffineryEvent } from "@diffinery/shared";
2+import type { Env } from "../env.js";
3+import type { Account, Directory, Repo } from "../lib/directory.js";
4+import { repoStub } from "../http/common.js";
5+import { atLeast } from "../repo/types.js";
6+import type { RepoToday, TodayMerged, TodayPerson, TodayPull, TodayRelease, TodayRun } from "./today-repo.js";
7+
8+/**
9+ * Today: one person's morning view across every repository they can see.
10+ * What needs them (reviews, agent pull requests waiting for a human), what
11+ * their agents are doing, and what landed or failed in the last day.
12+ *
13+ * Each repository's Durable Object summarises itself (./today-repo.ts); this
14+ * file picks the repositories, gathers their summaries and sorts the items
15+ * for one viewer. `classify` is pure so the rules are unit tested.
16+ */
17+
18+/** Visit at most this many repositories, the most recently active first. */
19+export const MAX_REPOS = 100;
20+/** Repositories with no activity for this long are skipped. */
21+export const ACTIVE_DAYS = 30;
22+/** "Since yesterday" looks back this far. */
23+export const SINCE_HOURS = 24;
24+
25+export interface TodayRepo {
26+ id: string;
27+ fullName: string;
28+ url: string;
29+ defaultBranch: string;
30+ visibility: string;
31+}
32+
33+export type NeedsKind = "review_requested" | "agent_approval" | "changes_requested" | "checks_failing";
34+
35+export type AgentPullState = "draft" | "landing" | "checks_failing" | "changes_requested" | "approved" | "checks_running" | "waiting_for_approval";
36+
37+export interface NeedsItem {
38+ kind: NeedsKind;
39+ repo: TodayRepo;
40+ pull: TodayPull;
41+}
42+
43+export interface AgentItem {
44+ state: AgentPullState;
45+ repo: TodayRepo;
46+ pull: TodayPull;
47+}
48+
49+export interface Today {
50+ generatedAt: string;
51+ since: string;
52+ /** How many repositories were read, and whether some active ones were left out or failed to answer. */
53+ repositories: number;
54+ incomplete: boolean;
55+ needsYou: NeedsItem[];
56+ agentsAtWork: AgentItem[];
57+ landed: Array<{ repo: TodayRepo; pull: TodayMerged }>;
58+ releases: Array<{ repo: TodayRepo; release: TodayRelease }>;
59+ failures: Array<{ repo: TodayRepo; run: TodayRun }>;
60+}
61+
62+export interface ClassifyInput {
63+ me: { id: string; login: string };
64+ /** Agents the viewer or their organizations own (and the viewer itself, when an agent asks). */
65+ agentIds: Set<string>;
66+ /** Repositories where the viewer can approve (write or more). */
67+ writable: Set<string>;
68+ repos: Array<{ repo: TodayRepo; data: RepoToday }>;
69+}
70+
71+const isAgent = (p: TodayPerson) => p.kind === "agent";
72+const byNewest = <T>(at: (x: T) => string) => (a: T, b: T) => at(b).localeCompare(at(a));
73+
74+/** Where an agent's open pull request stands, in the order a person would act on it. */
75+export function agentPullState(p: TodayPull): AgentPullState {
76+ if (p.draft) return "draft";
77+ if (p.queued || p.autoMerge) return "landing";
78+ if (p.checks === "failure") return "checks_failing";
79+ if (p.changesRequestedBy.length) return "changes_requested";
80+ if (p.approvedHead) return "approved";
81+ if (p.checks === "pending") return "checks_running";
82+ return "waiting_for_approval";
83+}
84+
85+/** Repositories with an agent pull request that may be waiting for a person: only these need the viewer's role. */
86+export function approvalCandidates(repos: ClassifyInput["repos"], me: { id: string }): string[] {
87+ return repos.filter((r) => r.data.pulls.some((p) => isAgent(p.author) && p.author.id !== me.id && waitsForPerson(p))).map((r) => r.repo.id);
88+}
89+
90+const waitsForPerson = (p: TodayPull) => ["waiting_for_approval", "checks_running"].includes(agentPullState(p));
91+
92+export function classify(input: ClassifyInput): Omit<Today, "generatedAt" | "since" | "repositories" | "incomplete"> {
93+ const me = input.me.login.toLowerCase();
94+ const needsYou: NeedsItem[] = [];
95+ const agentsAtWork: AgentItem[] = [];
96+ const landed: Today["landed"] = [];
97+ const releases: Today["releases"] = [];
98+ const failures: Today["failures"] = [];
99+ for (const { repo, data } of input.repos) {
100+ for (const pull of data.pulls) {
101+ const mine = pull.author.id === input.me.id;
102+ let need: NeedsKind | null = null;
103+ if (!pull.draft && !mine && pull.requestedReviewers.some((r) => r.toLowerCase() === me)) need = "review_requested";
104+ else if (!mine && isAgent(pull.author) && input.writable.has(repo.id) && waitsForPerson(pull)) need = "agent_approval";
105+ else if (mine && pull.changesRequestedBy.length) need = "changes_requested";
106+ else if (mine && pull.checks === "failure") need = "checks_failing";
107+ if (need) needsYou.push({ kind: need, repo, pull });
108+ if (input.agentIds.has(pull.author.id)) agentsAtWork.push({ state: agentPullState(pull), repo, pull });
109+ }
110+ for (const pull of data.merged) landed.push({ repo, pull });
111+ for (const release of data.releases) releases.push({ repo, release });
112+ const defaultRef = `refs/heads/${repo.defaultBranch}`;
113+ for (const run of data.failedRuns) {
114+ const ours = run.actor.id === input.me.id || input.agentIds.has(run.actor.id);
115+ const onDefault = (run.ref === defaultRef || run.ref === repo.defaultBranch) && run.event !== "pull_request";
116+ if (ours || onDefault) failures.push({ repo, run });
117+ }
118+ }
119+ needsYou.sort(byNewest((x) => x.pull.updatedAt));
120+ agentsAtWork.sort(byNewest((x) => x.pull.updatedAt));
121+ landed.sort(byNewest((x) => x.pull.mergedAt));
122+ releases.sort(byNewest((x) => x.release.publishedAt));
123+ failures.sort(byNewest((x) => x.run.at));
124+ return { needsYou, agentsAtWork, landed: landed.slice(0, 30), releases: releases.slice(0, 20), failures: failures.slice(0, 20) };
125+}
126+
127+/** The repositories Today visits: not archived, active in the last ACTIVE_DAYS, most recent first. */
128+export function pickRepos(repos: Repo[], activity: Map<string, string>, now: Date): { repos: Repo[]; skipped: number } {
129+ const cutoff = new Date(now.getTime() - ACTIVE_DAYS * 86400_000).toISOString();
130+ const lastActive = (r: Repo) => [r.pushed_at ?? "", activity.get(r.id) ?? "", r.created_at].reduce((a, b) => (b > a ? b : a), "");
131+ const active = repos
132+ .filter((r) => !r.archived && lastActive(r) >= cutoff)
133+ .map((r) => ({ r, at: lastActive(r) }))
134+ .sort((a, b) => b.at.localeCompare(a.at))
135+ .map((x) => x.r);
136+ return { repos: active.slice(0, MAX_REPOS), skipped: Math.max(0, active.length - MAX_REPOS) };
137+}
138+
139+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 });
140+
141+/**
142+ * Gather Today for one account (a person, or an agent asking over the API or MCP).
143+ * `repoIds` limits it to the repositories a token is restricted to.
144+ */
145+export async function gatherToday(env: Env, dir: Directory, me: Account, opts: { repoIds?: string[] | null; now?: Date } = {}): Promise<Today> {
146+ const now = opts.now ?? new Date();
147+ const since = new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString();
148+ const cutoff = new Date(now.getTime() - ACTIVE_DAYS * 86400_000).toISOString();
149+ const [visible, activityRows, orgs] = await Promise.all([
150+ dir.reposVisibleTo(me),
151+ env.DB.prepare("SELECT repo_id, at FROM repo_activity WHERE at >= ?").bind(cutoff).all<{ repo_id: string; at: string }>(),
152+ me.kind === "user" ? dir.orgsFor(me.id) : Promise.resolve([]),
153+ ]);
154+ const activity = new Map(activityRows.results.map((r) => [r.repo_id, r.at]));
155+ const allowed = opts.repoIds ? visible.filter((r) => opts.repoIds!.includes(r.id)) : visible;
156+ const { repos, skipped } = pickRepos(allowed, activity, now);
157+ const owners = me.kind === "agent" ? [] : [me.id, ...orgs.map((o) => o.id)];
158+ const agentIds = new Set((await dir.agentsOwnedBy(owners)).map((a) => a.id));
159+ if (me.kind === "agent") agentIds.add(me.id);
160+
161+ const settled = await Promise.allSettled(repos.map(async (r) => ({ repo: repoRef(r), data: await repoStub(env, r.id).todaySummary(since) })));
162+ const answered = settled.flatMap((s, i) => {
163+ if (s.status === "fulfilled") return [s.value];
164+ console.error("today: repository summary failed", repos[i]!.id, s.reason);
165+ return [];
166+ });
167+
168+ const byId = new Map(repos.map((r) => [r.id, r]));
169+ const writable = new Set<string>();
170+ await Promise.all(
171+ // An agent's approval never counts, so nothing waits on an agent to approve.
172+ (me.kind === "agent" ? [] : approvalCandidates(answered, me)).map(async (id) => {
173+ if (atLeast(await dir.roleFor(me, byId.get(id)!), "write")) writable.add(id);
174+ }),
175+ );
176+
177+ return {
178+ generatedAt: now.toISOString(),
179+ since,
180+ repositories: answered.length,
181+ incomplete: skipped > 0 || answered.length < repos.length,
182+ ...classify({ me, agentIds, writable, repos: answered }),
183+ };
184+}
185+
186+/** Remember when a repository last had activity (from the events queue), so Today finds it. Never throws. */
187+export async function recordActivity(env: Env, ev: DiffineryEvent): Promise<void> {
188+ if (ev.type === "audit" || !ev.repoId) return;
189+ try {
190+ 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")
191+ .bind(ev.repoId, ev.at)
192+ .run();
193+ } catch (e) {
194+ console.error("today: recording activity failed", ev.repoId, e);
195+ }
196+}
197+
198+// ---------------------------------------------------------------- JSON for the REST API and MCP
199+
200+const person = (p: TodayPerson) => ({ login: p.login, type: p.kind === "agent" ? "Bot" : p.kind === "user" ? "User" : p.kind });
201+
202+function pullJson(base: string, repo: TodayRepo, p: TodayPull) {
203+ return {
204+ repository: repo.fullName,
205+ number: p.number,
206+ title: p.title,
207+ html_url: `${base}${repo.url}/pull/${p.number}`,
208+ user: person(p.author),
209+ head: p.head,
210+ base: p.base,
211+ draft: p.draft,
212+ intent: p.intent,
213+ requested_reviewers: p.requestedReviewers,
214+ approved_by: p.approvedBy,
215+ approved_head: p.approvedHead,
216+ changes_requested_by: p.changesRequestedBy,
217+ checks: p.checks,
218+ failing_checks: p.failingChecks,
219+ auto_merge: p.autoMerge,
220+ in_merge_queue: p.queued,
221+ created_at: p.createdAt,
222+ updated_at: p.updatedAt,
223+ };
224+}
225+
226+export function todayJson(base: string, t: Today) {
227+ return {
228+ generated_at: t.generatedAt,
229+ since: t.since,
230+ repositories: t.repositories,
231+ incomplete_results: t.incomplete,
232+ needs_you: t.needsYou.map((x) => ({ reason: x.kind, ...pullJson(base, x.repo, x.pull) })),
233+ agents_at_work: t.agentsAtWork.map((x) => ({ state: x.state, ...pullJson(base, x.repo, x.pull) })),
234+ 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 })),
235+ 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 })),
236+ 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 })),
237+ };
238+}
addedapps/diffinery/src/agentic/web.tsx+190−0View file
@@ -1,0 +1,190 @@
1+/** @jsxImportSource hono/jsx */
2+import { raw } from "hono/html";
3+import type { FC } from "hono/jsx";
4+import { ICONS } from "../web/assets.js";
5+import { Actor, Status, Time } from "../web/ui.js";
6+import type { AgentItem, AgentPullState, NeedsItem, NeedsKind, Today } from "./today.js";
7+import type { TodayPull } from "./today-repo.js";
8+
9+/** The Today view's sections on the dashboard (web/account.tsx): what needs you, your agents, and the last day. */
10+
11+const NEEDS_TEXT: Record<NeedsKind, { label: string; tone: string }> = {
12+ review_requested: { label: "Review requested", tone: "blue" },
13+ agent_approval: { label: "Agent PR waiting for a person", tone: "purple" },
14+ changes_requested: { label: "Changes requested", tone: "amber" },
15+ checks_failing: { label: "Checks failing", tone: "red" },
16+};
17+
18+const AGENT_TEXT: Record<AgentPullState, { label: string; tone: string }> = {
19+ draft: { label: "Draft", tone: "" },
20+ landing: { label: "Landing", tone: "green" },
21+ checks_failing: { label: "Checks failing", tone: "red" },
22+ changes_requested: { label: "Changes requested", tone: "amber" },
23+ approved: { label: "Approved", tone: "green" },
24+ checks_running: { label: "Checks running", tone: "blue" },
25+ waiting_for_approval: { label: "Waiting for approval", tone: "purple" },
26+};
27+
28+const ChecksIcon: FC<{ p: TodayPull }> = ({ p }) =>
29+ p.checks === "none" ? (
30+ <span class="st faint" title="No checks">{raw(ICONS.pr)}</span>
31+ ) : p.checks === "pending" ? (
32+ <Status status="in_progress" />
33+ ) : (
34+ <Status conclusion={p.checks} />
35+ );
36+
37+const PullLine: FC<{ repo: { fullName: string; url: string }; p: TodayPull; badge: { label: string; tone: string } }> = ({ repo, p, badge }) => (
38+ <li>
39+ <ChecksIcon p={p} />
40+ <div class="grow">
41+ <a class="title" href={`${repo.url}/pull/${p.number}`}>
42+ {p.title}
43+ </a>{" "}
44+ <span class={`badge ${badge.tone}`}>{badge.label}</span>
45+ <div class="meta">
46+ <a href={repo.url} class="muted">
47+ {repo.fullName}
48+ </a>{" "}
49+ #{p.number} by <Actor login={p.author.login} kind={p.author.kind} avatar={false} /> · updated <Time at={p.updatedAt} />
50+ {p.failingChecks.length ? <> · failing: {p.failingChecks.slice(0, 3).join(", ")}</> : null}
51+ {p.changesRequestedBy.length ? <> · changes requested by {p.changesRequestedBy.join(", ")}</> : null}
52+ </div>
53+ {p.intent ? <div class="meta">Intent: {p.intent.length > 160 ? `${p.intent.slice(0, 157)}…` : p.intent}</div> : null}
54+ </div>
55+ </li>
56+);
57+
58+const Tile: FC<{ n: number; label: string; href: string; tone?: string }> = ({ n, label, href, tone }) => (
59+ <a class={`tile ${n && tone ? tone : ""}`} href={href}>
60+ <span class="num">{n}</span>
61+ <span class="lbl">{label}</span>
62+ </a>
63+);
64+
65+export const TodayView: FC<{ today: Today; name: string }> = ({ today, name }) => {
66+ const working = today.agentsAtWork.filter((x) => x.state !== "draft").length;
67+ const landedByAgents = today.landed.filter((x) => x.pull.author.kind === "agent").length;
68+ return (
69+ <div>
70+ <div class="row between mb">
71+ <h1 class="m0" style="font-size:22px">Today</h1>
72+ <span class="muted small">
73+ {name} · {today.repositories} active {today.repositories === 1 ? "repository" : "repositories"}
74+ {today.incomplete ? " (some left out)" : ""}
75+ </span>
76+ </div>
77+ <div class="tiles">
78+ <Tile n={today.needsYou.length} label="need you" href="#needs-you" tone="purple" />
79+ <Tile n={working} label="agent pull requests open" href="#agents" />
80+ <Tile n={today.landed.length} label={`merged since yesterday${landedByAgents ? ` (${landedByAgents} by agents)` : ""}`} href="#landed" />
81+ <Tile n={today.failures.length} label="failed runs" href="#landed" tone="red" />
82+ </div>
83+ <NeedsYou items={today.needsYou} />
84+ <Agents items={today.agentsAtWork} />
85+ <SinceYesterday today={today} />
86+ </div>
87+ );
88+};
89+
90+const NeedsYou: FC<{ items: NeedsItem[] }> = ({ items }) => (
91+ <div class="box" id="needs-you">
92+ <div class="head">
93+ <h2>{raw(ICONS.eye)} Needs you</h2>
94+ <span class="muted small">Reviews you were asked for, agent work waiting for a person, and your own blocked pull requests</span>
95+ </div>
96+ {items.length ? (
97+ <ul class="list">
98+ {items.slice(0, 30).map((x) => (
99+ <PullLine repo={x.repo} p={x.pull} badge={NEEDS_TEXT[x.kind]} />
100+ ))}
101+ </ul>
102+ ) : (
103+ <div class="empty">Nothing is waiting on you.</div>
104+ )}
105+ </div>
106+);
107+
108+const Agents: FC<{ items: AgentItem[] }> = ({ items }) => (
109+ <div class="box" id="agents">
110+ <div class="head">
111+ <h2>{raw(ICONS.bot)} Agents at work</h2>
112+ <span class="muted small">Open pull requests from agents you and your organizations own</span>
113+ <a class="small" href="/settings/agents" style="margin-left:auto">
114+ Manage agents
115+ </a>
116+ </div>
117+ {items.length ? (
118+ <ul class="list">
119+ {items.slice(0, 30).map((x) => (
120+ <PullLine repo={x.repo} p={x.pull} badge={AGENT_TEXT[x.state]} />
121+ ))}
122+ </ul>
123+ ) : (
124+ <div class="empty">
125+ Your agents have no open pull requests. <a href="/docs/agents/overview">Set up a coding agent</a>
126+ </div>
127+ )}
128+ </div>
129+);
130+
131+const SinceYesterday: FC<{ today: Today }> = ({ today }) => {
132+ const empty = !today.landed.length && !today.releases.length && !today.failures.length;
133+ return (
134+ <div class="box" id="landed">
135+ <div class="head">
136+ <h2>{raw(ICONS.history)} Since yesterday</h2>
137+ <span class="muted small">Merged, released and failed in the last 24 hours</span>
138+ </div>
139+ {empty ? (
140+ <div class="empty">Nothing merged, released or failed in the last 24 hours.</div>
141+ ) : (
142+ <ul class="list">
143+ {today.releases.map((x) => (
144+ <li>
145+ <span class="st ok">{raw(ICONS.tag)}</span>
146+ <div class="grow">
147+ <a class="title" href={`${x.repo.url}/releases/tag/${encodeURIComponent(x.release.tag)}`}>
148+ {x.release.name}
149+ </a>{" "}
150+ <span class="badge green">{x.release.prerelease ? "Pre-release" : "Released"}</span>
151+ <div class="meta">
152+ {x.repo.fullName} · by <Actor login={x.release.author.login} kind={x.release.author.kind} avatar={false} /> · <Time at={x.release.publishedAt} />
153+ </div>
154+ </div>
155+ </li>
156+ ))}
157+ {today.failures.map((x) => (
158+ <li>
159+ <span class="st bad">{raw(ICONS.failure)}</span>
160+ <div class="grow">
161+ <a class="title" href={`${x.repo.url}/actions/runs/${x.run.id}`}>
162+ {x.run.title || x.run.workflow}
163+ </a>{" "}
164+ <span class="badge red">{x.run.workflow} failed</span>
165+ <div class="meta">
166+ {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} />
167+ </div>
168+ </div>
169+ </li>
170+ ))}
171+ {today.landed.map((x) => (
172+ <li>
173+ <span class="st" style="color:var(--purple)">{raw(ICONS.prMerged)}</span>
174+ <div class="grow">
175+ <a class="title" href={`${x.repo.url}/pull/${x.pull.number}`}>
176+ {x.pull.title}
177+ </a>{" "}
178+ <span class="badge purple">Merged</span>
179+ <div class="meta">
180+ {x.repo.fullName} #{x.pull.number} by <Actor login={x.pull.author.login} kind={x.pull.author.kind} avatar={false} />
181+ {x.pull.mergedBy ? <> · merged by {x.pull.mergedBy}</> : null} · <Time at={x.pull.mergedAt} />
182+ </div>
183+ </div>
184+ </li>
185+ ))}
186+ </ul>
187+ )}
188+ </div>
189+ );
190+};
modifiedapps/diffinery/src/app.ts+3−0View file
@@ -26,6 +26,7 @@
2626 import { RegistryStore } from "./registry/store.js";
2727 import { mailCron, notifyEvent } from "./mail/notify.js";
2828 import { mailQueue } from "./mail/send.js";
29+import { recordActivity } from "./agentic/today.js";
2930 import type { MailJob } from "./mail/types.js";
3031
3132 export const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
@@ -155,6 +156,8 @@
155156 await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.action, target: ev.target, detail: ev.detail, at: ev.at });
156157 return;
157158 }
159+ // The Today view finds repositories by their latest activity (agentic/today.ts).
160+ await recordActivity(env, ev);
158161 if (ev.type === "push") {
159162 await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.forced ? "git.force_push" : ev.deleted ? "git.delete_ref" : "git.push", target: `${ev.owner}/${ev.repo}:${ev.ref}`, detail: { before: ev.before, after: ev.after }, at: ev.at });
160163 }
modifiedapps/diffinery/src/docs/api-meta.ts+5−0View file
@@ -1297,6 +1297,11 @@
12971297 },
12981298 },
12991299 "DELETE /diffinery/tokens/{id}": { summary: "Revoke a token", access: "account", status: 204 },
1300+ "GET /diffinery/today": {
1301+ summary: "Your morning view",
1302+ description: "Pull requests that need you, your agents' open pull requests with their state, and what merged, was released or failed in the last 24 hours, across the repositories you can see that were active in the last 30 days (at most 100). A token limited to repositories sees only those. See /docs/agents/today.",
1303+ access: "account",
1304+ },
13001305 "GET /diffinery/agents": { summary: "List your agents", description: "Agents you own, and agents of organizations you administer.", access: "account" },
13011306 "POST /diffinery/agents": {
13021307 summary: "Create an agent",
modifiedapps/diffinery/src/http/api.ts+3−0View file
@@ -11,6 +11,7 @@
1111 import type { CommitInfo } from "../repo/RepoDO.js";
1212 import type { QueueEntry } from "../repo/queue.js";
1313 import { imports } from "./imports.js";
14+import { agenticApi } from "../agentic/api.js";
1415 import { auditRepo, loadRepo, refuseAgent, repoStub } from "./common.js";
1516 import { repoCreated } from "../mail/notify.js";
1617 import { actionsRest } from "./actions-runtime.js";
@@ -54,6 +55,8 @@
5455
5556 // GitHub imports, milestones, organization variables and linked accounts.
5657 api.route("/", imports);
58+// Agentic work: the Today view (agentic/).
59+api.route("/", agenticApi);
5760
5861 // ---------------------------------------------------------------- meta & users
5962
modifiedapps/diffinery/src/mcp/server.ts+2−0View file
@@ -7,6 +7,7 @@
77 import { loadRepo } from "../http/common.js";
88 import { RELEASE_TOOLS } from "./releases.js";
99 import { DOCS_TOOLS } from "./docs.js";
10+import { AGENTIC_TOOLS } from "../agentic/mcp.js";
1011 import { ImportService } from "../imports/service.js";
1112 import { importJson } from "../http/imports.js";
1213
@@ -439,6 +440,7 @@
439440 },
440441 ...RELEASE_TOOLS,
441442 ...DOCS_TOOLS,
443+ ...AGENTIC_TOOLS,
442444 start_github_import: {
443445 description:
444446 "Start a server-side import of a GitHub repository into Diffinery: git history (keeping issue and pull-request numbers), issues and pull requests with comments and reviews, labels, milestones, branch protection, Actions variables, releases and deployment environments. Secret VALUES are never read from GitHub (only their names are reported, to set by hand), and push protection is never bypassed on an agent's behalf. An agent can import only where its owner may: the owner's own account or an organization the owner administers (an organization's agent: that organization), and only when the agent's policy allows write. Returns the import id; poll it with get_import.",
modifiedapps/diffinery/src/repo/RepoDO.ts+6−0View file
@@ -54,6 +54,7 @@
5454 import { Runtime } from "./runtime.js";
5555 import { TokenPolicy } from "./token-policy.js";
5656 import { searchIssues } from "./search.js";
57+import { repoToday } from "../agentic/today-repo.js";
5758 import { Social } from "./social.js";
5859 import { Statuses } from "./statuses.js";
5960 import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js";
@@ -1090,6 +1091,11 @@
10901091 return [...this.collab.checks(sha), ...this.statuses.asChecks(sha)];
10911092 }
10921093
1094+ /** This repository's part of the Today view (agentic/today.ts): open pull requests and what landed or failed since `since`. */
1095+ todaySummary(since: string) {
1096+ return repoToday(this.sql, (sha) => this.checksAndStatuses(sha), since);
1097+ }
1098+
10931099 createDeployment(...a: Parameters<Deployments["create"]>) {
10941100 return this.deploys.create(...a);
10951101 }
modifiedapps/diffinery/src/web/account.tsx+17−3View file
@@ -18,6 +18,8 @@
1818 import type { FormData } from "./csrf.js";
1919 import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
2020 import { MAX_AVATAR_BYTES } from "../lib/image.js";
21+import { gatherToday } from "../agentic/today.js";
22+import { TodayView } from "../agentic/web.js";
2123
2224 // ---------------------------------------------------------------- shared bits
2325
@@ -221,14 +223,24 @@
221223 );
222224 }
223225 const dir = c.get("dir");
224- const [repos, orgs, notes] = await Promise.all([dir.reposVisibleTo(me), dir.orgsFor(me.id), dir.notifications(me.id, true)]);
226+ const [repos, orgs, notes, today] = await Promise.all([
227+ dir.reposVisibleTo(me),
228+ dir.orgsFor(me.id),
229+ dir.notifications(me.id, true),
230+ // The morning view (agentic/today.ts); the dashboard still renders if it fails.
231+ gatherToday(c.env, dir, me).catch((e: unknown) => {
232+ console.error("today failed", e);
233+ return null;
234+ }),
235+ ]);
225236 const mine = repos.filter((r) => r.owner_id === me.id || orgs.some((o) => o.id === r.owner_id));
226237 const others = repos.filter((r) => !mine.includes(r));
227238 return render(
228239 c,
229- { title: "Dashboard" },
240+ { title: "Today" },
230241 <div class="grid left">
231- <aside class="side">
242+ {/* On phones, Today comes first and the repository list after it. */}
243+ <aside class="side after">
232244 <section>
233245 <div class="row between mb">
234246 <h3 class="m0">Your repositories</h3>
@@ -281,6 +293,8 @@
281293 </ol>
282294 </div>
283295 </div>
296+ ) : today ? (
297+ <TodayView today={today} name={me.name || me.login} />
284298 ) : null}
285299 {notes.length ? (
286300 <div class="box">
modifiedapps/diffinery/src/web/assets.ts+8−1View file
@@ -102,7 +102,7 @@
102102 .formrow+.hint{font-size:12px;color:var(--fg2);margin-top:8px}
103103 .grid{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:24px}
104104 .grid.left{grid-template-columns:260px minmax(0,1fr)}
105-@media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.pal-btn{min-width:0}.pal-btn span{display:none}}@media (max-width:600px){.pal-btn kbd{display:none}}
105+@media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.grid.left>.side.after{order:2}.pal-btn{min-width:0}.pal-btn span{display:none}}@media (max-width:600px){.pal-btn kbd{display:none}}
106106 .box{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);margin:0 0 16px;overflow:hidden}
107107 .box>.head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap}
108108 .box>.body{padding:14px}
@@ -256,6 +256,13 @@
256256 .features .box{padding:18px}.features h3{margin:0 0 6px}
257257 .stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px}
258258 .stats b{color:var(--fg)}
259+.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:0 0 16px}
260+.tile{display:flex;flex-direction:column;gap:2px;border:1px solid var(--border);border-radius:var(--r);padding:12px 14px;background:var(--bg);color:var(--fg);text-decoration:none}
261+.tile:hover{border-color:var(--accent);text-decoration:none}
262+.tile .num{font-size:24px;font-weight:600;line-height:1.2}
263+.tile .lbl{font-size:12px;color:var(--fg2)}
264+.tile.purple .num{color:var(--purple)}
265+.tile.red .num{color:var(--red)}
259266 .kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px}.kv dt{color:var(--fg2)}.kv dd{margin:0}
260267 table.tbl{width:100%;border-collapse:collapse;font-size:13px}
261268 table.tbl th{text-align:left;font-weight:600;color:var(--fg2);padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);white-space:nowrap}
addedapps/diffinery/test/e2e/agentic.test.mjs+211−0View file
@@ -1,0 +1,211 @@
1+// End-to-end agentic features: a real `wrangler dev` Worker and the real git CLI.
2+//
3+// pnpm --filter @diffinery/app e2e
4+//
5+// Covers the Today view (the morning view of agent work): an agent's pull
6+// request waiting for a person, an agent's failing pull request, approvals and
7+// merges moving items between sections, the REST API, the MCP tool, an agent's
8+// own view and the dashboard page.
9+import { spawn, execFileSync } from "node:child_process";
10+import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
11+import { tmpdir } from "node:os";
12+import { randomBytes } from "node:crypto";
13+import { join, dirname, resolve } from "node:path";
14+import { fileURLToPath } from "node:url";
15+import { createServer } from "node:net";
16+import { test, before, after } from "node:test";
17+import assert from "node:assert/strict";
18+
19+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
20+const work = mkdtempSync(join(tmpdir(), "diffinery-agentic-e2e-"));
21+let base;
22+let dev;
23+let devLog = "";
24+let token;
25+
26+const freePort = () =>
27+ new Promise((res) => {
28+ const s = createServer();
29+ s.listen(0, "127.0.0.1", () => {
30+ const { port } = s.address();
31+ s.close(() => res(port));
32+ });
33+ });
34+
35+async function api(method, path, body, tok = token) {
36+ const res = await fetch(`${base}/api/v3${path}`, {
37+ method,
38+ headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" },
39+ body: body === undefined ? undefined : JSON.stringify(body),
40+ });
41+ const text = await res.text();
42+ let json = null;
43+ try {
44+ json = text ? JSON.parse(text) : null;
45+ } catch {
46+ json = text;
47+ }
48+ return { status: res.status, json };
49+}
50+async function ok(method, path, body, tok) {
51+ const r = await api(method, path, body, tok);
52+ assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
53+ return r.json;
54+}
55+
56+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
57+async function until(what, fn, timeoutMs = 60_000) {
58+ const end = Date.now() + timeoutMs;
59+ let last;
60+ while (Date.now() < end) {
61+ last = await fn();
62+ if (last) return last;
63+ await sleep(500);
64+ }
65+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
66+}
67+
68+function git(cwd, args, tok = token) {
69+ const auth = Buffer.from(`x-token:${tok}`).toString("base64");
70+ return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], {
71+ cwd,
72+ encoding: "utf8",
73+ stdio: ["ignore", "pipe", "pipe"],
74+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" },
75+ });
76+}
77+
78+async function mcp(name, args, tok = token) {
79+ const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${tok}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
80+ const { result } = await r.json();
81+ assert.equal(result.isError, false, result.content[0].text);
82+ return result.structuredContent;
83+}
84+
85+before(async () => {
86+ const port = await freePort();
87+ base = `http://127.0.0.1:${port}`;
88+ const persist = join(work, "state");
89+ execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
90+ dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], {
91+ cwd: APP,
92+ env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
93+ stdio: ["ignore", "pipe", "pipe"],
94+ detached: true,
95+ });
96+ dev.stdout.on("data", (d) => (devLog += d));
97+ dev.stderr.on("data", (d) => (devLog += d));
98+ await until("wrangler dev", async () => {
99+ try {
100+ return (await fetch(`${base}/healthz`)).ok;
101+ } catch {
102+ return false;
103+ }
104+ }, 120_000);
105+ const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) });
106+ assert.equal(res.status, 201, await res.clone().text());
107+ token = (await res.json()).token;
108+});
109+
110+after(() => {
111+ if (dev?.pid) {
112+ try {
113+ process.kill(-dev.pid, "SIGTERM");
114+ } catch {}
115+ }
116+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
117+});
118+
119+test("Today: what needs you, your agents' work and what landed", async () => {
120+ const me = await ok("GET", "/user");
121+ const owner = me.login;
122+ const empty = await ok("GET", "/diffinery/today");
123+ assert.deepEqual([empty.needs_you, empty.agents_at_work, empty.landed], [[], [], []]);
124+
125+ await ok("POST", "/user/repos", { name: "today-demo" });
126+ const src = join(work, "src");
127+ mkdirSync(src, { recursive: true });
128+ writeFileSync(join(src, "README.md"), "# today\n");
129+ git(src, ["init", "-q", "-b", "main"]);
130+ git(src, ["add", "-A"]);
131+ git(src, ["commit", "-q", "-m", "initial"]);
132+ git(src, ["remote", "add", "origin", `${base}/${owner}/today-demo.git`]);
133+ git(src, ["push", "-q", "origin", "main"]);
134+
135+ // An agent the dev user owns opens two pull requests; one of them fails its checks.
136+ const bot = (await ok("POST", "/diffinery/agents", { login: "today-bot", name: "Today Bot" }, null)).token.token;
137+ for (const [branch, file] of [
138+ ["fix-link", "link.txt"],
139+ ["add-test", "test.txt"],
140+ ]) {
141+ git(src, ["checkout", "-q", "-b", branch, "main"]);
142+ writeFileSync(join(src, file), `${branch}\n`);
143+ git(src, ["add", "-A"]);
144+ git(src, ["commit", "-q", "-m", branch]);
145+ git(src, ["push", "-q", "origin", branch], bot);
146+ }
147+ const fix = await ok("POST", `/repos/${owner}/today-demo/pulls`, { title: "Fix the Contact sales link", head: "fix-link", base: "main", intent: "Point the link at /contact" }, bot);
148+ const add = await ok("POST", `/repos/${owner}/today-demo/pulls`, { title: "Add a test", head: "add-test", base: "main" }, bot);
149+ await ok("POST", `/repos/${owner}/today-demo/statuses/${add.head.sha}`, { state: "failure", context: "ci/test", description: "1 failing" }, bot);
150+
151+ let t = await ok("GET", "/diffinery/today");
152+ assert.ok(t.repositories >= 1);
153+ assert.equal(t.incomplete_results, false);
154+ const waiting = t.needs_you.find((x) => x.number === fix.number);
155+ assert.ok(waiting, JSON.stringify(t.needs_you));
156+ assert.equal(waiting.reason, "agent_approval");
157+ assert.equal(waiting.repository, `${owner}/today-demo`);
158+ assert.equal(waiting.user.login, "today-bot");
159+ assert.equal(waiting.user.type, "Bot");
160+ assert.equal(waiting.intent, "Point the link at /contact");
161+ assert.equal(waiting.html_url, `${base}/${owner}/today-demo/pull/${fix.number}`);
162+ assert.ok(!t.needs_you.some((x) => x.number === add.number), "a failing agent pull request waits on the agent, not on a person");
163+ const states = Object.fromEntries(t.agents_at_work.map((x) => [x.number, x]));
164+ assert.equal(states[fix.number].state, "waiting_for_approval");
165+ assert.equal(states[add.number].state, "checks_failing");
166+ assert.deepEqual(states[add.number].failing_checks, ["ci/test"]);
167+
168+ // The agent's own view: its pull requests, and no approvals to give.
169+ const botView = await ok("GET", "/diffinery/today", undefined, bot);
170+ assert.deepEqual(botView.agents_at_work.map((x) => x.number).sort(), [fix.number, add.number].sort());
171+ assert.ok(!botView.needs_you.some((x) => x.reason === "agent_approval"));
172+
173+ // The MCP tool returns the same view.
174+ const viaMcp = await mcp("today", {});
175+ assert.deepEqual(viaMcp.needs_you.map((x) => x.number), t.needs_you.map((x) => x.number));
176+
177+ // The dashboard shows it.
178+ const page = await (await fetch(`${base}/`)).text();
179+ assert.match(page, /<h1[^>]*>Today<\/h1>/);
180+ assert.match(page, /Needs you/);
181+ assert.match(page, /Agent PR waiting for a person/);
182+ assert.match(page, /Fix the Contact sales link/);
183+ assert.match(page, /Checks failing/);
184+
185+ // A person approves: it leaves "needs you" and shows as approved, then merges into "since yesterday".
186+ await ok("POST", `/repos/${owner}/today-demo/pulls/${fix.number}/reviews`, { event: "APPROVE", body: "LGTM" });
187+ t = await ok("GET", "/diffinery/today");
188+ assert.ok(!t.needs_you.some((x) => x.number === fix.number));
189+ assert.equal(t.agents_at_work.find((x) => x.number === fix.number).state, "approved");
190+ assert.deepEqual(t.agents_at_work.find((x) => x.number === fix.number).approved_by, [owner]);
191+ await ok("PUT", `/repos/${owner}/today-demo/pulls/${fix.number}/merge`, { merge_method: "squash" });
192+ t = await ok("GET", "/diffinery/today");
193+ assert.ok(!t.agents_at_work.some((x) => x.number === fix.number));
194+ const landed = t.landed.find((x) => x.number === fix.number);
195+ assert.ok(landed, JSON.stringify(t.landed));
196+ assert.equal(landed.merged_by, owner);
197+ assert.equal(landed.user.login, "today-bot");
198+
199+ // Once its checks pass, the agent's other pull request waits for a person too.
200+ await ok("POST", `/repos/${owner}/today-demo/statuses/${add.head.sha}`, { state: "success", context: "ci/test" }, bot);
201+ t = await ok("GET", "/diffinery/today");
202+ assert.equal(t.needs_you.find((x) => x.number === add.number)?.reason, "agent_approval");
203+
204+ // A token limited to other repositories sees none of it.
205+ await ok("POST", "/user/repos", { name: "elsewhere" });
206+ const other = await ok("GET", `/repos/${owner}/elsewhere`);
207+ const limited = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "limited", scopes: ["read"], repositories: [other.full_name] }) });
208+ assert.equal(limited.status, 201, await limited.clone().text());
209+ const view = await ok("GET", "/diffinery/today", undefined, (await limited.json()).token);
210+ assert.deepEqual([view.needs_you, view.landed], [[], []]);
211+});
addedapps/diffinery/test/unit/today.test.ts+119−0View file
@@ -1,0 +1,119 @@
1+import { describe, expect, it } from "vitest";
2+import { agentPullState, approvalCandidates, classify, pickRepos, type ClassifyInput, type TodayRepo } from "../../src/agentic/today.js";
3+import { checksState, type RepoToday, type TodayPull } from "../../src/agentic/today-repo.js";
4+import type { Repo } from "../../src/lib/directory.js";
5+
6+const ME = { id: "u-me", login: "Ayse" };
7+const BOT = { id: "a-bot", login: "ayse-bot", kind: "agent" };
8+const OTHER_BOT = { id: "a-other", login: "other-bot", kind: "agent" };
9+const BOB = { id: "u-bob", login: "bob", kind: "user" };
10+
11+const pull = (p: Partial<TodayPull> & { number: number }): TodayPull => ({
12+ title: `PR ${p.number}`,
13+ author: BOB,
14+ head: `b${p.number}`,
15+ base: "main",
16+ draft: false,
17+ requestedReviewers: [],
18+ intent: null,
19+ createdAt: "2026-10-13T08:00:00Z",
20+ updatedAt: `2026-10-13T08:${String(p.number).padStart(2, "0")}:00Z`,
21+ approvedBy: [],
22+ approvedHead: false,
23+ changesRequestedBy: [],
24+ reviewerIds: [],
25+ checks: "success",
26+ failingChecks: [],
27+ autoMerge: false,
28+ queued: false,
29+ ...p,
30+});
31+
32+const repo = (id: string): TodayRepo => ({ id, fullName: `acme/${id}`, url: `/acme/${id}`, defaultBranch: "main", visibility: "private" });
33+const data = (d: Partial<RepoToday>): RepoToday => ({ pulls: [], merged: [], releases: [], failedRuns: [], ...d });
34+const input = (repos: ClassifyInput["repos"], writable: string[] = repos.map((r) => r.repo.id)): ClassifyInput => ({ me: ME, agentIds: new Set([BOT.id]), writable: new Set(writable), repos });
35+
36+describe("checksState", () => {
37+ it("is none without checks, failure when any failed, pending while any runs", () => {
38+ expect(checksState([]).state).toBe("none");
39+ expect(checksState([{ name: "ci", status: "completed", conclusion: "success" }, { name: "lint", status: "in_progress", conclusion: null }]).state).toBe("pending");
40+ const f = checksState([{ name: "ci", status: "completed", conclusion: "failure" }, { name: "lint", status: "in_progress", conclusion: null }]);
41+ expect(f).toEqual({ state: "failure", failing: ["ci"] });
42+ expect(checksState([{ name: "ci", status: "completed", conclusion: "skipped" }, { name: "x", status: "completed", conclusion: "neutral" }]).state).toBe("success");
43+ });
44+});
45+
46+describe("agentPullState", () => {
47+ it("orders what a person acts on", () => {
48+ expect(agentPullState(pull({ number: 1, draft: true, checks: "failure" }))).toBe("draft");
49+ expect(agentPullState(pull({ number: 1, autoMerge: true, checks: "failure" }))).toBe("landing");
50+ expect(agentPullState(pull({ number: 1, checks: "failure", changesRequestedBy: ["bob"] }))).toBe("checks_failing");
51+ expect(agentPullState(pull({ number: 1, changesRequestedBy: ["bob"], approvedHead: true }))).toBe("changes_requested");
52+ expect(agentPullState(pull({ number: 1, approvedHead: true }))).toBe("approved");
53+ expect(agentPullState(pull({ number: 1, checks: "pending" }))).toBe("checks_running");
54+ expect(agentPullState(pull({ number: 1 }))).toBe("waiting_for_approval");
55+ });
56+});
57+
58+describe("classify", () => {
59+ it("asks the viewer for requested reviews, case-insensitively, but not on drafts", () => {
60+ const t = classify(input([{ repo: repo("web"), data: data({ pulls: [pull({ number: 1, requestedReviewers: ["ayse"] }), pull({ number: 2, requestedReviewers: ["AYSE"], draft: true })] }) }]));
61+ expect(t.needsYou.map((x) => [x.kind, x.pull.number])).toEqual([["review_requested", 1]]);
62+ });
63+
64+ it("lists agent pull requests waiting for a person only where the viewer can approve", () => {
65+ const pulls = [pull({ number: 1, author: OTHER_BOT }), pull({ number: 2, author: OTHER_BOT, checks: "pending" }), pull({ number: 3, author: OTHER_BOT, approvedHead: true }), pull({ number: 4, author: OTHER_BOT, checks: "failure" }), pull({ number: 5, author: OTHER_BOT, changesRequestedBy: ["bob"] })];
66+ const t = classify(input([{ repo: repo("web"), data: data({ pulls }) }, { repo: repo("api"), data: data({ pulls: [pull({ number: 9, author: OTHER_BOT })] }) }], ["web"]));
67+ expect(t.needsYou.map((x) => `${x.repo.id}#${x.pull.number}:${x.kind}`).sort()).toEqual(["web#1:agent_approval", "web#2:agent_approval"]);
68+ });
69+
70+ it("only role-checks repositories with agent work waiting for a person", () => {
71+ const repos = [
72+ { repo: repo("a"), data: data({ pulls: [pull({ number: 1, author: OTHER_BOT })] }) },
73+ { repo: repo("b"), data: data({ pulls: [pull({ number: 1, author: OTHER_BOT, approvedHead: true }), pull({ number: 2 })] }) },
74+ { repo: repo("c"), data: data({ pulls: [pull({ number: 1, author: { ...ME, kind: "agent" } })] }) },
75+ ];
76+ expect(approvalCandidates(repos, ME)).toEqual(["a"]);
77+ });
78+
79+ it("flags the viewer's own blocked pull requests", () => {
80+ const mine = { ...ME, kind: "user" };
81+ const t = classify(input([{ repo: repo("web"), data: data({ pulls: [pull({ number: 1, author: mine, changesRequestedBy: ["bob"] }), pull({ number: 2, author: mine, checks: "failure" }), pull({ number: 3, author: mine })] }) }]));
82+ expect(t.needsYou.map((x) => [x.pull.number, x.kind]).sort()).toEqual([
83+ [1, "changes_requested"],
84+ [2, "checks_failing"],
85+ ]);
86+ });
87+
88+ it("shows the viewer's agents' pull requests with their state, newest first, and lists them once in needs-you", () => {
89+ const t = classify(input([{ repo: repo("web"), data: data({ pulls: [pull({ number: 1, author: BOT }), pull({ number: 2, author: BOT, checks: "failure" }), pull({ number: 3, author: OTHER_BOT })] }) }]));
90+ expect(t.agentsAtWork.map((x) => [x.pull.number, x.state])).toEqual([
91+ [2, "checks_failing"],
92+ [1, "waiting_for_approval"],
93+ ]);
94+ expect(t.needsYou.map((x) => x.pull.number).sort()).toEqual([1, 3]);
95+ });
96+
97+ it("keeps failures that are the viewer's, their agents' or on the default branch", () => {
98+ const run = (id: string, actor: { id: string; login: string; kind: string }, ref: string, event = "push", at = "2026-10-13T07:00:00Z") => ({ id, runNumber: 1, workflow: "CI", title: id, ref, event, actor, conclusion: "failure", prNumber: null, at });
99+ const t = classify(
100+ input([
101+ {
102+ repo: repo("web"),
103+ data: data({ failedRuns: [run("mine", { ...ME, kind: "user" }, "refs/heads/x"), run("bot", BOT, "refs/heads/y"), run("main", BOB, "refs/heads/main"), run("pr", BOB, "refs/heads/main", "pull_request"), run("other", BOB, "refs/heads/z")] }),
104+ },
105+ ]),
106+ );
107+ expect(t.failures.map((x) => x.run.id).sort()).toEqual(["bot", "main", "mine"]);
108+ });
109+});
110+
111+describe("pickRepos", () => {
112+ const r = (id: string, pushed: string | null, archived = 0): Repo => ({ id, owner_id: "o", owner_login: "acme", owner_kind: "org", name: id, description: "", visibility: "private", default_branch: "main", archived, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z", pushed_at: pushed });
113+ it("visits active, unarchived repositories, most recent activity first", () => {
114+ const now = new Date("2026-10-13T09:00:00Z");
115+ const picked = pickRepos([r("old", "2026-01-01T00:00:00Z"), r("pushed", "2026-10-12T00:00:00Z"), r("api", "2026-01-01T00:00:00Z"), r("arch", "2026-10-13T00:00:00Z", 1)], new Map([["api", "2026-10-13T08:00:00Z"]]), now);
116+ expect(picked.repos.map((x) => x.id)).toEqual(["api", "pushed"]);
117+ expect(picked.skipped).toBe(0);
118+ });
119+});