Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Keep the organization's header and tabs on its packages pages (#30)

An organization's packages list and package pages render the same header and tab bar as its other tabs, with Packages selected. The live test checks it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ruyx3Qr4jYrZEpPi466deb
Canberk Demirci authored · GitHub committed signed1 parent 5018b53commit 0f675dab20972c887c99144e79e403c413302b86Browse files
4 files changed+86−35
Files (4)
modified.github/workflows/live-test.yml+11−0View file
@@ -258,6 +258,17 @@
258258 grep -F "buildx ok 120000000 bytes" <<<"$log" || { echo "::error::the image pushed by docker/build-push-action did not come back from the registry"; exit 1; }
259259 api "/users/$OWNER/packages/container/${REPO,,}/versions" | jq -c '.[] | {tags: .metadata.container.tags, size, platforms}'
260260
261+ - name: An organization's packages page keeps its tabs
262+ if: steps.token.outputs.skip != 'true'
263+ run: |
264+ set -euo pipefail
265+ org=$(curl -fsS "$DIFFINERY/api/v3/user/orgs" -H "Authorization: Bearer $TOKEN" | jq -r '.[0].login // empty')
266+ [ -n "$org" ] || { echo "$OWNER is in no organization; skipped"; exit 0; }
267+ html=$(curl -fsS "$DIFFINERY/$org/-/packages" -H "Authorization: Bearer $TOKEN")
268+ grep -qF "<a href=\"/$org/-/packages\" class=\"on\">" <<<"$html" || { echo "::error::$org's packages page lost the organization's tab bar"; exit 1; }
269+ grep -qF "href=\"/$org?tab=repositories\"" <<<"$html" || { echo "::error::$org's packages page has no Repositories tab"; exit 1; }
270+ echo "$org's packages page shows the organization's tabs with Packages selected"
271+
261272 - name: Git LFS round trip
262273 if: steps.token.outputs.skip != 'true'
263274 run: |
modifiedapps/diffinery/src/web/account.tsx+39−27View file
@@ -1476,6 +1476,44 @@
14761476
14771477 type OrgTab = "repositories" | "people" | "agents" | "settings";
14781478
1479+/** An organization's name and tab bar, shared by its profile tabs and its packages pages. */
1480+export function OrgHeader({ org, role, isOwner, tab }: { org: Account; role: "owner" | "admin" | "member" | null; isOwner: boolean; tab: OrgTab | "packages" }) {
1481+ const base = `/${org.login}`;
1482+ return (
1483+ <>
1484+ <div class="row mb" style="gap:16px">
1485+ <Avatar login={org.login} size="lg" version={org.avatar_version} />
1486+ <div>
1487+ <h1 class="m0">{org.name ?? org.login}</h1>
1488+ <div class="muted small">
1489+ Organization · {org.login}
1490+ {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""}
1491+ </div>
1492+ </div>
1493+ </div>
1494+ <nav class="tabs mb" style="border-bottom:1px solid var(--border)">
1495+ {(
1496+ [
1497+ ["repositories", "Repositories", true],
1498+ ["people", "People", true],
1499+ ["agents", "Agents", true],
1500+ ["settings", "Settings", isOwner],
1501+ ] as const
1502+ )
1503+ .filter((x) => x[2])
1504+ .map(([k, label]) => (
1505+ <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}>
1506+ {label}
1507+ </a>
1508+ ))}
1509+ <a href={`${base}/-/packages`} class={tab === "packages" ? "on" : ""}>
1510+ Packages
1511+ </a>
1512+ </nav>
1513+ </>
1514+ );
1515+}
1516+
14791517 const ROLE_LABEL: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" };
14801518
14811519 /** The invitation link right after inviting someone by email (it's also in their inbox). */
@@ -1788,33 +1826,7 @@
17881826 c,
17891827 { title: acct.login },
17901828 <>
1791- <div class="row mb" style="gap:16px">
1792- <Avatar login={acct.login} size="lg" version={acct.avatar_version} />
1793- <div>
1794- <h1 class="m0">{acct.name ?? acct.login}</h1>
1795- <div class="muted small">
1796- Organization · {acct.login}
1797- {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""}
1798- </div>
1799- </div>
1800- </div>
1801- <nav class="tabs mb" style="border-bottom:1px solid var(--border)">
1802- {(
1803- [
1804- ["repositories", "Repositories", true],
1805- ["people", "People", true],
1806- ["agents", "Agents", true],
1807- ["settings", "Settings", isOwner],
1808- ] as const
1809- )
1810- .filter((x) => x[2])
1811- .map(([k, label]) => (
1812- <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}>
1813- {label}
1814- </a>
1815- ))}
1816- <a href={`${base}/-/packages`}>Packages</a>
1817- </nav>
1829+ <OrgHeader org={acct} role={role} isOwner={isOwner} tab={tab} />
18181830 {body}
18191831 </>,
18201832 );
modifiedapps/diffinery/src/web/packages.tsx+26−8View file
@@ -9,6 +9,7 @@
99 import { RegistryStore, type ManifestRow, type PackageRow } from "../registry/store.js";
1010 import { ICONS } from "./assets.js";
1111 import { KSelect } from "./kumo.js";
12+import { OrgHeader } from "./account.js";
1213 import { csrfToken } from "./csrf.js";
1314 import { Avatar, CopyButton, Form, Time, act, fmtBytes, post, principalAccount, prm, render, repoUrl, type WebApp } from "./ui.js";
1415
@@ -27,6 +28,13 @@
2728 return owner;
2829 }
2930
31+/** An organization's packages pages keep its header and tab bar, with Packages selected. */
32+async function orgShell(c: AppContext, org: Account) {
33+ const me = principalAccount(c);
34+ const role = me ? await c.get("dir").orgRole(org.id, me.kind === "agent" ? (me.owner_id ?? me.id) : me.id) : null;
35+ return <OrgHeader org={org} role={role} isOwner={!!me?.site_admin || role === "owner"} tab="packages" />;
36+}
37+
3038 async function visible(c: AppContext, owner: Account, rows: PackageRow[]): Promise<PackageRow[]> {
3139 const out: PackageRow[] = [];
3240 for (const p of rows) if (allows((await packageAccess(c, owner, p.name, p)).role, "pull")) out.push(p);
@@ -117,17 +125,26 @@
117125 c,
118126 { title: `${owner.login} packages` },
119127 <>
120- <div class="row mb" style="gap:12px">
121- <Avatar login={owner.login} size="lg" version={owner.avatar_version} />
122- <div>
123- <h1 class="m0">
124- <a href={`/${owner.login}`}>{owner.login}</a> <span class="faint">/</span> Packages
125- </h1>
126- <div class="muted small">
128+ {owner.kind === "org" ? (
129+ <>
130+ {await orgShell(c, owner)}
131+ <p class="muted small">
127132 Container images at <span class="mono">{host(c)}/{owner.login.toLowerCase()}/…</span>
133+ </p>
134+ </>
135+ ) : (
136+ <div class="row mb" style="gap:12px">
137+ <Avatar login={owner.login} size="lg" version={owner.avatar_version} />
138+ <div>
139+ <h1 class="m0">
140+ <a href={`/${owner.login}`}>{owner.login}</a> <span class="faint">/</span> Packages
141+ </h1>
142+ <div class="muted small">
143+ Container images at <span class="mono">{host(c)}/{owner.login.toLowerCase()}/…</span>
144+ </div>
128145 </div>
129146 </div>
130- </div>
147+ )}
131148 <div class="box">
132149 <PackageList
133150 c={c}
@@ -159,6 +176,7 @@
159176 c,
160177 { title: `${owner.login}/${pkg.name}`, description: pkg.description ?? `Container image ${image}` },
161178 <>
179+ {owner.kind === "org" ? await orgShell(c, owner) : null}
162180 <div class="mb">
163181 <h1 class="m0">
164182 <VisIcon v={pkg.visibility} />
modifiedapps/diffinery/test/e2e/registry.test.mjs+10−0View file
@@ -373,6 +373,16 @@
373373 // ...but not to the members' unlinked packages, which he can't see.
374374 assert.equal((await reg("GET", "crew/mira-tool/manifests/v1", { tok: CREW.otto })).status, 404);
375375 assert.deepEqual((await ok("GET", "/orgs/crew/packages", undefined, CREW.otto)).map((p) => p.name), ["app"]);
376+
377+ // The organization's packages pages keep its header and tab bar, with Packages selected.
378+ for (const path of ["/crew/-/packages", "/crew/-/packages/container/mira-tool"]) {
379+ const page = await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${CREW.mira}` } });
380+ const html = await page.text();
381+ assert.equal(page.status, 200, path);
382+ assert.match(html, /Organization · crew · you are a member/, path);
383+ assert.match(html, /<a href="\/crew\/-\/packages" class="on">/, path);
384+ assert.match(html, /href="\/crew\?tab=people"/, path);
385+ }
376386 });
377387
378388 function git(cwd, args) {