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.

205 linesCodeBlameRaw
1import { ArrowLeft, Pencil } from "lucide-react";
2import { useState } from "react";
3import { Link, redirect } from "react-router";
4
5import type { Pull, WorkItem } from "@g1t/contracts";
6
7import type { Route } from "./+types/milestone";
8import { LabelChip, MilestoneBar } from "../../../components/labels";
9import { Markdown } from "../../../components/markdown";
10import { DeleteMilestone, DueLine, MilestoneForm, MilestoneStateButton, ProgressLine } from "../../../components/milestones";
11import { EmptyState, ErrorText, TimeAgo } from "../../../components/ui";
12import { Badge } from "../../../components/ui/badge";
13import { Button } from "../../../components/ui/button";
14import { Card } from "../../../components/ui/card";
15import { PullIcon } from "../../../components/work";
16import { WorkItemRow } from "../../../components/work-items";
17import { readableRepos } from "../../../lib/access.server";
18import { colorsOf } from "../../../lib/labels";
19import { orElse, part } from "../../../lib/partial";
20import { page } from "../../../lib/meta";
21import { milestoneAction } from "../../../lib/milestones.server";
22import { openedBy } from "../../../lib/opened-by";
23import { work } from "../../../lib/services.server";
24import { getViewer } from "../../../lib/session.server";
25import { canItem } from "../../../lib/work-access";
26import { shortPath, workPath } from "../../../lib/work-items";
27
28export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
29 const title = loaderData?.detail.milestone.title;
30 return page(args, { title: `${title ? `${title} · Milestone · ` : ""}Work · ${params.owner} · g1t` });
31}
32
33export async function loader({ params, context, request }: Route.LoaderArgs) {
34 const viewer = getViewer(context);
35 const workspace = params.owner.toLowerCase();
36 const state = new URL(request.url).searchParams.get("state") === "closed" ? "closed" : "open";
37 const [found, labels] = await Promise.all([
38 work.getMilestone(workspace, Number(params.number), viewer),
39 // Only its chips' colors: without them a chip still names its label.
40 part(work.listLabels(workspace, viewer), "Labels").then((labels) => orElse(labels, null)),
41 ]);
42 if (!found.ok) throw new Response("Milestone not found.", { status: 404 });
43 // Its pull requests are each in a repository of the workspace: where each is.
44 const ids = [...new Set(found.value.pulls.map((pull) => pull.repoId))];
45 const repos = await readableRepos(ids, viewer).catch(() => []);
46 return {
47 workspace,
48 detail: found.value,
49 repoPaths: Object.fromEntries(repos.map((repo) => [repo.id, `${repo.namespace}/${repo.name}`])) as Record<string, string>,
50 labels: labels?.ok ? labels.value : [],
51 state,
52 canEdit: canItem(viewer, workspace, null, "manage_labels"),
53 today: new Date().toISOString(),
54 } as const;
55}
56
57export async function action({ request, params, context }: Route.ActionArgs) {
58 const result = await milestoneAction(request, params, context);
59 // Deleted: back to the list.
60 if (result.intent === "delete" && !result.error) throw redirect(workPath(params.owner, "milestones"));
61 return result;
62}
63
64type Entry = { kind: "item"; item: WorkItem } | { kind: "pull"; pull: Pull };
65
66function isOpen(entry: Entry): boolean {
67 return entry.kind === "item" ? entry.item.state === "open" : entry.pull.status === "open" || entry.pull.status === "draft";
68}
69
70/** When it was made, to put work items and pull requests in one order. */
71function madeAt(entry: Entry): number {
72 return Date.parse(entry.kind === "item" ? entry.item.createdAt : entry.pull.createdAt);
73}
74
75export default function MilestonePage({ loaderData, actionData }: Route.ComponentProps) {
76 const { detail, labels, state, canEdit, workspace, repoPaths } = loaderData;
77 const { milestone } = detail;
78 const base = workPath(workspace);
79 const today = new Date(loaderData.today);
80 const colors = colorsOf(labels);
81 const [editing, setEditing] = useState(false);
82 // Work items and pull requests together, newest first.
83 const entries: Entry[] = [
84 ...detail.items.map((item) => ({ kind: "item" as const, item })),
85 ...detail.pulls.map((pull) => ({ kind: "pull" as const, pull })),
86 ].sort((a, b) => madeAt(b) - madeAt(a));
87 const openEntries = entries.filter(isOpen);
88 const closedEntries = entries.filter((entry) => !isOpen(entry));
89 const shown = state === "open" ? openEntries : closedEntries;
90 const here = `${base}/milestones/${milestone.number}`;
91 const tab = (value: "open" | "closed", label: string, count: number) => (
92 <Link
93 to={value === "open" ? here : `${here}?state=closed`}
94 className={"rounded-md px-3 py-1.5 text-sm transition-colors " + (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")}
95 >
96 {label} <span className="tabular-nums text-faint">{count}</span>
97 </Link>
98 );
99 return (
100 <div className="space-y-5">
101 <Link to={`${base}/milestones`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
102 <ArrowLeft size={14} />
103 Milestones
104 </Link>
105 {editing ? (
106 <MilestoneForm milestone={milestone} onDone={() => setEditing(false)} />
107 ) : (
108 <header className="grid gap-4 sm:grid-cols-[1fr_18rem]">
109 <div className="min-w-0">
110 <h1 className="text-2xl font-semibold tracking-tight text-balance">
111 {milestone.title}
112 {milestone.state === "closed" && (
113 <Badge size="md" className="ml-2 align-middle">
114 Closed
115 </Badge>
116 )}
117 </h1>
118 <p className="mt-1 text-sm">
119 <DueLine milestone={milestone} today={today} />
120 </p>
121 {milestone.description && (
122 <div className="mt-3 text-sm">
123 <Markdown source={milestone.description} workspace={workspace} />
124 </div>
125 )}
126 </div>
127 <div className="min-w-0">
128 <MilestoneBar milestone={milestone} />
129 <p className="mt-1.5">
130 <ProgressLine milestone={milestone} />
131 </p>
132 {canEdit && (
133 <div className="mt-3 flex flex-wrap gap-2">
134 <Button type="button" variant="outline" onClick={() => setEditing(true)}>
135 <Pencil size={14} />
136 Edit
137 </Button>
138 <MilestoneStateButton milestone={milestone} />
139 <DeleteMilestone milestone={milestone} />
140 </div>
141 )}
142 </div>
143 </header>
144 )}
145 {actionData?.error && actionData.intent !== "edit" && <ErrorText>{actionData.error}</ErrorText>}
146 <Card tone="plain" radius="lg" className="flex gap-1 self-start p-1 sm:w-fit">
147 {tab("open", "Open", openEntries.length)}
148 {tab("closed", "Closed", closedEntries.length)}
149 </Card>
150 {shown.length === 0 ? (
151 <EmptyState title={state === "open" ? "Nothing open in this milestone" : "Nothing closed in this milestone yet"}>
152 {canEdit
153 ? "Put a work item or a pull request in it from the Milestone menu in its sidebar."
154 : "Work items and pull requests in this milestone show here."}
155 </EmptyState>
156 ) : (
157 <Card asChild tone="plain" divided className="overflow-hidden">
158 <ul>
159 {shown.map((entry) =>
160 entry.kind === "item" ? (
161 <WorkItemRow key={`i${entry.item.id}`} item={entry.item} colors={colors} />
162 ) : (
163 <PullRow key={`p${entry.pull.id}`} pull={entry.pull} repo={repoPaths[entry.pull.repoId] ?? null} workspace={workspace} colors={colors} />
164 ),
165 )}
166 </ul>
167 </Card>
168 )}
169 </div>
170 );
171}
172
173/** A pull request in the milestone, with the repository it is in. */
174function PullRow({ pull, repo, workspace, colors }: { pull: Pull; repo: string | null; workspace: string; colors: Record<string, string> }) {
175 const inner = (
176 <>
177 <span className="mt-0.5">
178 <PullIcon status={pull.status} />
179 </span>
180 <span className="min-w-0 grow">
181 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
182 <span className="font-medium wrap-break-word">{pull.title}</span>
183 {(pull.labels ?? []).map((name) => (
184 <LabelChip key={name} name={name} color={colors[name]} />
185 ))}
186 </span>
187 <span className="mt-0.5 block text-xs text-faint">
188 {repo && <span className="font-mono">{shortPath(repo, workspace)}</span>}#{pull.number} opened <TimeAgo at={pull.createdAt} /> by{" "}
189 {openedBy(pull).name}
190 </span>
191 </span>
192 </>
193 );
194 return (
195 <li>
196 {repo ? (
197 <Link to={`/${repo}/pull/${pull.number}`} className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface">
198 {inner}
199 </Link>
200 ) : (
201 <div className="flex items-start gap-3 px-4 py-3">{inner}</div>
202 )}
203 </li>
204 );
205}