Files
buzz/desktop/src/features/projects/projectRepositoryCreation.test.mjs
thomaspblock 122a8b8988 Projects v3: unify sharing, discussions, and issue ownership (#5792)
## Summary

Projects v3 makes repository work shareable, discussion-aware, and
easier to scan in one coherent workspace. People can copy canonical
links, reopen the exact workspace tab, understand issue and pull-request
context at a glance, find related channel conversations, and assign or
unassign issues across Desktop and CLI.

- **Unified workspace** — top-level sections sit above repository
controls in one rounded workspace, with navigation positioned close to
the page heading. README and Files retain branch selection; every
section has a labeled icon header, and Issues and Pull Requests expose
creation from a consistent right-aligned action.
- **Repository management** — the repository selector is always
available, including single-repository projects. Its integrated add flow
lets project owners create a repository manually or select an existing
repository without a separate toolbar button.
- **Readable work-item lists** — issue and pull-request rows use
plain-language context instead of opaque metadata. Files, commits,
issues, pull requests, channels, and contributors share consistent row
density and right-aligned timestamps, while deterministic
fallback-avatar colors keep participants distinct on light backgrounds.
Inbox pull-request metadata wraps between complete phrases and truncates
long channel names instead of compressing copy into narrow columns.
- **Reliable entity links** — projects, repositories, issues, pull
requests, and commits have canonical `buzz://` links, preview cards, OS
deep-link routing, and tab-aware navigation. Reopening the same link
re-applies its destination instead of leaving the user on a locally
selected tab.
- **Related conversations** — repository and work-item views surface
channels discussing the current entity, including participants, channel
navigation, message context, and an explicit notice when discovery
reaches its 500-result cap.
- **Reversible issue ownership** — trusted assignment and unassignment
events work across Desktop, Tauri, `buzz-sdk`, and `buzz issues`.
Assignees appear in project views and the assigned inbox, while
authorized users can remove assignments directly from the assignee row.

Assignment state is derived chronologically from labeled Nostr notes.
Issue authors and repository owners may change any assignee; other users
may only assign or unassign themselves. Shared golden fixtures keep
entity-link grammar and validation aligned across TypeScript and Rust.

The branch also updates `webbrowser` to the patched release for
RUSTSEC-2026-0257.

### Related issue

N/A.

### Testing

- [x] `just ci` — formatting, lint, typechecking, unit tests, and builds
passed
- [x] Full pre-push suite — organization, branch-skew, Desktop checks,
typechecking, and tests passed on the latest push
- [x] `cargo test -p buzz-cli` and focused `buzz-sdk` assignment tests
passed
- [x] Focused Tauri recipient-note and 500-result search-limit tests
passed
- [x] Desktop entity-link and issue-assignment unit tests passed
- [x] Playwright smoke coverage passed for assignment, repeated
entity-link navigation, repository create/select flows, section headers
and actions, timestamp alignment, timeline icons, sentence-style
issue/PR metadata, header spacing, avatar contrast, and Inbox metadata
at stacked and side-rail breakpoints
- [ ] Manual staging pass: link round-trips, Channels tab, assignment
flows, and inbox routing

### Screenshots

Pull requests explain who opened the request, where it lives, and which
branch it comes from; fallback avatars remain visually distinct.

![Pull request list with conversational
metadata](https://raw.githubusercontent.com/block/buzz/2a536de86f7e6f79b349d7bc147b2923ff2b817d/pr-5624--05-pr-list-metadata.png)

Issues use the same sentence-style hierarchy while keeping status and
recency easy to scan.

![Issue list with conversational
metadata](https://raw.githubusercontent.com/block/buzz/2a536de86f7e6f79b349d7bc147b2923ff2b817d/pr-5624--06-issue-list-metadata.png)

The wide Inbox detail keeps author, timestamp, and origin context
readable beside its metadata rail.

![Pull request Inbox detail with readable
metadata](https://raw.githubusercontent.com/block/buzz/e65b433e14b97c45365ed7b68ea402ec01d26615/pr-5624--02-pull-request-detail-wide.png)

[View the complete six-state Projects v3 screenshot
set](https://github.com/block/buzz/pull/5624#issuecomment-5268039672)
and [the compact/wide Inbox
comparison](https://github.com/block/buzz/pull/5624#issuecomment-5268614585).


---

> Supersedes #5624, whose head commit accumulated permanently-queued
required check suites (block-dco-check et al.) that GitHub never
dispatched. History flattened into a single signed-off commit on latest
main; tree verified byte-identical (`git merge-tree`) to merging the
original branch into main.

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Co-authored-by: Wintermute <3f1797424fd9ad6653a83665c660517777cd7f8c228c0d5907f49e01537f3ca5@buzz.block.builderlab.xyz>
2026-08-14 16:48:41 -04:00

449 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
buildRepositoryChannelBindingTemplate,
buildProjectPatchTemplate,
buildAddedRepositoryEventTemplatesFromHead,
} from "./projectRepositoryCreation.ts";
import { validateProjectEventEnvelope } from "./projectModels.ts";
const OWNER = "a".repeat(64);
test("buildRepositoryChannelBindingTemplate preserves repository metadata", () => {
const repository = {
id: `${OWNER}:desktop`,
dtag: "desktop",
name: "Desktop",
description: "Desktop app",
owner: OWNER,
createdAt: 1,
repoAddress: `30617:${OWNER}:desktop`,
eventContent: "Desktop app",
eventTags: [
["d", "desktop"],
["name", "Desktop"],
["x-custom", "preserve-me"],
],
};
const template = buildRepositoryChannelBindingTemplate({
channelId: "11111111-1111-4111-8111-111111111111",
ownerPubkey: OWNER,
repository,
});
assert.equal(template.content, "Desktop app");
assert.deepEqual(template.tags, [
["d", "desktop"],
["name", "Desktop"],
["x-custom", "preserve-me"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
]);
});
// ── buildProjectPatchTemplate: unknown-tag preservation ────────────────────
test("buildProjectPatchTemplate preserves unknown tags from the live head", () => {
const OWNER = "a".repeat(64);
const existingAddress = `30617:${OWNER}:desktop`;
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "ignored-by-nip-mp",
tags: [
["d", "platform"],
["name", "Platform"],
["description", "Multi-repo project"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
["future-metadata", "preserve-me"],
["alt", "extension tag that must survive round-trip"],
["a", existingAddress],
],
};
const newAddress = `30617:${OWNER}:mobile`;
const template = buildProjectPatchTemplate({
liveHead,
ownerPubkey: OWNER,
repositoryAddresses: [existingAddress, newAddress],
});
// Content must be preserved verbatim (NIP-MP §content).
assert.equal(template.content, "ignored-by-nip-mp");
// Non-`a` tags — including unknown ones — must survive in order.
const nonMemberTags = template.tags.filter((t) => t[0] !== "a");
assert.deepEqual(nonMemberTags, [
["d", "platform"],
["name", "Platform"],
["description", "Multi-repo project"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
["future-metadata", "preserve-me"],
["alt", "extension tag that must survive round-trip"],
]);
// New address must be added; sorted order maintained.
const memberTags = template.tags.filter((t) => t[0] === "a").map((t) => t[1]);
assert.deepEqual(memberTags.sort(), [existingAddress, newAddress].sort());
});
test("buildProjectPatchTemplate preserves relay hints on existing members", () => {
const OWNER = "a".repeat(64);
const OTHER = "b".repeat(64);
const existingWithHint = `30617:${OTHER}:relay`;
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["a", existingWithHint, "wss://relay.example"],
],
};
const newAddress = `30617:${OWNER}:mobile`;
const template = buildProjectPatchTemplate({
liveHead,
ownerPubkey: OWNER,
repositoryAddresses: [existingWithHint, newAddress],
});
const existingTag = template.tags.find(
(t) => t[0] === "a" && t[1] === existingWithHint,
);
assert.ok(existingTag, "existing member tag must be present");
assert.equal(
existingTag[2],
"wss://relay.example",
"relay hint must be preserved",
);
});
test("buildProjectPatchTemplate rejects a non-owner caller", () => {
const OWNER = "a".repeat(64);
const OTHER = "b".repeat(64);
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [["d", "platform"]],
};
assert.throws(
() =>
buildProjectPatchTemplate({
liveHead,
ownerPubkey: OTHER,
repositoryAddresses: [],
}),
/Only the project owner/,
);
});
test("buildProjectPatchTemplate rejects a repository address list exceeding 64 members", () => {
const OWNER = "a".repeat(64);
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [["d", "wide"]],
};
const tooMany = Array.from(
{ length: 65 },
(_, i) => `30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`,
);
assert.throws(
() =>
buildProjectPatchTemplate({
liveHead,
ownerPubkey: OWNER,
repositoryAddresses: tooMany,
}),
/64/,
);
});
// ── buildAddedRepositoryEventTemplatesFromHead: racing writer ───────────────
test("buildAddedRepositoryEventTemplatesFromHead detects a concurrent add via the live head", () => {
const OWNER = "a".repeat(64);
const existingAddress = `30617:${OWNER}:desktop`;
const newDtag = "mobile";
const newAddress = `30617:${OWNER}:${newDtag}`;
// Live head already contains the address (another session snuck it in).
const liveHeadWithRace = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 150,
content: "",
tags: [
["d", "platform"],
["a", existingAddress],
["a", newAddress], // concurrent add
],
};
assert.throws(
() =>
buildAddedRepositoryEventTemplatesFromHead({
accessChannelId: "11111111-1111-4111-8111-111111111111",
existingRepositoryAddresses: [existingAddress],
liveHead: liveHeadWithRace,
name: "Mobile",
ownerPubkey: OWNER,
}),
/already contains.*mobile.*another session/,
);
});
// ── validateProjectEventEnvelope: shared full-envelope validator ─────────────
// These tests pin the NIP-MP validation rules through the WRITE helper so that
// a nonconforming live head (e.g. from a permissive relay) is caught before
// Desktop signs and re-submits it.
test("validateProjectEventEnvelope accepts a valid minimal envelope", () => {
assert.doesNotThrow(() =>
validateProjectEventEnvelope([["d", "platform"]], ""),
);
});
test("validateProjectEventEnvelope rejects missing d tag", () => {
assert.throws(
() => validateProjectEventEnvelope([["name", "X"]], ""),
/NIP-MP.*'d'/,
);
});
test("validateProjectEventEnvelope rejects duplicate d tags", () => {
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "a"],
["d", "b"],
],
"",
),
/NIP-MP.*'d'/,
);
});
test("validateProjectEventEnvelope rejects duplicate name tags", () => {
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["name", "X"],
["name", "Y"],
],
"",
),
/NIP-MP.*duplicate.*'name'/,
);
});
test("validateProjectEventEnvelope rejects a name tag that exceeds 256 bytes", () => {
const longName = "x".repeat(257);
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["name", longName],
],
"",
),
/NIP-MP.*'name'.*256/,
);
});
test("validateProjectEventEnvelope rejects a description tag that exceeds 2048 bytes", () => {
const longDesc = "x".repeat(2049);
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["description", longDesc],
],
"",
),
/NIP-MP.*'description'.*2048/,
);
});
test("validateProjectEventEnvelope rejects more than 64 a-tags", () => {
const tags = [["d", "wide"]];
for (let i = 0; i < 65; i++) {
tags.push([
"a",
`30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`,
]);
}
assert.throws(() => validateProjectEventEnvelope(tags, ""), /NIP-MP.*64/);
});
test("validateProjectEventEnvelope rejects a member address with uppercase owner hex", () => {
const upperAddr = `30617:${"A".repeat(64)}:desktop`;
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["a", upperAddr],
],
"",
),
/NIP-MP.*invalid.*address/,
);
});
test("validateProjectEventEnvelope rejects duplicate a-tag addresses", () => {
const addr = `30617:${"a".repeat(64)}:desktop`;
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["a", addr],
["a", addr],
],
"",
),
/NIP-MP.*duplicate.*address/,
);
});
test("buildProjectPatchTemplate catches duplicate d in live head via full-envelope validation", () => {
// A relay that accepted a nonconforming event could serve a head with two d tags.
// buildProjectPatchTemplate must catch this before signing.
const badLiveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["d", "extra"],
],
};
assert.throws(
() =>
buildProjectPatchTemplate({
liveHead: badLiveHead,
ownerPubkey: OWNER,
repositoryAddresses: [],
}),
/NIP-MP.*'d'/,
);
});
// ── buildAddedRepositoryEventTemplatesFromHead: partial-publish recovery ────
//
// addRepo publishes two events sequentially (project head, then repository).
// If the repository publish fails after the project head lands, the head
// references a coordinate with no repository event — a dangling member.
// Retry must heal it: when the live head already lists the coordinate but no
// kind-30617 head exists there, the builder returns resume templates instead
// of throwing the "already contains" race error.
test("retry after event 2 fails (event 1 succeeded) returns resume templates that heal the dangling member", () => {
const OWNER = "a".repeat(64);
const existingAddress = `30617:${OWNER}:desktop`;
const newAddress = `30617:${OWNER}:mobile`;
const accessChannelId = "11111111-1111-4111-8111-111111111111";
const preAddHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["buzz-channel", accessChannelId],
["a", existingAddress],
],
};
// Attempt 1: fresh add. Project template gains the address; the repository
// head at the coordinate does not exist yet.
const attempt1 = buildAddedRepositoryEventTemplatesFromHead({
accessChannelId,
existingRepositoryAddresses: [existingAddress],
liveHead: preAddHead,
name: "Mobile",
ownerPubkey: OWNER,
repositoryHeadExists: false,
});
assert.equal(attempt1.resume, false);
assert.deepEqual(
attempt1.project.tags.filter((tag) => tag[0] === "a").map((tag) => tag[1]),
[existingAddress, newAddress],
);
// Event 1 (project head) lands; event 2 (repository) fails. The live head
// now references the coordinate, but no repository head exists.
const danglingHead = {
...preAddHead,
id: "f".repeat(64),
created_at: 101,
tags: [...preAddHead.tags, ["a", newAddress]],
};
// Attempt 2 (retry): must not throw "already contains" — it must resume.
const attempt2 = buildAddedRepositoryEventTemplatesFromHead({
accessChannelId,
existingRepositoryAddresses: [existingAddress, newAddress],
liveHead: danglingHead,
name: "Mobile",
ownerPubkey: OWNER,
repositoryHeadExists: false,
});
assert.equal(attempt2.resume, true);
// The project template must not double-add the coordinate.
assert.deepEqual(
attempt2.project.tags.filter((tag) => tag[0] === "a").map((tag) => tag[1]),
[existingAddress, newAddress],
);
// The repository template is the same missing event the first attempt
// failed to publish.
assert.deepEqual(attempt2.repository, attempt1.repository);
assert.equal(attempt2.repositoryAddress, newAddress);
});
test("a coordinate in the live head WITH a live repository head is still a concurrent-add conflict", () => {
const OWNER = "a".repeat(64);
const newAddress = `30617:${OWNER}:mobile`;
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["a", newAddress],
],
};
assert.throws(
() =>
buildAddedRepositoryEventTemplatesFromHead({
accessChannelId: "11111111-1111-4111-8111-111111111111",
existingRepositoryAddresses: [],
liveHead,
name: "Mobile",
ownerPubkey: OWNER,
repositoryHeadExists: true,
}),
/already contains.*mobile.*another session/,
);
});