Skip to content

g1t/apps/web/app/lib/checklist.ts

282 lines10,403 bytesCodeBlame
1/**
2 * The steps that take a project to production, or for a library to its
3 * first release, each worked out from what the project has done, with
4 * where to do the ones it has not. Shown on the project's overview to its
5 * members until they are all done or it is dismissed.
6 */
7
8export type ChecklistFacts = {
9 /** The project's address, `/<workspace>/<project>`. */
10 base: string;
11 /** Its default branch has a commit, or its source is a mirror. */
12 hasCode: boolean;
13 /** Deployments are switched on for it. */
14 deploysEnabled: boolean;
15 /** A production build has gone live, now or before. */
16 productionDeployed: boolean;
17 /** Custom domains it has; null when they could not be read. */
18 domains: number | null;
19 /** A preview has been built for a branch or pull request. */
20 previewOpened: boolean;
21 /** An AGENTS.md or CLAUDE.md at the root of its default branch; null when unknown. */
22 instructions: boolean | null;
23 /** g1t has been given an issue here. */
24 agentAssigned: boolean;
25};
26
27export type ChecklistItem = {
28 key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "production" | "where" | "links" | "instructions" | "agent";
29 title: string;
30 detail: string;
31 done: boolean;
32 to: string;
33 action: string;
34};
35
36export function productionChecklist(facts: ChecklistFacts): ChecklistItem[] {
37 const { base } = facts;
38 return [
39 {
40 key: "code",
41 title: "Connect a source or push code",
42 detail: "Push an existing project, or have your coding agent start one.",
43 done: facts.hasCode,
44 to: `${base}/code`,
45 action: "Push code",
46 },
47 {
48 key: "deploy",
49 title: "Deploy to production",
50 detail: facts.deploysEnabled
51 ? "Production builds from the default branch on every push."
52 : "Deployments are off until you turn them on. Then production builds from the default branch on every push.",
53 done: facts.productionDeployed,
54 to: facts.deploysEnabled ? `${base}/deployments` : `${base}/settings/deployments`,
55 action: facts.deploysEnabled ? "Deployments" : "Turn on",
56 },
57 {
58 key: "domain",
59 title: "Add a custom domain",
60 detail: "Serve production from a domain of your own.",
61 done: (facts.domains ?? 0) > 0,
62 to: `${base}/settings/domains`,
63 action: "Add",
64 },
65 {
66 key: "preview",
67 title: "Open a preview",
68 detail: "Every pull request gets a live preview of its branch.",
69 done: facts.previewOpened,
70 to: `${base}/pulls/new`,
71 action: "New pull request",
72 },
73 {
74 key: "instructions",
75 title: "Set up repository instructions",
76 detail: "Commit an AGENTS.md with how to build and test; every agent run reads it.",
77 done: facts.instructions === true,
78 to: `${base}/agents#instructions`,
79 action: "How",
80 },
81 {
82 key: "agent",
83 title: "Assign a first issue to g1t",
84 detail: "It opens a pull request, makes the change and sees it through checks and review.",
85 done: facts.agentAssigned,
86 to: `${base}/issues/new`,
87 action: "New issue",
88 },
89 ];
90}
91
92export type ReleaseFacts = Pick<ChecklistFacts, "base" | "hasCode" | "instructions" | "agentAssigned"> & {
93 /** It has a workflow, whose runs are its pull requests' checks; null when unknown. */
94 hasWorkflow: boolean | null;
95 /** A package its repository publishes has a version. */
96 released: boolean;
97 /** Where publishing a first version is explained: its package's page or its registry's guide. */
98 releaseTo: string;
99};
100
101/**
102 * The steps for a library or a tool, which ships as releases rather than
103 * deploying: the same first and last steps as production, with checks and
104 * a first version in between.
105 */
106export function releaseChecklist(facts: ReleaseFacts): ChecklistItem[] {
107 const shared = productionChecklist({ ...facts, deploysEnabled: false, productionDeployed: false, domains: null, previewOpened: false });
108 const step = (key: ChecklistItem["key"]) => shared.find((item) => item.key === key)!;
109 return [
110 step("code"),
111 {
112 key: "checks",
113 title: "Add checks on pull requests",
114 detail: "A workflow that builds and tests it. Its runs are every pull request's checks.",
115 done: facts.hasWorkflow === true,
116 to: `${facts.base}/actions`,
117 action: "Add CI",
118 },
119 {
120 key: "release",
121 title: "Tag a release or publish a package",
122 detail: "Publish a first version to the workspace's registry for others to install.",
123 done: facts.released,
124 to: facts.releaseTo,
125 action: "How",
126 },
127 step("instructions"),
128 step("agent"),
129 ];
130}
131
132export type StartFacts = Pick<ChecklistFacts, "base" | "hasCode" | "instructions" | "agentAssigned"> & {
133 /** It has a workflow, whose runs are its pull requests' checks; null when unknown. */
134 hasWorkflow: boolean | null;
135 /** Production's address, for an app deployed elsewhere. */
136 productionUrl: string | null;
137 /** Its homepage, docs or any other link is set. */
138 hasLinks: boolean;
139 /** Its docs address is set, or its homepage. */
140 hasDocsLink: boolean;
141};
142
143/** Where a project's own settings are, for the steps that are done there. */
144const settingsAt = (base: string, anchor: string) => `${base}/settings#${anchor}`;
145
146/**
147 * The steps for a project g1t does not deploy, by what it is. Every step
148 * applies to it and each is done from its own fact: nothing about turning
149 * on Deployments, domains or previews.
150 *
151 * - An app deployed elsewhere: its production address, then checks.
152 * - An app nobody has said where it runs: saying so, then checks.
153 * - Docs: where they are read.
154 * - Anything else: its links.
155 */
156export function startChecklist(kind: "elsewhere" | "unknown" | "docs" | "other", facts: StartFacts): ChecklistItem[] {
157 const shared = productionChecklist({ ...facts, deploysEnabled: false, productionDeployed: false, domains: null, previewOpened: false });
158 const step = (key: ChecklistItem["key"]) => shared.find((item) => item.key === key)!;
159 const checks: ChecklistItem = {
160 key: "checks",
161 title: "Add checks on pull requests",
162 detail: "A workflow that builds and tests it. Its runs are every pull request's checks.",
163 done: facts.hasWorkflow === true,
164 to: `${facts.base}/actions`,
165 action: "Add CI",
166 };
167 const middle: ChecklistItem[] =
168 kind === "elsewhere"
169 ? [
170 {
171 key: "production",
172 title: "Add production's address",
173 detail: "Where your own pipeline deploys it, so its overview links to production.",
174 done: facts.productionUrl != null,
175 to: settingsAt(facts.base, "kind"),
176 action: "Add",
177 },
178 checks,
179 ]
180 : kind === "unknown"
181 ? [
182 {
183 key: "where",
184 title: "Say where it runs",
185 detail: "Deployed on g1t, deployed elsewhere, or not deployed at all, such as a library.",
186 done: false,
187 to: settingsAt(facts.base, "kind"),
188 action: "Choose",
189 },
190 checks,
191 ]
192 : kind === "docs"
193 ? [
194 {
195 key: "links",
196 title: "Add where its docs are read",
197 detail: "A docs or homepage address, shown on its overview and wherever the project is listed.",
198 done: facts.hasDocsLink,
199 to: settingsAt(facts.base, "links"),
200 action: "Add",
201 },
202 ]
203 : [
204 {
205 key: "links",
206 title: "Add its links",
207 detail: "A homepage, docs, or any other address people go to for it.",
208 done: facts.hasLinks,
209 to: settingsAt(facts.base, "links"),
210 action: "Add",
211 },
212 ];
213 return [step("code"), ...middle, step("instructions"), step("agent")];
214}
215
216export type ChecklistPlan = "production" | "release" | "elsewhere" | "unknown" | "docs" | "other";
217
218/**
219 * Which steps a project gets, and their heading, from what it is and where
220 * it runs. Only what g1t deploys gets production's steps.
221 */
222export function checklistPlan(project: {
223 kind: "app" | "library" | "tool" | "docs" | "other";
224 runs: "g1t" | "elsewhere" | null;
225}): { plan: ChecklistPlan; title: string } {
226 if (project.runs === "g1t") return { plan: "production", title: "Get to production" };
227 if (project.kind === "library" || project.kind === "tool") return { plan: "release", title: "Ship a release" };
228 if (project.kind === "app") return { plan: project.runs === "elsewhere" ? "elsewhere" : "unknown", title: "Get started" };
229 return { plan: project.kind, title: "Get started" };
230}
231
232/** `3/6`, for the card's heading. */
233export function progress(items: ChecklistItem[]): { done: number; total: number; complete: boolean } {
234 const done = items.filter((item) => item.done).length;
235 return { done, total: items.length, complete: done === items.length };
236}
237
238/** A repository's root lists instructions for agents. */
239export function hasInstructions(names: string[]): boolean {
240 return names.some((name) => /^(agents|claude)\.md$/i.test(name));
241}
242
243/** Whether g1t has worked here, from its runs, pull requests or issues. */
244export function agentWasAssigned(input: {
245 runAgents: string[];
246 pullAgents: string[];
247 issues: { assignees: string[]; agent: string | null }[];
248}): boolean {
249 const isAgent = (name: string | null) => name?.toLowerCase() === "g1t";
250 return (
251 input.runAgents.some(isAgent) ||
252 input.pullAgents.some(isAgent) ||
253 input.issues.some((issue) => isAgent(issue.agent) || issue.assignees.some(isAgent))
254 );
255}
256
257// --- Dismissing it, per project, in this browser -----------------------------
258
259type Store = Pick<Storage, "getItem" | "setItem">;
260
261export function dismissKey(base: string): string {
262 return `g1t:checklist-dismissed:${base.toLowerCase()}`;
263}
264
265export function isDismissed(storage: () => Store | null | undefined, base: string): boolean {
266 try {
267 return storage()?.getItem(dismissKey(base)) === "1";
268 } catch {
269 return false;
270 }
271}
272
273export function dismiss(storage: () => Store | null | undefined, base: string): boolean {
274 try {
275 const store = storage();
276 if (!store) return false;
277 store.setItem(dismissKey(base), "1");
278 return true;
279 } catch {
280 return false;
281 }
282}