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

Phone app: a new work item offers its own workspace's repositories

New work item listed the signed-in workspace's repositories, cached without the workspace, so a link into another workspace offered the wrong ones. It reads that workspace's projects, and ticks the repository it is filed from as a target, as the site does. Work's New, on a list filtered to a repository, files the item from it.

G1T-Session:
ses_01m56vafhge7ttrgs5288dpxvb
syntaqxcommitted Parentef4817eBrowse files
5 files+91−130/5 viewed
+2−1
229229 <Pressable
230230 accessibilityRole="button"
231231 accessibilityLabel="New work item"
232− onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug } })}
232+ // Filtered to a repository, a new item is filed from it.
233+ onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug, ...(filter.repo ? { repo: filter.repo } : {}) } })}
233234 style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]}
234235 >
235236 <Plus size={20} color={colors.onAccent} strokeWidth={2.5} />
+15−12
66
77 import { Button, Text } from "@/components/ui";
88 import { Chip } from "@/components/work";
9−import { useRepos } from "@/lib/code";
9+import { useWorkspaceRepos } from "@/lib/code";
10+import { originOf } from "@/lib/new-item";
1011 import { useSignedIn } from "@/lib/session";
1112 import { useLabels, useOpenWorkItem } from "@/lib/work";
1213 import { radius, space, useColors } from "@/theme";
1415 /**
1516 * A new work item: a title, what it is about, its labels, and the
1617 * repositories where it is meant to land (none is fine: it can be linked
17− * later). Opened from a repository's Work, that repository is its origin.
18+ * later): its own workspace's, whichever the app is in. Opened from a
19+ * repository (`repo`), that repository is its origin, and a target to
20+ * start with.
1821 * Made for the on-screen keyboard: the title's return goes on to the
1922 * description, and Create stays in the header above the keyboard.
2023 */
2225 const params = useLocalSearchParams<{ workspace?: string; repo?: string }>();
2326 const { workspace: current } = useSignedIn();
2427 const workspace = params.workspace ?? current?.slug ?? "";
25− const origin = params.repo ? `${workspace}/${params.repo}` : undefined;
28+ const origin = originOf(workspace, params.repo) ?? undefined;
2629 const colors = useColors();
2730 const headerHeight = useHeaderHeight();
2831 const [title, setTitle] = useState("");
2932 const [body, setBody] = useState("");
3033 const [labels, setLabels] = useState<string[]>([]);
31− const [targets, setTargets] = useState<string[]>([]);
34+ // Filed from a repository, it starts as a target too, as on the site; untick it to leave it the origin only.
35+ const [targets, setTargets] = useState<string[]>(origin ? [origin] : []);
3236 const bodyField = useRef<TextInput>(null);
3337 const known = useLabels(workspace);
34− const repos = useRepos("");
38+ const repos = useWorkspaceRepos(workspace);
3539 const open = useOpenWorkItem(workspace);
3640 const ready = title.trim().length > 0 && !open.isPending;
3741
6064 <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
6165 {origin ? (
6266 <Text size="small" tone="muted">
63− Filed from {params.repo}, in {workspace}
67+ Filed from {origin}
6468 </Text>
6569 ) : (
6670 <Text size="small" tone="muted">
106110 hint="The repositories the work is meant to land in. It closes once it has landed in each. Leave them out to pick later."
107111 empty={repos.isPending ? "…" : "No repositories you can see."}
108112 >
109− {(repos.data ?? []).map((repo) => {
110− const path = `${repo.namespace}/${repo.name}`;
111− const on = targets.includes(path);
113+ {(repos.data ?? []).map((path) => {
114+ const on = targets.some((one) => one.toLowerCase() === path.toLowerCase());
112115 return (
113116 <Chip
114− key={repo.id}
115− label={repo.name}
117+ key={path}
118+ label={path.split("/")[1] ?? path}
116119 tone={on ? "accent" : "neutral"}
117120 leading={on ? <Check size={12} color={colors.accent} /> : null}
118− onPress={() => setTargets(toggle(targets, path))}
121+ onPress={() => setTargets(on ? targets.filter((one) => one.toLowerCase() !== path.toLowerCase()) : [...targets, path])}
119122 />
120123 );
121124 })}
+15−0
11 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22
3+import { workspaceRepos } from "./new-item";
34 import { useSignedIn } from "./session";
45 import type { ApiWorkItem } from "./work";
56
9091 type ApiProject = { repository: string | null; primary: boolean; description: string | null };
9192
9293 /**
94+ * The repositories of `workspace`'s own projects, `owner/name`: where a
95+ * new work item of it may land, whichever workspace the app is in.
96+ */
97+export function useWorkspaceRepos(workspace: string | null | undefined) {
98+ const { client } = useSignedIn();
99+ return useQuery({
100+ queryKey: ["code", "workspace-repos", workspace?.toLowerCase()],
101+ enabled: Boolean(workspace),
102+ queryFn: () => client.api<ApiProject[]>(`/workspaces/${encodeURIComponent(workspace!)}/projects`),
103+ select: (projects) => workspaceRepos(workspace!, projects),
104+ });
105+}
106+
107+/**
93108 * The descriptions of a workspace's repositories, by lowercase
94109 * `owner/name`. A repository has none of its own: its project's is shown.
95110 */
+25−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { originOf, workspaceRepos } from "./new-item.ts";
5+
6+test("a new work item may land in its own workspace's repositories, each once", () => {
7+ const projects = [
8+ { repository: "acme/web" },
9+ { repository: null },
10+ { repository: "Acme/Web" },
11+ { repository: "acme/api" },
12+ // Shared with someone from another workspace: not this one's to land in.
13+ { repository: "other/tool" },
14+ ];
15+ assert.deepEqual(workspaceRepos("acme", projects), ["acme/web", "acme/api"]);
16+ assert.deepEqual(workspaceRepos("beta", projects), []);
17+});
18+
19+test("a new work item is filed from a repository of its workspace, by name or path", () => {
20+ assert.equal(originOf("acme", "web"), "acme/web");
21+ assert.equal(originOf("acme", "Acme/web"), "acme/web");
22+ assert.equal(originOf("acme", "other/web"), null);
23+ assert.equal(originOf("acme", ""), null);
24+ assert.equal(originOf("acme", undefined), null);
25+});
+34−0
1+/**
2+ * A new work item's choices, as the site's New work item has them
3+ * (apps/web/app/routes/workspace/work/new.tsx): the repositories of its
4+ * own workspace it may land in, and the one it is filed from, ticked as
5+ * a target to start with.
6+ *
7+ * Kept free of React Native imports so `npm test` runs it under Node.
8+ */
9+
10+/** A workspace's project as the API answers: only its repository, `owner/name`, when it has one. */
11+export type ProjectRepo = { repository: string | null };
12+
13+/** The repositories of a workspace's projects, `owner/name`, each once, in the order given. Another workspace's are left out. */
14+export function workspaceRepos(workspace: string, projects: readonly ProjectRepo[]): string[] {
15+ const own = workspace.toLowerCase();
16+ const seen = new Map<string, string>();
17+ for (const project of projects) {
18+ const path = project.repository;
19+ if (!path || path.split("/")[0]?.toLowerCase() !== own) continue;
20+ if (!seen.has(path.toLowerCase())) seen.set(path.toLowerCase(), path);
21+ }
22+ return [...seen.values()];
23+}
24+
25+/**
26+ * The repository a new item is filed from, `owner/name`, from a link's
27+ * `repo` (`web` or `acme/web`): only one of this workspace, else none.
28+ */
29+export function originOf(workspace: string, repo: string | null | undefined): string | null {
30+ const parts = (repo ?? "").split("/").filter(Boolean);
31+ if (parts.length === 1) return `${workspace}/${parts[0]}`;
32+ if (parts.length === 2 && parts[0]!.toLowerCase() === workspace.toLowerCase()) return `${workspace}/${parts[1]}`;
33+ return null;
34+}

Comments

Sign in to comment.