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.

140 lines · 7.1 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import { callerFor, type AppContext, type AppVars } from "../auth/principal.js";
4import type { Repo } from "../lib/directory.js";
5import { matchesText, parseIssueQuery, parseRepoQuery } from "../lib/search-query.js";
6import { HttpError } from "../lib/util.js";
7import type { Issue } from "../repo/collab.js";
8import { hiddenAuthors, repoStub } from "./common.js";
9import { baseOf, ghIssue, ghRepo, pageOf, setLinks, topicsOf } from "./gh.js";
10import { issueCtx } from "./ghctx.js";
11
12/** GET /search/issues and /search/repositories over the repositories the caller can read. */
13export const search = new Hono<{ Bindings: Env; Variables: AppVars }>();
14
15/** Searches fan out to at most this many repositories; more sets `incomplete_results`. */
16const MAX_REPOS = 50;
17const MAX_RESULTS = 1000;
18/** GitHub's limit on the length of a search query. */
19const MAX_QUERY = 256;
20/** Repository search checks access on at most this many candidates. */
21const MAX_REPO_CANDIDATES = 300;
22
23/**
24 * Repositories the caller might read: only active accounts (and agents whose
25 * owner is active) get anything beyond public repositories, and every
26 * candidate is checked again with callerFor, which applies roles, token
27 * scopes and repository restrictions.
28 */
29async function candidates(c: AppContext, q?: { text: string[]; owners: string[] }): Promise<Repo[]> {
30 const p = c.get("principal");
31 const dir = c.get("dir");
32 // Public repositories are looked up by the query's words and owners in D1, not taken from a fixed
33 // number of the most recent ones, so any of them can be found however many there are.
34 const publicOnes = () => dir.publicRepos(500, { words: q?.text.filter((w) => !w.startsWith("-")), owners: q?.owners });
35 if (p.type === "account") {
36 if (p.account.status !== "active") return [];
37 const [mine, open] = await Promise.all([dir.reposVisibleTo(p.account), publicOnes()]);
38 const seen = new Set(mine.map((r) => r.id));
39 return [...mine, ...open.filter((r) => !seen.has(r.id))];
40 }
41 if (p.type === "repo-token") {
42 const r = await dir.repoById(p.repoId);
43 return r ? [r] : [];
44 }
45 return publicOnes();
46}
47
48/** The first `limit` repositories (in order) the caller can actually read, and whether more were left unchecked. */
49async function authorized(c: AppContext, repos: Repo[], limit: number): Promise<{ repos: Repo[]; incomplete: boolean }> {
50 const out: Repo[] = [];
51 for (let i = 0; i < repos.length; i++) {
52 if (out.length >= limit) return { repos: out, incomplete: true };
53 if (await callerFor(c, repos[i]!)) out.push(repos[i]!);
54 }
55 return { repos: out, incomplete: false };
56}
57
58const fullName = (r: Repo) => `${r.owner_login}/${r.name}`.toLowerCase();
59
60function queryOf(c: AppContext): string {
61 const raw = c.req.query("q") ?? "";
62 if (!raw.trim()) throw new HttpError(422, "Validation Failed", { errors: [{ resource: "Search", field: "q", code: "missing" }] });
63 if (raw.length > MAX_QUERY) throw new HttpError(422, `The search is longer than ${MAX_QUERY} characters.`, { errors: [{ resource: "Search", field: "q", code: "invalid" }] });
64 return raw;
65}
66
67search.get("/search/issues", async (c) => {
68 const q = parseIssueQuery(queryOf(c), { sort: c.req.query("sort"), order: c.req.query("order") });
69 const p = pageOf(c);
70 if (p.offset >= MAX_RESULTS) throw new HttpError(422, "Only the first 1000 search results are available");
71 const owners = [...q.owners, ...q.repos.map((r) => r.split("/")[0]!)];
72 let repos = await candidates(c, { text: [], owners });
73 if (q.repos.length) {
74 const want = new Set(q.repos.map((r) => r.toLowerCase()));
75 repos = repos.filter((r) => want.has(fullName(r)));
76 }
77 if (q.owners.length) {
78 const owners = new Set(q.owners.map((o) => o.toLowerCase()));
79 repos = repos.filter((r) => owners.has(r.owner_login.toLowerCase()));
80 }
81 const allowed = await authorized(c, repos, MAX_REPOS);
82 // Each repository returns at most `want` hits as (number, sort key); only the page is loaded afterwards.
83 const want = Math.min(p.offset + p.perPage, MAX_RESULTS);
84 const results = await Promise.all(allowed.repos.map(async (r) => ({ repo: r, ...(await repoStub(c.env, r.id).searchIssues(q, want)) })));
85 const total = results.reduce((n, r) => n + r.total, 0);
86 const sort = q.sort ?? { field: "created", dir: "desc" };
87 const page = results
88 .flatMap((r) => r.hits.map((h) => ({ repo: r.repo, number: h.number, key: h.key })))
89 .sort((a, b) => {
90 const d = a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
91 return (sort.dir === "asc" ? d : -d) || (a.repo.id < b.repo.id ? -1 : a.repo.id > b.repo.id ? 1 : b.number - a.number);
92 })
93 .slice(p.offset, p.offset + p.perPage);
94 const byRepo = new Map<string, { repo: Repo; numbers: number[] }>();
95 for (const h of page) {
96 const entry = byRepo.get(h.repo.id) ?? { repo: h.repo, numbers: [] };
97 entry.numbers.push(h.number);
98 byRepo.set(h.repo.id, entry);
99 }
100 const shaped = new Map<string, ReturnType<typeof ghIssue>>();
101 for (const { repo, numbers } of byRepo.values()) {
102 const stub = repoStub(c.env, repo.id);
103 const issues = (await stub.issuesByNumber(numbers, await hiddenAuthors(c))) as unknown as Issue[];
104 const x = await issueCtx(c, repo, stub, issues);
105 for (const i of issues) shaped.set(`${repo.id}#${i.number}`, ghIssue(x, i));
106 }
107 setLinks(c, p, Math.min(total, MAX_RESULTS));
108 const items = page.flatMap((h) => {
109 const item = shaped.get(`${h.repo.id}#${h.number}`);
110 return item ? [{ ...item, score: 1 }] : [];
111 });
112 return c.json({ total_count: total, incomplete_results: allowed.incomplete, items });
113});
114
115search.get("/search/repositories", async (c) => {
116 const q = parseRepoQuery(queryOf(c));
117 const p = pageOf(c);
118 const fields = q.in.length ? q.in : ["name", "description", "topics"];
119 const owners = new Set(q.owners.map((o) => o.toLowerCase()));
120 const names = new Set(q.repos.map((r) => r.toLowerCase()));
121 const filtered = (await candidates(c, q)).filter((r) => {
122 if (owners.size && !owners.has(r.owner_login.toLowerCase())) return false;
123 if (names.size && !names.has(fullName(r))) return false;
124 if (q.visibility && (q.visibility === "public") !== (r.visibility === "public")) return false;
125 if (q.archived !== undefined && !!r.archived !== q.archived) return false;
126 const topics = topicsOf(r);
127 if (q.topics.some((t) => !topics.includes(t))) return false;
128 const hay: string[] = [];
129 if (fields.includes("name")) hay.push(r.name, fullName(r));
130 if (fields.includes("description")) hay.push(r.description ?? "");
131 if (fields.includes("topics")) hay.push(topics.join(" "));
132 return matchesText(q.text, hay);
133 });
134 const allowed = await authorized(c, filtered, MAX_REPO_CANDIDATES);
135 const hits = allowed.repos;
136 const order = c.req.query("order") === "asc" ? 1 : -1;
137 if (c.req.query("sort") === "updated") hits.sort((a, b) => ((a.pushed_at ?? a.updated_at) < (b.pushed_at ?? b.updated_at) ? -order : order));
138 setLinks(c, p, hits.length);
139 return c.json({ total_count: hits.length, incomplete_results: allowed.incomplete, items: hits.slice(p.offset, p.offset + p.perPage).map((r) => ({ ...ghRepo(baseOf(c), r), score: 1 })) });
140});