Files
buzz/desktop/src/features/projects/projectIssues.mjs
Thomas Petersen 9ae5e5cd61 feat(desktop): refine repository-aware project workspaces
Make Projects repository-aware across navigation, source management, work items, discussions, and contextual actions while consolidating detail views into consistent single-column workspaces.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-15 21:07:00 -04:00

291 lines
9.4 KiB
JavaScript

import { sortEvents } from "../../shared/api/relayClientShared.ts";
import { projectTaskCategoryFromLabels } from "./projectTaskCategories.ts";
// Issue assignment mirrors PR review requests (projectPullRequests.mjs):
// a kind:1 comment labeled with this `t` tag whose `p` tags are the
// assignees. Labeled text notes stay readable for any client that treats
// them as plain comments, and the `p` tags route the assignment into the
// assignee's mention feed (inbox) for free.
export const ISSUE_ASSIGNMENT_LABEL = "assignment";
export const ISSUE_UNASSIGNMENT_LABEL = "unassignment";
export const PROJECT_ISSUE_STATUS = {
TRIAGE: "Triage",
BACKLOG: "Backlog",
IN_PROGRESS: "In Progress",
IN_REVIEW: "In Review",
DONE: "Done",
CLOSED: "Closed",
};
function isNonEmptyString(value) {
return typeof value === "string" && value.length > 0;
}
export function getTag(event, name) {
const value = event.tags.find((tag) => tag[0] === name)?.[1];
return isNonEmptyString(value) ? value : undefined;
}
export function getAllTags(event, name) {
return event.tags
.filter((tag) => tag[0] === name && isNonEmptyString(tag[1]))
.map((tag) => tag[1]);
}
export function getImetaTags(event) {
return event.tags.filter((tag) => tag[0] === "imeta");
}
function repoOwnerFromAddress(repoAddress) {
const owner = (repoAddress ?? "").split(":")[1] ?? "";
return /^[a-fA-F0-9]{64}$/.test(owner) ? owner.toLowerCase() : null;
}
/**
* Pubkeys allowed to change a root event's lifecycle (status, updates):
* the root author and the owner of the repo the root event targets.
* Anyone else's status/update events are ignored (NIP-34 scopes these
* to the root author or a maintainer).
*/
export function allowedActorsForRoot(rootEvent) {
const allowed = new Set([rootEvent.pubkey.toLowerCase()]);
const owner = repoOwnerFromAddress(getTag(rootEvent, "a"));
if (owner) allowed.add(owner);
return allowed;
}
function latestStatusForIssue(issue, statusEvents) {
const allowedActors = allowedActorsForRoot(issue);
return statusEvents
.filter(
(event) =>
allowedActors.has(event.pubkey.toLowerCase()) &&
event.tags.some((tag) => tag[0] === "e" && tag[1] === issue.id),
)
.sort((left, right) => right.created_at - left.created_at)[0];
}
function statusFromEvent(issue, statusEvent) {
if (statusEvent?.kind === 1631) return PROJECT_ISSUE_STATUS.DONE;
if (statusEvent?.kind === 1632) return PROJECT_ISSUE_STATUS.CLOSED;
// NIP-34 calls 1633 "Draft"; we surface it as Triage for issues. The
// label-based fallbacks below are client-side heuristics, not protocol.
if (statusEvent?.kind === 1633) return PROJECT_ISSUE_STATUS.TRIAGE;
const labels = getAllTags(issue, "t").map((label) => label.toLowerCase());
if (labels.includes("in-review") || labels.includes("review")) {
return PROJECT_ISSUE_STATUS.IN_REVIEW;
}
if (labels.includes("in-progress") || labels.includes("active")) {
return PROJECT_ISSUE_STATUS.IN_PROGRESS;
}
if (labels.includes("triage")) return PROJECT_ISSUE_STATUS.TRIAGE;
return PROJECT_ISSUE_STATUS.BACKLOG;
}
/**
* Assignment state is reduced from trusted kind:1 operations. `t: assignment`
* adds each `p` tag and `t: unassignment` removes it. The issue root's `p`
* tags are notification routing only.
*
* Trusted signers are the issue author and repo owner (who may change anyone),
* plus any community member whose operation names only themselves. Uncaused
* self-service operations are applied first, authoritative operations second,
* and self-service operations that causally reference the current per-assignee
* operation head last. This prevents signer-controlled timestamps from
* overriding authority while allowing a later observed owner/author decision
* to be superseded by the affected assignee.
*/
function assignmentStateForIssue(issue, issueCommentEvents) {
const allowedActors = allowedActorsForRoot(issue);
const assignees = new Set();
const operationHeads = new Map();
const uncausedSelfServiceOperations = [];
const authoritativeOperations = [];
const causalSelfServiceOperations = [];
const events = sortEvents(
issueCommentEvents.filter(
(event) =>
event.kind === 1 &&
event.tags.some((tag) => tag[0] === "e" && tag[1] === issue.id),
),
);
for (const event of events) {
const labels = getAllTags(event, "t");
const isAssignment = labels.includes(ISSUE_ASSIGNMENT_LABEL);
const isUnassignment = labels.includes(ISSUE_UNASSIGNMENT_LABEL);
if (isAssignment === isUnassignment) continue;
const signer = event.pubkey.toLowerCase();
const pubkeys = getAllTags(event, "p").map((pubkey) =>
pubkey.toLowerCase(),
);
const isSelfOperation = pubkeys.length === 1 && pubkeys[0] === signer;
if (!allowedActors.has(signer) && !isSelfOperation) continue;
const operation = {
id: event.id.toLowerCase(),
isAssignment,
pubkeys,
};
if (allowedActors.has(signer)) {
authoritativeOperations.push(operation);
} else {
const priorTags = event.tags.filter((tag) => tag[0] === "prior");
if (priorTags.length === 0) {
uncausedSelfServiceOperations.push(operation);
continue;
}
if (
priorTags.length !== 1 ||
!/^[a-fA-F0-9]{64}$/.test(priorTags[0]?.[1] ?? "")
) {
continue;
}
causalSelfServiceOperations.push({
...operation,
prior: priorTags[0][1].toLowerCase(),
});
}
}
for (const { id, isAssignment, pubkeys, prior } of [
...uncausedSelfServiceOperations,
...authoritativeOperations,
...causalSelfServiceOperations,
]) {
if (prior && operationHeads.get(pubkeys[0]) !== prior) continue;
for (const pubkey of pubkeys) {
if (isAssignment) {
assignees.add(pubkey);
} else {
assignees.delete(pubkey);
}
operationHeads.set(pubkey, id);
}
}
return {
assignees: [...assignees],
heads: Object.fromEntries(operationHeads),
};
}
function commentsForIssue(issueCommentEvents) {
return sortEvents(issueCommentEvents).map((event) => ({
id: event.id,
content: event.content,
tags: getImetaTags(event),
author: event.pubkey,
createdAt: event.created_at,
}));
}
export function eventToProjectIssue(
issue,
statusEvents = [],
commentEvents = [],
) {
const latestStatus = latestStatusForIssue(issue, statusEvents);
const issueCommentEvents = commentEvents.filter((event) =>
event.tags.some(
(tag) => (tag[0] === "e" || tag[0] === "E") && tag[1] === issue.id,
),
);
const comments = commentsForIssue(issueCommentEvents);
const assignmentState = assignmentStateForIssue(issue, issueCommentEvents);
const labels = getAllTags(issue, "t");
const title =
getTag(issue, "subject") || issue.content.split("\n")[0] || "Untitled task";
return {
id: issue.id,
title,
content: issue.content,
tags: getImetaTags(issue),
author: issue.pubkey,
createdAt: issue.created_at,
repoAddress: getTag(issue, "a") ?? null,
channelId: getTag(issue, "h") ?? null,
originAgentName: getTag(issue, "buzz-origin-agent") ?? null,
labels,
category: projectTaskCategoryFromLabels(labels),
recipients: getAllTags(issue, "p"),
assignees: assignmentState.assignees,
assigneeOperationHeads: assignmentState.heads,
status: statusFromEvent(issue, latestStatus),
statusEventId: latestStatus?.id ?? null,
updatedAt:
[
...comments,
...(latestStatus ? [{ createdAt: latestStatus.created_at }] : []),
].sort((left, right) => right.createdAt - left.createdAt)[0]?.createdAt ??
issue.created_at,
comments,
};
}
export function projectIssueEventsToIssues(
issueEvents,
statusEvents = [],
commentEvents = [],
) {
return [...issueEvents]
.map((issue) => eventToProjectIssue(issue, statusEvents, commentEvents))
.sort((left, right) => right.updatedAt - left.updatedAt);
}
/** Keep consecutive comments ordered across whole-second Nostr timestamps. */
export function nextProjectIssueCommentCreatedAt(issue, now, author) {
const normalizedAuthor = author.toLowerCase();
return Math.max(
now,
...issue.comments
.filter((comment) => comment.author.toLowerCase() === normalizedAuthor)
.map((comment) => comment.createdAt + 1),
);
}
export function buildGitIssueTags({
repoAddress,
repoOwner,
title,
labels = [],
}) {
if (!repoAddress.startsWith("30617:")) {
throw new Error("Task repo address must reference a kind:30617 repo.");
}
if (!/^[a-fA-F0-9]{64}$/.test(repoOwner)) {
throw new Error("Repo owner must be 64 hex characters.");
}
const subject = title.trim();
if (!subject) {
throw new Error("Task title is required.");
}
if (subject.length > 256) {
throw new Error("Task title must be 256 characters or fewer.");
}
const tags = [
["a", repoAddress],
["p", repoOwner.toLowerCase()],
["subject", subject],
];
for (const label of labels) {
const trimmed = label.trim();
if (trimmed) tags.push(["t", trimmed]);
}
return tags;
}
export function buildGitStatusTags({ issueId, repoAddress, repoOwner }) {
if (!/^[a-fA-F0-9]{64}$/.test(issueId)) {
throw new Error("Task ID must be 64 hex characters.");
}
const tags = [["e", issueId, "", "root"]];
if (repoAddress) tags.push(["a", repoAddress]);
if (repoOwner && /^[a-fA-F0-9]{64}$/.test(repoOwner)) {
tags.push(["p", repoOwner.toLowerCase()]);
}
return tags;
}