Skip to content
243 linesCodeBlameRaw
1/**
2 * A project's place in the catalog, put together from what its files say
3 * (see `./extract`) and what g1t knows about it already: its dependencies,
4 * owners, deployments and integrations. Pure, so a rebuild from the same
5 * inputs gives the same entities and relations, and writing them is
6 * idempotent.
7 */
8
9import type { EntityKind, RelationKind } from "@g1t/contracts";
10
11import type { FileFacts, Hint } from "./extract";
12import { harvestable } from "./harvest.ts";
13
14export type ProjectInput = {
15 id: string;
16 workspace: string;
17 slug: string;
18 name: string;
19 description: string | null;
20 private: boolean;
21 repoId: string;
22 repo: { namespace: string; name: string };
23 rootDir: string;
24 defaultBranch: string;
25};
26
27export type FileRecord = { path: string; facts: FileFacts };
28
29export type Surroundings = {
30 /** Members who own it: named in its files, or who wrote most of it. */
31 owners: string[];
32 dependsOn: { slug: string; as: string | null }[];
33 deploy: {
34 enabled: boolean;
35 production: { url: string; commit: string; deployedAt: string } | null;
36 previews: number;
37 latest: { status: string; kind: string; error: string | null; createdAt: string } | null;
38 } | null;
39 integrations: { id: string; provider: string; name: string; kind: string; repo: string | null }[];
40};
41
42export type EntityDraft = {
43 kind: EntityKind;
44 key: string;
45 name: string;
46 summary: string | null;
47 data: Record<string, unknown>;
48 ref: string | null;
49};
50
51export type Ref = { kind: EntityKind; key: string };
52export type RelationDraft = { from: Ref; kind: RelationKind; to: Ref };
53
54export type Assembled = {
55 entities: EntityDraft[];
56 relations: RelationDraft[];
57 /** Stack facts and doc hints, with the file each came from. */
58 hints: (Hint & { path: string })[];
59 /** Whether a workflow runs tests. */
60 tests: boolean;
61};
62
63function unique<T>(values: T[]): T[] {
64 return [...new Set(values)];
65}
66
67/** The catalog entries for one project. */
68export function assemble(project: ProjectInput, files: FileRecord[], around: Surroundings): Assembled {
69 const me: Ref = { kind: "project", key: project.slug };
70 const entities: EntityDraft[] = [];
71 const relations: RelationDraft[] = [];
72 const relate = (from: Ref, kind: RelationKind, to: Ref) => relations.push({ from, kind, to });
73
74 const languages = unique(files.flatMap((file) => file.facts.languages));
75 const packages = files.flatMap((file) => file.facts.packages.map((pkg) => ({ ...pkg, path: file.path })));
76 const apis = files.flatMap((file) => file.facts.apis.map((api) => ({ ...api, path: file.path })));
77 const docs = files.flatMap((file) => (file.facts.doc ? [file.facts.doc] : []));
78 const tests = files.some((file) => file.facts.tests);
79 const declaredOwners = unique(files.flatMap((file) => file.facts.owners));
80 const owners = unique([...declaredOwners, ...around.owners]);
81
82 const app: Ref = { kind: "app", key: project.slug };
83 const hasApp = !!around.deploy?.enabled;
84 entities.push({
85 kind: "project",
86 key: project.slug,
87 name: project.name,
88 summary:
89 [
90 project.description,
91 languages.length ? `Written in ${languages.join(", ")}.` : null,
92 packages.length ? `Packages: ${packages.map((pkg) => pkg.name).join(", ")}.` : null,
93 around.dependsOn.length ? `Uses ${around.dependsOn.map((dep) => dep.slug).join(", ")}.` : null,
94 owners.length ? `Owned by ${owners.join(", ")}.` : null,
95 ]
96 .filter(Boolean)
97 .join(" ") || null,
98 data: {
99 repo: `${project.repo.namespace}/${project.repo.name}`,
100 rootDir: project.rootDir,
101 defaultBranch: project.defaultBranch,
102 languages,
103 owners,
104 tests,
105 testCommands: packages.flatMap((pkg) => (pkg.scripts?.test ? [`${pkg.ecosystem === "npm" ? "npm test" : pkg.scripts.test}`] : [])),
106 productionUrl: around.deploy?.production?.url ?? null,
107 },
108 ref: `/${project.repo.namespace}/${project.repo.name}${project.rootDir ? `/tree/${project.defaultBranch}/${project.rootDir}` : ""}`,
109 });
110
111 for (const dep of around.dependsOn) {
112 relate(me, "depends_on", { kind: "project", key: dep.slug });
113 }
114 for (const owner of owners) {
115 entities.push({ kind: "owner", key: owner.toLowerCase(), name: owner, summary: null, data: {}, ref: `/u/${owner}` });
116 relate(me, "owned_by", { kind: "owner", key: owner.toLowerCase() });
117 }
118 for (const language of languages) {
119 entities.push({ kind: "language", key: language.toLowerCase(), name: language, summary: null, data: {}, ref: null });
120 relate(me, "uses", { kind: "language", key: language.toLowerCase() });
121 }
122 for (const pkg of packages) {
123 const key = `${pkg.ecosystem}:${pkg.name}`;
124 entities.push({
125 kind: "package",
126 key,
127 name: pkg.name,
128 summary: `${pkg.ecosystem} package${pkg.version ? ` ${pkg.version}` : ""} in ${project.name}; depends on ${pkg.dependencies.length} packages.`,
129 data: { ecosystem: pkg.ecosystem, version: pkg.version, dependencies: pkg.dependencies, scripts: pkg.scripts ?? {}, members: pkg.members ?? [], path: pkg.path },
130 ref: `/${project.repo.namespace}/${project.repo.name}/blob/${project.defaultBranch}/${[project.rootDir, pkg.path].filter(Boolean).join("/")}`,
131 });
132 relate(me, "exposes", { kind: "package", key });
133 // Within the workspace, a package using another is a dependency the
134 // catalog can draw; ones from outside have no entity and are dropped
135 // when read.
136 for (const dependency of pkg.dependencies) {
137 relate({ kind: "package", key }, "depends_on", { kind: "package", key: `${pkg.ecosystem}:${dependency}` });
138 }
139 }
140 for (const api of apis) {
141 const key = `${project.slug}:${api.kind}:${api.name}`;
142 entities.push({
143 kind: "api",
144 key,
145 name: api.name,
146 summary: api.summary,
147 data: { kind: api.kind, routes: api.routes, path: api.path },
148 ref: `/${project.repo.namespace}/${project.repo.name}/blob/${project.defaultBranch}/${[project.rootDir, api.path].filter(Boolean).join("/")}`,
149 });
150 relate(me, "exposes", { kind: "api", key });
151 if (hasApp && api.kind === "worker") relate(app, "exposes", { kind: "api", key });
152 }
153 for (const doc of docs) {
154 const key = `${project.slug}:${doc.path}`;
155 entities.push({
156 kind: "doc",
157 key,
158 name: doc.title,
159 summary: doc.summary,
160 data: { path: doc.path, role: doc.role, chunks: doc.chunks.length },
161 ref: `/${project.repo.namespace}/${project.repo.name}/blob/${project.defaultBranch}/${[project.rootDir, doc.path].filter(Boolean).join("/")}`,
162 });
163 relate(me, "documented_by", { kind: "doc", key });
164 }
165 if (hasApp && around.deploy) {
166 const { production, previews, latest } = around.deploy;
167 entities.push({
168 kind: "app",
169 key: project.slug,
170 name: project.name,
171 summary: production ? `Live at ${production.url}.` : "Deploys from its default branch; not live yet.",
172 data: { productionUrl: production?.url ?? null, previews, latest },
173 ref: `/${project.repo.namespace}/${project.repo.name}/deployments`,
174 });
175 relate(me, "exposes", app);
176 entities.push({
177 kind: "environment",
178 key: `${project.slug}/production`,
179 name: `${project.name} production`,
180 summary: production ? `${production.url}, from ${production.commit.slice(0, 7)}` : "Not deployed yet",
181 data: { url: production?.url ?? null, commit: production?.commit ?? null, deployedAt: production?.deployedAt ?? null, status: latest?.kind === "production" ? latest.status : production ? "ready" : null },
182 ref: production?.url ?? null,
183 });
184 relate(app, "deploys_to", { kind: "environment", key: `${project.slug}/production` });
185 if (previews > 0) {
186 entities.push({
187 kind: "environment",
188 key: `${project.slug}/preview`,
189 name: `${project.name} previews`,
190 summary: `${previews} preview${previews === 1 ? "" : "s"} up, one per open pull request`,
191 data: { previews },
192 ref: `/${project.repo.namespace}/${project.repo.name}/deployments`,
193 });
194 relate(app, "deploys_to", { kind: "environment", key: `${project.slug}/preview` });
195 }
196 }
197 const repoPath = `${project.repo.namespace}/${project.repo.name}`.toLowerCase();
198 for (const integration of around.integrations) {
199 if (integration.repo?.toLowerCase() === repoPath) relate(me, "uses", { kind: "integration", key: integration.id });
200 }
201
202 // A doc is a source of memory only when it says how to work here (see
203 // ./harvest), or project.yml says it is.
204 const memory = files.find((file) => file.facts.memory)?.facts.memory ?? null;
205 const hints = files
206 .filter((file) => !file.facts.doc || harvestable(file.path, file.facts.doc.role, memory))
207 .flatMap((file) => file.facts.hints.map((hint) => ({ ...hint, path: file.path })));
208 return { entities, relations, hints, tests };
209}
210
211/** A workspace's integrations, as catalog entries of their own. */
212export function integrationEntities(integrations: Surroundings["integrations"]): EntityDraft[] {
213 return integrations.map((integration) => ({
214 kind: "integration",
215 key: integration.id,
216 name: integration.name,
217 summary: `${integration.provider} (${integration.kind})${integration.repo ? `, opens issues on ${integration.repo}` : ""}`,
218 data: { provider: integration.provider, kind: integration.kind, repo: integration.repo },
219 ref: null,
220 }));
221}
222
223/**
224 * The members who wrote most of a project, from its recent commits: those
225 * with at least a fifth of them, at most three. A commit's author is a
226 * member when its name or its email's local part is their username.
227 */
228export function authorsOf(commits: { author: { name: string; email: string } }[], members: string[]): string[] {
229 const byName = new Map(members.map((member) => [member.toLowerCase(), member]));
230 const counts = new Map<string, number>();
231 for (const commit of commits) {
232 const name = commit.author.name.toLowerCase();
233 const local = commit.author.email.split("@")[0]?.toLowerCase() ?? "";
234 const member = byName.get(name) ?? byName.get(local);
235 if (member) counts.set(member, (counts.get(member) ?? 0) + 1);
236 }
237 const total = commits.length || 1;
238 return [...counts.entries()]
239 .filter(([, count]) => count / total >= 0.2)
240 .sort((a, b) => b[1] - a[1])
241 .slice(0, 3)
242 .map(([member]) => member);
243}