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.

Commit

Link previews for work items

A work item's page, /<workspace>/-/work/<number>, gets a card of its own when it was filed in a public repository, and an old issue's address gets the card of the work item it became. Profiles count work items opened, and projects open work items linked to their repository.

G1T-Session:
ses_01m566357hejh9yeqjbetx23f3
syntaqxcommitted Parent3219c53Browse files
4 files+96−620/4 viewed
+3−3
112112
113113 /** Lucide's icons, as the site uses them, at 24 units. */
114114 export const ICON = {
115− issue: `<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>`,
116− issueDone: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`,
117− issueNotPlanned: `<circle cx="12" cy="12" r="10"/><line x1="9" x2="15" y1="15" y2="9"/>`,
115+ item: `<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>`,
116+ itemDone: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`,
117+ itemNotPlanned: `<circle cx="12" cy="12" r="10"/><line x1="9" x2="15" y1="15" y2="9"/>`,
118118 pull: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" x2="6" y1="9" y2="21"/>`,
119119 pullDraft: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M18 6V5"/><path d="M18 11v-1"/><line x1="6" x2="6" y1="9" y2="21"/>`,
120120 merged: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>`,
+14−14
55 * dot grid and lavender kept for accents.
66 */
77 import { COLOR, ICON, LOCKUP_BOX, backdropSvg, dataUri, iconSvg, lockupSvg } from "./brand.ts";
8−import type { Card, IssueState } from "./resolve.ts";
8+import type { Card, ItemState } from "./resolve.ts";
99 import type { PullStatus } from "@g1t/contracts";
1010
1111 export const WIDTH = 1200;
308308 closed: ["Closed", ICON.pullClosed, TONE.closed],
309309 };
310310
311−const ISSUE_BADGE: Record<IssueState, [string, string, Tone]> = {
312− open: ["Open", ICON.issue, TONE.open],
313− completed: ["Closed", ICON.issueDone, TONE.merged],
314− not_planned: ["Not planned", ICON.issueNotPlanned, TONE.quiet],
311+const ITEM_BADGE: Record<ItemState, [string, string, Tone]> = {
312+ open: ["Open", ICON.item, TONE.open],
313+ completed: ["Closed", ICON.itemDone, TONE.merged],
314+ not_planned: ["Not planned", ICON.itemNotPlanned, TONE.quiet],
315315 };
316316
317317 function count(n: number, one: string, many: string): [string, string] {
350350 case "person": {
351351 const [merged, mergedNoun] = count(card.pullsMerged, "pull request merged", "pull requests merged");
352352 const [pulls, pullNoun] = count(card.pullsOpen, "open pull request", "open pull requests");
353− const [issues, issueNoun] = count(card.issues, "issue opened", "issues opened");
353+ const [items, itemNoun] = count(card.items, "work item opened", "work items opened");
354354 return frame({
355355 address: `g1t.sh/u/${card.username}`,
356356 // The username above the name they go by; "Profile" when there is none.
360360 facts: [
361361 fact(ICON.merged, merged, mergedNoun),
362362 fact(ICON.pull, pulls, pullNoun),
363− fact(ICON.issue, issues, issueNoun),
363+ fact(ICON.item, items, itemNoun),
364364 ],
365365 });
366366 }
367367
368368 case "project": {
369− const [issues, issueNoun] = count(card.issues, "open issue", "open issues");
369+ const [items, itemNoun] = count(card.items, "open work item", "open work items");
370370 const [pulls, pullNoun] = count(card.pulls, "open pull request", "open pull requests");
371371 const named = card.name.toLowerCase() !== card.repo.toLowerCase();
372372 return frame({
374374 eyebrow: eyebrow(named ? `${card.owner}/${card.repo}` : `${card.owner} /`, true),
375375 title: named ? title(card.name, 2) : repoTitle(card.repo),
376376 description: card.description,
377− facts: [fact(ICON.issue, issues, issueNoun), fact(ICON.pull, pulls, pullNoun)],
377+ facts: [fact(ICON.item, items, itemNoun), fact(ICON.pull, pulls, pullNoun)],
378378 });
379379 }
380380
381− case "issue": {
382− const [label, shapes, tone] = ISSUE_BADGE[card.state];
381+ case "item": {
382+ const [label, shapes, tone] = ITEM_BADGE[card.state];
383383 return frame({
384− address: `g1t.sh/${card.owner}/${card.repo}/issues/${card.number}`,
385− eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number),
384+ address: `g1t.sh/${card.workspace}/-/work/${card.number}`,
385+ eyebrow: eyebrowWithNumber(card.workspace, card.number),
386386 title: title(card.title),
387387 description: null,
388− facts: [badge(label, shapes, tone), plain("Issue"), dot(), byline(card.author, card.requestedBy)],
388+ facts: [badge(label, shapes, tone), plain("Work item"), dot(), byline(card.author, card.requestedBy)],
389389 });
390390 }
391391
+35−18
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import type { Issue, Project, Pull, Repo, Result, Workspace } from "@g1t/contracts";
4+import type { Project, Pull, Repo, Result, WorkItem, Workspace } from "@g1t/contracts";
55
66 import { RENDER_VERSION, cacheKey } from "./cache.ts";
77 import { type Sources, cardPath, clean, docsCard, resolve, segments } from "./resolve.ts";
3030 "acme/vault": repo("acme", "vault", true),
3131 };
3232
33−const ISSUE = { number: 7, title: "Fix the thing", state: "closed", reason: "not_planned", author, requestedBy: null } as Issue;
33+// Work item 41 of acme, filed in acme/web as its issue #7.
34+const ITEM = { workspace: "acme", number: 41, title: "Fix the thing", state: "closed", reason: "not_planned", author, requestedBy: null } as WorkItem;
3435 // A change g1t made for ada.
3536 const PULL = { number: 8, title: "Make the thing", status: "merged", author: { id: "usr_g1t_agent", username: "g1t", kind: "agent" }, requestedBy: author } as Pull;
3637
6061 get: async (path, viewer) => seen(viewer, REPOS[`${path.namespace}/${path.name}`.toLowerCase()] ? ok(REPOS[`${path.namespace}/${path.name}`.toLowerCase()]) : notFound),
6162 },
6263 work: {
63− counts: async (path, viewer) => seen(viewer, hidden(path) ? notFound : ok({ issues: 3, pulls: 2 })),
64− getIssue: async (path, number, viewer) =>
65− seen(viewer, !hidden(path) && number === 7 ? ok({ issue: ISSUE, pulls: [], comments: [] }) : notFound),
64+ counts: async (path, viewer) => seen(viewer, hidden(path) ? notFound : ok({ items: 3, pulls: 2 })),
65+ // Item 42 was filed in the private acme/vault: no one outside sees it.
66+ getWorkItem: async (workspace, number, viewer) =>
67+ seen(viewer, workspace === "acme" && number === 41 ? ok({ item: ITEM, pulls: [], comments: [] }) : notFound),
68+ resolveRef: async (viewer, context, number) =>
69+ seen(
70+ viewer,
71+ "repo" in context && !hidden(context.repo) && number === 7
72+ ? { kind: "work_item" as const, workspace: "acme", number: 41, itemId: "iss_7" }
73+ : { kind: "none" as const },
74+ ),
6675 getPull: async (path, number, viewer) =>
67− seen(viewer, !hidden(path) && number === 8 ? ok({ pull: PULL, issue: null, comments: [] } as never) : notFound),
76+ seen(viewer, !hidden(path) && number === 8 ? ok({ pull: PULL, item: null, comments: [] } as never) : notFound),
6877 byAuthor: async (_username, viewer) =>
69− seen(viewer, ok({ items: [], next: null, repos: [], counts: { pullsMerged: 4, pullsOpen: 1, pulls: 6, issues: 2, issuesOpen: 1 } })),
78+ seen(viewer, ok({ items: [], next: null, repos: [], counts: { pullsMerged: 4, pullsOpen: 1, pulls: 6, items: 2, itemsOpen: 1 } })),
7079 },
7180 projects: {
7281 get: async (workspace, slug, viewer) =>
127136 bio: "Engines.",
128137 pullsMerged: 4,
129138 pullsOpen: 1,
130− issues: 2,
139+ items: 2,
131140 avatar: null,
132141 });
133142 assert.ok(viewers.every((viewer) => viewer === null));
144153 repo: "web",
145154 name: "Web app",
146155 description: "The storefront",
147− issues: 3,
156+ items: 3,
148157 pulls: 2,
149158 });
150159 });
151160
152−test("issues, pull requests and Soon pages get cards of their own", async () => {
161+test("work items, pull requests and Soon pages get cards of their own", async () => {
153162 const { sources: s } = sources();
154− assert.deepEqual(await resolve("/acme/web/issues/7", s), {
155− kind: "issue",
156− owner: "acme",
157− repo: "web",
158− number: 7,
163+ const item = {
164+ kind: "item",
165+ workspace: "acme",
166+ number: 41,
159167 title: "Fix the thing",
160168 state: "not_planned",
161169 author: "ada",
162170 requestedBy: null,
163− });
171+ };
172+ assert.deepEqual(await resolve("/acme/-/work/41", s), item);
173+ assert.deepEqual(await resolve("/Acme/-/work/41", s), item);
174+ // An old issue's address shows the work item it became.
175+ assert.deepEqual(await resolve("/acme/web/issues/7", s), item);
176+ // One no one outside the workspace may see gets the generic card.
177+ assert.deepEqual(await resolve("/acme/-/work/42", s), { kind: "brand" });
164178 const pull = await resolve("/acme/web/pull/8", s);
165179 assert.equal(pull.kind, "pull");
166180 assert.equal(pull.kind === "pull" && pull.state, "merged");
199213
200214 test("every lookup is made with no viewer", async () => {
201215 const { sources: s, viewers } = sources();
202− for (const path of ["/acme", "/acme/web", "/acme/web/issues/7", "/acme/web/pull/8", "/acme/vault/pull/8"]) {
216+ for (const path of ["/acme", "/acme/web", "/acme/-/work/41", "/acme/web/issues/7", "/acme/web/pull/8", "/acme/vault/pull/8"]) {
203217 await resolve(path, s);
204218 }
205219 assert.ok(viewers.length > 0);
259273 assert.equal(cardPath("/u/Ada"), "/u/ada");
260274 assert.equal(cardPath("/acme/web/issues/12"), "/acme/web/issues/12");
261275 assert.equal(cardPath("/acme/web/issues/012"), "/acme/web");
276+ assert.equal(cardPath("/Acme/-/work/12"), "/acme/-/work/12");
277+ assert.equal(cardPath("/acme/-/work/12/extra"), "/acme");
278+ assert.equal(cardPath("/acme/-/work/labels"), "/acme");
262279 assert.equal(cardPath("/acme/web/soon/no-such-thing"), "/acme/web");
263280 assert.equal(cardPath("//evil"), "/");
264281 });
265282
266283 test("a path resolves to the same card as the page it is keyed by", async () => {
267284 const { sources: s } = sources();
268− for (const path of ["/acme/web/tree/main", "/acme/web/pull/7/files", "/acme/-/settings", "/acme/web/issues/3"]) {
285+ for (const path of ["/acme/web/tree/main", "/acme/web/pull/7/files", "/acme/-/settings", "/acme/web/issues/3", "/Acme/-/work/41"]) {
269286 assert.deepEqual(await resolve(path, s), await resolve(cardPath(path), s), path);
270287 }
271288 });
+44−27
66 * page that does not exist, or anything that fails to load gets the generic
77 * card: never a name or a title that a link preview could leak.
88 */
9−import type { IdentityApi, Issue, Project, PullStatus, ReposApi, WorkApi, ProjectsApi } from "@g1t/contracts";
9+import type { IdentityApi, Project, PullStatus, ReposApi, WorkApi, WorkItem, ProjectsApi } from "@g1t/contracts";
1010
1111 // The one list of Soon pages, shared with the site so the two never disagree.
1212 import { roadmapItem } from "../../../apps/web/app/lib/roadmap.ts";
1313
14−export type IssueState = "open" | "completed" | "not_planned";
14+export type ItemState = "open" | "completed" | "not_planned";
1515
1616 export type Card =
1717 /**
4141 /** Over public repositories only. */
4242 pullsMerged: number;
4343 pullsOpen: number;
44− issues: number;
44+ items: number;
4545 avatar?: string | null;
4646 icon?: string;
4747 }
5151 repo: string;
5252 name: string;
5353 description: string | null;
54− issues: number;
54+ /** Open work items linked to the repository. */
55+ items: number;
5556 pulls: number;
5657 }
5758 | {
58− kind: "issue";
59− owner: string;
60− repo: string;
59+ /** A work item, at `/<workspace>/-/work/<number>`. */
60+ kind: "item";
61+ workspace: string;
6162 number: number;
6263 title: string;
63− state: IssueState;
64+ state: ItemState;
6465 author: string;
6566 /** For one g1t's agent filed: the person it was working for. */
6667 requestedBy: string | null;
8687 export type Sources = {
8788 identity: Pick<IdentityApi, "getWorkspace" | "profile">;
8889 repos: Pick<ReposApi, "get">;
89− work: Pick<WorkApi, "counts" | "getIssue" | "getPull" | "byAuthor">;
90+ work: Pick<WorkApi, "counts" | "getWorkItem" | "resolveRef" | "getPull" | "byAuthor">;
9091 projects: Pick<ProjectsApi, "get" | "list">;
9192 };
9293
117118 address: "g1t.sh/search",
118119 eyebrow: "Search",
119120 title: "Search all of g1t",
120− description: "Repositories, code, issues, pull requests and people, in one search.",
121+ description: "Repositories, code, work items, pull requests and people, in one search.",
121122 },
122123 policies: {
123124 kind: "page",
254255 return parts.length === 2 && page in PAGES ? join(lower, parts[1].toLowerCase()) : "/";
255256 }
256257 if (!NAME.test(owner)) return "/";
258+ if (repo === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) return join(lower, "-", "work", item);
257259 if (repo === undefined || repo === "-") return join(lower);
258260 if (!NAME.test(repo)) return "/";
261+ // An old issue's address: the work item it became.
259262 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) return join(owner, repo, "issues", item);
260263 if (section === "pull" && item !== undefined && isNumber(item)) return join(owner, repo, "pull", item);
261264 if (section === "soon" && item !== undefined && parts.length === 4 && roadmapItem(item)) return join(owner, repo, "soon", item);
292295 bio: profile.bio,
293296 pullsMerged: counts?.pullsMerged ?? 0,
294297 pullsOpen: counts?.pullsOpen ?? 0,
295− issues: counts?.issues ?? 0,
298+ items: counts?.items ?? 0,
296299 avatar: profile.avatar,
297300 };
298301 }
304307 }
305308 if (!NAME.test(owner)) return null;
306309
310+ // A work item: only one filed in a public repository is anyone's to see.
311+ if (repo === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) {
312+ return itemCard(work, owner.toLowerCase(), Number(item));
313+ }
314+
307315 // A workspace, or one of its own pages under `-`.
308316 if (repo === undefined || repo === "-") {
309317 const workspace = await identity.getWorkspace(owner.toLowerCase());
328336 const { namespace, name } = found.value;
329337 const canonical = { namespace, name };
330338
339+ // An old issue's address names the work item it became.
331340 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) {
332− const issue = await work.getIssue(canonical, Number(item), null);
333− if (issue.ok) {
334− return {
335− kind: "issue",
336− owner: namespace,
337− repo: name,
338− number: issue.value.issue.number,
339− title: issue.value.issue.title,
340− state: issueState(issue.value.issue),
341− author: issue.value.issue.author.username,
342− requestedBy: issue.value.issue.requestedBy?.username ?? null,
343− };
341+ const target = await work.resolveRef(null, { repo: canonical }, Number(item));
342+ if (target.kind === "work_item") {
343+ const card = await itemCard(work, target.workspace, target.number);
344+ if (card) return card;
344345 }
345346 }
346347
386387 name: shown?.name ?? name,
387388 // Only projects have descriptions; a private project keeps its own to itself.
388389 description: shown?.description ?? null,
389− issues: counts?.ok ? counts.value.issues : 0,
390+ items: counts?.ok ? counts.value.items : 0,
390391 pulls: counts?.ok ? counts.value.pulls : 0,
391392 };
392393 }
395396 return /^[1-9][0-9]{0,8}$/.test(value);
396397 }
397398
398−function issueState(issue: Issue): IssueState {
399− if (issue.state === "open") return "open";
400− return issue.reason === "not_planned" ? "not_planned" : "completed";
399+/** A work item's card, looked up with no viewer. */
400+async function itemCard(work: Sources["work"], workspace: string, number: number): Promise<Card | null> {
401+ const found = await work.getWorkItem(workspace, number, null);
402+ if (!found.ok) return null;
403+ const item = found.value.item;
404+ return {
405+ kind: "item",
406+ workspace: item.workspace,
407+ number: item.number,
408+ title: item.title,
409+ state: itemState(item),
410+ author: item.author.username,
411+ requestedBy: item.requestedBy?.username ?? null,
412+ };
413+}
414+
415+function itemState(item: WorkItem): ItemState {
416+ if (item.state === "open") return "open";
417+ return item.reason === "not_planned" ? "not_planned" : "completed";
401418 }
402419
403420 /** The card for a page of the docs, from what the page says about itself. */

Comments

Sign in to comment.