Link and mention releases

- Route release and release list links to their views
- Offer releases for a pasted ID or link
- List delegate releases in # search
- Show release references as chips
- Cache release lookups for mentions
- Record release mentions in ADR 0003
This commit is contained in:
Daniel Norman
2026-10-08 18:21:48 +02:00
committed by Rūdolfs Ošiņš
parent 595a81150b
commit e930bfd397
9 changed files with 240 additions and 33 deletions
+3 -2
View File
@@ -38,7 +38,8 @@ text as an absolute identifier:
| Person | `did:key:<NID>` |
- Object ids are always full. The composer expands a prefix and offers every
match when several objects share it.
match when several objects share it. It searches only delegate releases;
a full release id or link resolves any release.
- Identifiers are never relative to the current repo.
- Explorer URLs are accepted as input and normalised on insert. Where a URL
is ambiguous, as in `/tree/<branch>/<path>` with a branch containing
@@ -54,7 +55,7 @@ from explorer routes. It is tested against the RIP 4 vectors and
`radicle-uri`'s cases.
**Clients decide presentation.** This app shows repos, issues, patches,
commits and people as chips, and every other entity above as a link
releases, commits and people as chips, and every other entity above as a link
labelled with the repo name, such as `heartwood: src/lib.rs`. Either
navigates in-app when the repo is local and falls back to the configured
`publicExplorer`, which a right click also offers. A resource a client does
+45
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Release } from "@bindings/cob/release/Release";
import type { Config } from "@bindings/config/Config";
import type { ComponentProps } from "svelte";
@@ -8,6 +9,7 @@
cachedConfig,
cachedIssueById,
cachedPatchById,
cachedReleaseById,
cachedRepoById,
cachedRepoCommit,
} from "@app/lib/invoke";
@@ -49,6 +51,7 @@
let resolvedRepoName: string | undefined = $state(undefined);
let issue: Issue | undefined = $state(undefined);
let patch: Patch | undefined = $state(undefined);
let release = $state<Release | undefined>(undefined);
let commitSummary: string | undefined = $state(undefined);
let missing = $state(false);
let config: Config | undefined = $state(undefined);
@@ -136,6 +139,27 @@
};
});
$effect(() => {
if (target.type !== "cob" || target.kind !== "release") return;
let cancelled = false;
void cachedReleaseById(target.rid, target.oid)
.then(result => {
if (cancelled) return;
if (result) release = result;
else missing = true;
})
.catch(() => {
if (!cancelled) missing = true;
});
return () => {
cancelled = true;
};
});
const releaseTitle = $derived(
release ? release.title || release.tagName : undefined,
);
$effect(() => {
if (target.type !== "commit") return;
let cancelled = false;
@@ -365,6 +389,27 @@
<span class="mention-oid">{formatOid(target.oid)}</span>
{/if}
</a>
{:else if target.kind === "release"}
<a
class="mention"
class:unresolved={missing}
{href}
target={missing ? "_blank" : undefined}
rel={missing ? "noopener noreferrer" : undefined}
onclick={handleClick}
onmousedown={keepSelection}
oncontextmenu={openMenu}
title={missing
? `${fallback} · ${target.oid} — not replicated locally`
: `${releaseTitle ?? fallback} · ${target.oid}`}>
<span class="mention-status">
<Icon name="parcel" />
</span>
<span class="mention-label">{releaseTitle ?? fallback}</span>
{#if missing || (release && !releaseTitle)}
<span class="mention-oid">{formatOid(target.oid)}</span>
{/if}
</a>
{:else}
<a
class="mention"
+53 -2
View File
@@ -9,6 +9,7 @@
<script lang="ts">
import type { AliasSuggestion } from "@bindings/cob/AliasSuggestion";
import type { Author } from "@bindings/cob/Author";
import type { Release } from "@bindings/cob/release/Release";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { ComponentProps } from "svelte";
@@ -29,8 +30,10 @@
cachedIssueById,
cachedListIssueCandidates,
cachedListPatchCandidates,
cachedListReleaseCandidates,
cachedListReposSummary,
cachedPatchById,
cachedReleaseById,
cachedRepoById,
cachedRepoCommit,
cachedRepoCommitsByPrefix,
@@ -321,6 +324,25 @@
};
}
if (target.kind === "release") {
const release = await cachedReleaseById(target.rid, target.oid).catch(
() => undefined,
);
const label = release
? releaseTitle(release)
: `release ${formatOid(target.oid)}`;
return {
key,
target,
label,
primary: label,
secondary: formatOid(target.oid),
icon: "parcel",
haystack: label,
};
}
const patch = await cachedPatchById(target.rid, target.oid).catch(
() => undefined,
);
@@ -431,9 +453,10 @@
}
async function resolveOid(oid: string): Promise<Suggestion[]> {
const [issue, patch, commit] = await Promise.all([
const [issue, patch, release, commit] = await Promise.all([
cachedIssueById(rid, oid).catch(() => undefined),
cachedPatchById(rid, oid).catch(() => undefined),
cachedReleaseById(rid, oid).catch(() => undefined),
cachedRepoCommit(rid, oid).catch(() => undefined),
]);
const rows: Suggestion[] = [];
@@ -460,6 +483,9 @@
haystack: patch.title,
});
}
if (release) {
rows.push(releaseRow(release));
}
if (commit && rows.length === 0) {
rows.push({
key: `commit:${oid}`,
@@ -564,9 +590,10 @@
}
async function collectCobs(): Promise<Suggestion[]> {
const [allIssues, allPatches] = await Promise.all([
const [allIssues, allPatches, allReleases] = await Promise.all([
cachedListIssueCandidates(rid).catch(() => []),
cachedListPatchCandidates(rid).catch(() => []),
cachedListReleaseCandidates(rid).catch(() => []),
]);
const rows = [
...allIssues.map(issue => ({
@@ -601,6 +628,10 @@
icon: patchIcon[patch.state.status],
haystack: `${patch.title} ${patch.id}`,
})),
...allReleases.map(release => ({
timestamp: release.createdAt,
...releaseRow(release),
})),
];
return rows
@@ -608,6 +639,26 @@
.map(({ timestamp: _timestamp, ...row }) => row);
}
function releaseTitle(release: Release): string {
return (
release.title || release.tagName || `release ${formatOid(release.id)}`
);
}
function releaseRow(release: Release): Suggestion {
const label = releaseTitle(release);
return {
key: `release:${release.id}`,
target: { type: "cob", kind: "release", rid, oid: release.id },
label,
primary: label,
secondary: formatOid(release.id),
icon: "parcel",
haystack: `${label} ${release.tagName ?? ""} ${release.id}`,
};
}
const patchIcon: Record<string, IconName> = {
draft: "patch-draft",
open: "patch",
+30
View File
@@ -3,6 +3,8 @@ import type { DiffOptions } from "@bindings/cob/DiffOptions";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Release } from "@bindings/cob/release/Release";
import type { ReleaseFilter } from "@bindings/cob/release/ReleaseFilter";
import type { Config } from "@bindings/config/Config";
import type { Diff } from "@bindings/diff/Diff";
import type { Stats } from "@bindings/diff/Stats";
@@ -231,6 +233,23 @@ export const cachedListPatchCandidates = cached(
{ max: 10, ttl: 30_000 },
);
async function listReleaseCandidates(rid: string): Promise<Release[]> {
const releases = await invoke<PaginatedQuery<Release[]>>("list_releases", {
rid,
filter: { scope: "trusted", showRedacted: false } satisfies ReleaseFilter,
skip: 0,
take: MENTION_CANDIDATE_TAKE,
});
return releases.content;
}
export const cachedListReleaseCandidates = cached(
listReleaseCandidates,
(...[rid]) => `release_candidates:${rid}`,
{ max: 10, ttl: 30_000 },
);
const missTtl = 5_000;
async function repoById(rid: string): Promise<RepoInfo | null> {
@@ -266,6 +285,17 @@ export const cachedPatchById = cached(
missTtl,
);
async function releaseById(rid: string, id: string): Promise<Release | null> {
return withTestBackend(tauri.invoke, "release_by_id", { rid, id });
}
export const cachedReleaseById = cached(
releaseById,
(...[rid, id]) => `release_by_id:${rid}:${id}`,
{ max: 200, ttl: 60_000 },
missTtl,
);
async function repoCommit(rid: string, sha: string): Promise<Commit | null> {
return withTestBackend(tauri.invoke, "repo_commit", { rid, sha });
}
+24 -4
View File
@@ -23,9 +23,20 @@ import { explorerLink, formatOid, truncateId } from "@app/lib/utils";
export type Entity =
| { type: "node"; nid: string }
| { type: "repo"; rid: string }
| { type: "cob"; kind: "issue" | "patch"; rid: string; oid: string }
| {
type: "cob";
kind: "issue" | "patch" | "release";
rid: string;
oid: string;
}
| { type: "commit"; rid: string; oid: string };
const cobTypes = {
issue: issueType,
patch: patchType,
release: releaseType,
};
export function toEntity(reference: RadReference): Entity | undefined {
if (reference.type === "did") return { type: "node", nid: reference.node };
@@ -44,6 +55,9 @@ export function toEntity(reference: RadReference): Entity | undefined {
if (resource.typeName === patchType) {
return { type: "cob", kind: "patch", rid, oid: resource.oid };
}
if (resource.typeName === releaseType) {
return { type: "cob", kind: "release", rid, oid: resource.oid };
}
}
return undefined;
@@ -68,7 +82,7 @@ export function toRadReference(target: Entity): RadReference {
repo,
resource: {
type: "cob",
typeName: target.kind === "issue" ? issueType : patchType,
typeName: cobTypes[target.kind],
oid: target.oid,
},
},
@@ -179,6 +193,9 @@ export function referenceRoute(reference: RadReference): RepoRoute | undefined {
reviewId: undefined,
};
}
if (resource.typeName === releaseType) {
return { resource: "repo.release", rid, release: resource.oid };
}
}
if (resource.type === "commit" || resource.type === "tag") {
return {
@@ -196,6 +213,9 @@ export function referenceRoute(reference: RadReference): RepoRoute | undefined {
if (typeName === patchType) {
return { resource: "repo.patches", rid, status: undefined };
}
if (typeName === releaseType) {
return { resource: "repo.releases", rid };
}
return undefined;
}
@@ -213,7 +233,7 @@ const cobLists: Record<
> = {
[issueType]: { label: "Issues", icon: "issue" },
[patchType]: { label: "Patches", icon: "patch" },
[releaseType]: { label: "Releases", icon: "archive" },
[releaseType]: { label: "Releases", icon: "parcel" },
};
export function describeLink(
@@ -269,7 +289,7 @@ export function describeLink(
label: `${repoName}: ${release}`,
primary: `Release ${formatOid(resource.oid)}`,
secondary: repoName,
icon: "archive",
icon: "parcel",
};
}
+47 -4
View File
@@ -1,12 +1,16 @@
import type { Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager.js";
import * as Fs from "node:fs/promises";
import * as Path from "node:path";
import {
createCollaborators,
createProject,
} from "@tests/support/collaboration.js";
import {
expect,
goto,
reload,
test,
useBackend,
@@ -85,6 +89,45 @@ test("a pasted patch URI becomes a chip that opens the patch", async ({
await expect(page).toHaveURL(new RegExp(`/patches/${patchId}`));
});
test("a pasted release ID becomes a chip that opens the release", async ({
page,
peer,
}) => {
const { rid, issueId, repoFolder } = await openIssue(page, peer);
const file = Path.join(repoFolder, "build.tar");
await Fs.writeFile(file, "artifact bytes");
// The native file dialog only exists under Tauri.
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [file] }),
);
await goto(page, `/repos/${rid}/releases`);
await page.getByRole("button", { name: "New release" }).click();
await page.getByPlaceholder("Pick a commit or paste a SHA").click();
await page.getByText("Add notes").click();
await page.getByRole("button", { name: "Choose files" }).click();
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
const releaseId = new URL(page.url()).pathname.split("/").pop()!;
const uri = `${rid}/cob/dev.radicle.artifact/${releaseId}`;
await goto(page, `/repos/${rid}/issues/${issueId}`);
await type(page, "#Add");
await expect(suggestion(page, "Add notes")).toBeVisible();
await commentBox(page).fill("");
await type(page, releaseId);
await expect(suggestion(page, "Add notes")).toBeVisible();
await page.keyboard.press("Enter");
await expect(commentBox(page)).toHaveValue(`[Add notes](${uri}) `);
await comment(page);
await reload(page);
const chip = rendered(page).locator("a.mention", { hasText: "Add notes" });
await expect(chip).toBeVisible();
await chip.click();
await expect(page).toHaveURL(new RegExp(`/releases/${releaseId}`));
});
test("unsafe and invalid references stay inert", async ({ page, peer }) => {
const { rid } = await openIssue(page, peer);
@@ -214,10 +257,10 @@ test("every reference type renders and links where it should", async ({
await expect(link(`${repo}/home/remotes/${nid}`)).toHaveText("remote");
await expect(link(`${repo}/issues?status=all`)).toHaveText("issues");
await expect(link(`${repo}/patches`)).toHaveText("patches");
await expect(
link(`${explorer}/${rid}/releases/${missingOid}`),
).toHaveAttribute("target", "_blank");
await expect(link(`${explorer}/${rid}/releases`)).toHaveText("releases");
const missingRelease = link(`${explorer}/${rid}/releases/${missingOid}`);
await expect(missingRelease).toHaveClass(/unresolved/);
await expect(missingRelease).toHaveAttribute("target", "_blank");
await expect(link(`${repo}/releases`)).toHaveText("releases");
await expect(
body.locator("a:not([href])", { hasText: "tree" }),
).toBeVisible();
+4 -7
View File
@@ -57,6 +57,10 @@ describe("deepLinkTarget", () => {
reviewId: undefined,
},
],
[
`${rid}/cob/dev.radicle.artifact/${oid}`,
{ resource: "repo.release", rid, release: oid },
],
[`${rid}/commit/${oid}`, { resource: "repo.commit", rid, commit: oid }],
[
`${rid}/commit/main?path=src/lib.rs#L10`,
@@ -82,13 +86,6 @@ describe("deepLinkTarget", () => {
});
});
test("opens the explorer for a page the app lacks", () => {
expect(target(`${rid}/cob/dev.radicle.artifact/${oid}`)).toEqual({
type: "external",
url: `${explorer}/releases/${oid}`,
});
});
test("goes nowhere when neither has a page", () => {
expect(target(`${rid}/tree/${oid}`)).toBeUndefined();
});
+13 -5
View File
@@ -152,13 +152,21 @@ describe("explorer links without a chip", () => {
describe("releases and lines", () => {
const node = "https://radicle.network/nodes/seed";
test("offers a release as a link", () => {
expect(trigger(`${node}/${rid}/releases/${oid}`)).toMatchObject({
test.each([
`${node}/${rid}/releases/${oid}`,
`${rid}/cob/dev.radicle.artifact/${oid}`,
])("offers %s as a release", link => {
expect(trigger(link)).toMatchObject({
kind: "identifier",
target: { type: "cob", kind: "release", rid, oid },
});
});
test("offers a release list as a link", () => {
expect(trigger(`${node}/${rid}/releases`)).toMatchObject({
kind: "link",
reference: {
uri: {
resource: { type: "cob", typeName: "dev.radicle.artifact", oid },
},
uri: { resource: { type: "cob", typeName: "dev.radicle.artifact" } },
},
});
});
+20 -8
View File
@@ -35,6 +35,14 @@ describe("parseEntityHref", () => {
`${rid}/cob/xyz.radicle.patch/${oid}`,
{ type: "cob", kind: "patch", rid, oid },
],
[
`${rid}/cob/dev.radicle.artifact/${oid}`,
{ type: "cob", kind: "release", rid, oid },
],
[
`https://app.radicle.xyz/nodes/seed/${rid}/releases/${oid}`,
{ type: "cob", kind: "release", rid, oid },
],
[
`https://app.radicle.xyz/nodes/seed/${rid}/patches/${oid}`,
{ type: "cob", kind: "patch", rid, oid },
@@ -235,17 +243,21 @@ describe("referenceRoute", () => {
`${rid}/cob/xyz.radicle.patch`,
{ resource: "repo.patches", rid, status: undefined },
],
[
`${rid}/cob/dev.radicle.artifact/${oid}`,
{ resource: "repo.release", rid, release: oid },
],
[`${rid}/cob/dev.radicle.artifact`, { resource: "repo.releases", rid }],
])("%s", (href, expected) => {
expect(referenceRoute(parseReference(href)!)).toEqual(expected);
});
test.each([
`${rid}/cob/dev.radicle.artifact`,
`${rid}/tree/${oid}`,
`did:key:${nid}`,
])("has no page for %s", href => {
test.each([`${rid}/tree/${oid}`, `did:key:${nid}`])(
"has no page for %s",
href => {
expect(referenceRoute(parseReference(href)!)).toBeUndefined();
});
},
);
});
describe("describeLink", () => {
@@ -262,11 +274,11 @@ describe("describeLink", () => {
[`${rid}/tag/v1.0`, "heartwood: v1.0", "label"],
[`${rid}/cob/xyz.radicle.issue`, "heartwood: issues", "issue"],
[`${rid}/cob/xyz.radicle.patch`, "heartwood: patches", "patch"],
[`${rid}/cob/dev.radicle.artifact`, "heartwood: releases", "archive"],
[`${rid}/cob/dev.radicle.artifact`, "heartwood: releases", "parcel"],
[
`${rid}/cob/dev.radicle.artifact/${oid}`,
"heartwood: release 5e46830",
"archive",
"parcel",
],
[`${rid}/${nid}`, "heartwood: z6MknS…tBU8Vi", "repository"],
])("%s", (href, label, icon) => {