Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

140 linesCodeBlameRaw
1import { data } from "react-router";
2
3import { type Capability, type Repo, type Result, type Viewer, abilities, can, granted, httpStatus, needs, permission } from "@g1t/contracts";
4
5import type { ViewerAccess } from "./access";
6import { projects, repos, work } from "./services.server";
7import { getViewer } from "./session.server";
8
9type Context = Parameters<typeof getViewer>[0];
10type RepoParams = { owner?: string; repo?: string };
11
12/**
13 * One count of a project's open work items and pull requests per request: the
14 * sidebar (root) and the project's tabs (its layout) both show them.
15 */
16const tallies = new WeakMap<object, Map<string, ReturnType<typeof work.counts>>>();
17
18export function countsFor(context: Context, params: RepoParams): ReturnType<typeof work.counts> {
19 const key = `${params.owner}/${params.repo}`.toLowerCase();
20 let seen = tallies.get(context);
21 if (!seen) tallies.set(context, (seen = new Map()));
22 let found = seen.get(key);
23 if (!found) {
24 found = work.counts({ namespace: params.owner ?? "", name: params.repo ?? "" }, getViewer(context));
25 seen.set(key, found);
26 }
27 return found;
28}
29
30/**
31 * The repositories of these ids the viewer can read: one call for all of
32 * them, then one each for any it leaves out (forks).
33 */
34export async function readableRepos(ids: string[], viewer: Viewer): Promise<Repo[]> {
35 if (ids.length === 0) return [];
36 const found = await repos.readable(ids, viewer).catch(() => [] as Repo[]);
37 const seen = new Set(found.map((repo) => repo.id));
38 const rest = await Promise.all(ids.filter((id) => !seen.has(id)).map((id) => repos.getById(id, viewer).catch(() => null)));
39 return [...found, ...rest.flatMap((repo) => (repo?.ok ? [repo.value] : []))];
40}
41
42/**
43 * One lookup of a repository per request: the repository's layout and the
44 * page under it load at the same time and both need it.
45 */
46const lookups = new WeakMap<object, Map<string, Promise<Result<Repo>>>>();
47
48export function repoFor(context: Context, params: RepoParams): Promise<Result<Repo>> {
49 const key = `${params.owner}/${params.repo}`.toLowerCase();
50 let seen = lookups.get(context);
51 if (!seen) lookups.set(context, (seen = new Map()));
52 let found = seen.get(key);
53 if (!found) {
54 found = repos.get({ namespace: params.owner ?? "", name: params.repo ?? "" }, getViewer(context));
55 seen.set(key, found);
56 }
57 return found;
58}
59
60/**
61 * One lookup of a project per request: the project's layout and its
62 * overview load at the same time and both need it.
63 */
64const projectLookups = new WeakMap<object, Map<string, ReturnType<typeof projects.get>>>();
65
66export function projectFor(context: Context, params: RepoParams): ReturnType<typeof projects.get> {
67 const key = `${params.owner}/${params.repo}`.toLowerCase();
68 let seen = projectLookups.get(context);
69 if (!seen) projectLookups.set(context, (seen = new Map()));
70 let found = seen.get(key);
71 if (!found) {
72 found = projects.get(params.owner ?? "", params.repo ?? "", getViewer(context));
73 seen.set(key, found);
74 }
75 return found;
76}
77
78/** The viewer's role on a repository and what it lets them do. */
79export function accessFor(viewer: Viewer, repo: Repo): ViewerAccess {
80 return {
81 role: permission(viewer, repo),
82 // A role of their own, not only because the repository is public.
83 insider: viewer ? granted(viewer, repo) != null : false,
84 can: abilities(viewer, repo),
85 };
86}
87
88/**
89 * The repository and the viewer's access to it, refusing with a 404 when
90 * they cannot read it and a 403 that says which role is needed when they
91 * can read it but not do `capability`.
92 */
93export async function requireRepo(context: Context, params: RepoParams, capability: Capability = "read") {
94 const viewer = getViewer(context);
95 const found = await repoFor(context, params);
96 if (!found.ok) {
97 if (found.error.code === "not_found" || found.error.code === "forbidden") throw data(null, { status: 404 });
98 throw data(found.error.message, { status: httpStatus(found.error) });
99 }
100 const access = accessFor(viewer, found.value);
101 if (!access.can.read) throw data(null, { status: 404 });
102 if (!access.can[capability]) throw data(needs(capability), { status: 403 });
103 return { viewer, repo: found.value, access };
104}
105
106/**
107 * Pages only people with a role of their own see (plans, deployments,
108 * security, settings): a 404 for anyone else, as before.
109 */
110export async function requireInsider(context: Context, params: RepoParams, capability: Capability = "read") {
111 const found = await requireRepo(context, params, "read");
112 if (!found.access.insider) throw data(null, { status: 404 });
113 if (!found.access.can[capability]) throw data(needs(capability), { status: 403 });
114 return found;
115}
116
117/** The viewer's access, or none when the repository cannot be read. */
118export async function accessTo(context: Context, params: RepoParams): Promise<ViewerAccess> {
119 const found = await repoFor(context, params);
120 return found.ok
121 ? accessFor(getViewer(context), found.value)
122 : { role: null, insider: false, can: abilities(null, { id: "", namespace: "", isPrivate: true }) };
123}
124
125/**
126 * For an action: why the viewer may not do `capability` here, or null when
127 * they may. Not cached, since the action may change the repository.
128 */
129export async function refusal(context: Context, params: RepoParams, capability: Capability): Promise<string | null> {
130 const viewer = getViewer(context);
131 const found = await repos.get({ namespace: params.owner ?? "", name: params.repo ?? "" }, viewer);
132 if (!found.ok) return found.error.message;
133 return can(viewer, found.value, capability) ? null : needs(capability);
134}
135
136/** For an action whose page only people with `capability` see: a 403 that says why, otherwise nothing. */
137export async function requireCapability(context: Context, params: RepoParams, capability: Capability): Promise<void> {
138 const refused = await refusal(context, params, capability);
139 if (refused) throw data(refused, { status: 403 });
140}