flagon-io/g1t

public

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

g1t/services/context/src/assemble.ts

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