Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/buzz-relay/src/api/mesh_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,12 @@ mod tests {
.query_async::<String>(&mut *conn)
.await
.ok()?;
// Keep test leases well above QUIC setup + task-spawn latency under
// full-suite parallelism so the owner lease cannot expire mid-forward
// (#2458). Production default is 30s; tests do not exercise expiry.
Some(SessionDirectory::with_lease_ttl(
pool,
Duration::from_secs(5),
Duration::from_secs(60),
))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const PAGE_BACK_CLASS =
const MODAL_PRIMARY_ACTION_CLASS = `${ONBOARDING_PRIMARY_CTA_CLASS} !text-[rgb(var(--buzz-hosted-community-modal-action-fg))]`;
const MODAL_BACK_ACTION_CLASS =
"h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15";
/** Hosted Builderlab OAuth can hang forever on TLS/network failures (#2484). */
const BUILDERLAB_SIGN_IN_TIMEOUT_MS = 45_000;

type HostedCommunityOnboardingProps = {
onBack: () => void;
Expand Down Expand Up @@ -135,18 +137,35 @@ export function HostedCommunityOnboarding({
const attempt = ++loginAttempt.current;
setAction("Signing in…");
setError(null);
void startBuilderlabLogin()
let timeoutId = 0;
const timeout = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(
new Error(
"Builderlab sign-in timed out. Retry, or go back and connect to an existing / self-hosted relay.",
),
);
}, BUILDERLAB_SIGN_IN_TIMEOUT_MS);
});
void Promise.race([startBuilderlabLogin(), timeout])
.then(async (nextAuth) => {
window.clearTimeout(timeoutId);
if (loginAttempt.current !== attempt) return;
setAuth(nextAuth);
await loadAccount();
})
.catch((cause) => {
window.clearTimeout(timeoutId);
if (loginAttempt.current !== attempt) return;
// Invalidate any late success from the still-running native login.
loginAttempt.current += 1;
setError(cause instanceof Error ? cause.message : String(cause));
void cancelBuilderlabLogin().catch(() => {
// Best-effort cleanup when the browser flow never returns.
});
})
.finally(() => {
if (loginAttempt.current === attempt) setAction(null);
setAction((current) => (current === "Signing in…" ? null : current));
});
};

Expand Down Expand Up @@ -496,18 +515,30 @@ export function HostedCommunityOnboarding({
Waiting for your browser…
</Button>
) : (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
onClick={signIn}
>
Sign in to continue
</Button>
<div className="mt-6 flex w-full flex-col items-stretch gap-2">
<Button
className={MODAL_PRIMARY_ACTION_CLASS}
onClick={signIn}
>
{error ? "Retry sign in" : "Sign in to continue"}
</Button>
{error ? (
<Button
className={MODAL_BACK_ACTION_CLASS}
onClick={cancelSignInAndGoBack}
variant="ghost"
>
Back — use another relay
</Button>
) : null}
</div>
)}
{/* Quiet breadcrumb: Buzz itself is open source; this hosted
relay is the one account-backed piece of the flow. */}
<p className="mt-6 w-full border-t border-foreground/10 pt-4 text-xs leading-5 text-foreground/45">
Buzz is open source. Builderlab hosts the relay for this
account.
account. If hosted setup fails, go back and connect to a
self-hosted or existing relay instead.
</p>
</>
) : !identity ? (
Expand Down
15 changes: 13 additions & 2 deletions desktop/src/features/projects/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";

import { relayClient } from "@/shared/api/relayClient";
import { projectCommentKindAndChannelTags } from "./projectCommentPublish";
import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
import { signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
Expand Down Expand Up @@ -318,13 +319,18 @@ async function createProjectPullRequestComment({
throw new Error("A review commit is required for review comments.");
}

const { kind, channelTags } = projectCommentKindAndChannelTags(
project,
mentionPubkeys,
);
const recipients = new Set([
project.owner.toLowerCase(),
pullRequest.author.toLowerCase(),
...pullRequest.recipients.map((recipient) => recipient.toLowerCase()),
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
]);
const tags = [
...channelTags,
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
Expand All @@ -347,7 +353,7 @@ async function createProjectPullRequestComment({
];

const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
kind,
content: body,
...(decision
? {
Expand Down Expand Up @@ -385,13 +391,18 @@ async function createProjectIssueComment({
throw new Error("Comment cannot be empty.");
}

const { kind, channelTags } = projectCommentKindAndChannelTags(
project,
mentionPubkeys,
);
const recipients = new Set([
project.owner.toLowerCase(),
issue.author.toLowerCase(),
...issue.recipients.map((recipient) => recipient.toLowerCase()),
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
]);
const tags = [
...channelTags,
["e", issue.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
Expand All @@ -400,7 +411,7 @@ async function createProjectIssueComment({
const identity = await getIdentity();

const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
kind,
content: body,
createdAt: nextProjectIssueCommentCreatedAt(
issue,
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/projects/projectCommentPublish.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function projectCommentKindAndChannelTags(
project: {
owner: string;
projectChannelId: string | null;
repoAddress: string;
},
mentionPubkeys: string[],
): { kind: number; channelTags: string[][] };
28 changes: 28 additions & 0 deletions desktop/src/features/projects/projectCommentPublish.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
KIND_STREAM_MESSAGE,
KIND_TEXT_NOTE,
} from "../../shared/constants/kinds.ts";

/**
* Issue/PR comments without agent mentions stay kind:1 (relay has no NIP-22
* kind 1111). Mentions must be kind:9 + channel `h` so buzz-acp's per-channel
* Mentions subscription (#h + #p + kinds [9,…]) can deliver them — see #2462.
*/
export function projectCommentKindAndChannelTags(project, mentionPubkeys) {
if (mentionPubkeys.length === 0) {
return { kind: KIND_TEXT_NOTE, channelTags: [] };
}
const channelId =
typeof project.projectChannelId === "string"
? project.projectChannelId.trim()
: "";
if (!channelId) {
throw new Error(
"This project has no discussion channel. Link a channel before @mentioning agents, or mention them in a channel instead.",
);
}
return {
kind: KIND_STREAM_MESSAGE,
channelTags: [["h", channelId]],
};
}
46 changes: 46 additions & 0 deletions desktop/src/features/projects/projectCommentPublish.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";

import { KIND_STREAM_MESSAGE, KIND_TEXT_NOTE } from "@/shared/constants/kinds";
import { projectCommentKindAndChannelTags } from "./projectCommentPublish.mjs";

test("plain project comments stay kind:1 without a channel tag", () => {
const result = projectCommentKindAndChannelTags(
{
owner: "a".repeat(64),
projectChannelId: "channel-1",
repoAddress: "30617:owner:repo",
},
[],
);
assert.equal(result.kind, KIND_TEXT_NOTE);
assert.deepEqual(result.channelTags, []);
});

test("agent mentions publish as kind:9 with the project channel h tag", () => {
const result = projectCommentKindAndChannelTags(
{
owner: "a".repeat(64),
projectChannelId: "channel-1",
repoAddress: "30617:owner:repo",
},
["b".repeat(64)],
);
assert.equal(result.kind, KIND_STREAM_MESSAGE);
assert.deepEqual(result.channelTags, [["h", "channel-1"]]);
});

test("agent mentions without a project channel fail closed", () => {
assert.throws(
() =>
projectCommentKindAndChannelTags(
{
owner: "a".repeat(64),
projectChannelId: null,
repoAddress: "30617:owner:repo",
},
["b".repeat(64)],
),
/discussion channel/i,
);
});
139 changes: 139 additions & 0 deletions desktop/src/features/projects/projectCommentPublish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { relayClient } from "@/shared/api/relayClient";
import { signRelayEvent } from "@/shared/api/tauri";

import type { ProjectIssue } from "./projectIssues.mjs";
import { projectCommentKindAndChannelTags } from "./projectCommentPublish.mjs";
import type {
ProjectPullRequest,
ProjectPullRequestCommentAnchor,
} from "./projectPullRequests.mjs";
import {
normalizeProjectPullRequestCommentAnchor,
PR_INLINE_COMMENT_LABEL,
} from "./projectPullRequests.mjs";

export { projectCommentKindAndChannelTags } from "./projectCommentPublish.mjs";

type ProjectCommentTarget = {
owner: string;
projectChannelId: string | null;
repoAddress: string;
};

export async function createProjectPullRequestComment({
anchor,
content,
mediaTags,
mentionPubkeys = [],
project,
pullRequest,
}: {
anchor?: ProjectPullRequestCommentAnchor;
content: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
project: ProjectCommentTarget;
pullRequest: ProjectPullRequest;
}): Promise<void> {
const body = content.trim();
if (!body) {
throw new Error("Comment cannot be empty.");
}
const normalizedAnchor = anchor
? normalizeProjectPullRequestCommentAnchor(anchor)
: null;
if (anchor && !normalizedAnchor) {
throw new Error("Comment location is invalid.");
}
if (normalizedAnchor && !pullRequest.commit) {
throw new Error("Pull request commit is required for inline comments.");
}

const { kind, channelTags } = projectCommentKindAndChannelTags(
project,
mentionPubkeys,
);
const recipients = new Set([
project.owner.toLowerCase(),
pullRequest.author.toLowerCase(),
...pullRequest.recipients.map((recipient) => recipient.toLowerCase()),
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
]);
const tags = [
...channelTags,
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
...(normalizedAnchor
? [
["t", PR_INLINE_COMMENT_LABEL],
["c", pullRequest.commit as string],
["file", normalizedAnchor.path],
["side", normalizedAnchor.side],
["line", String(normalizedAnchor.line)],
]
: []),
...(mediaTags ?? []),
];

const event = await signRelayEvent({
kind,
content: body,
tags,
});

await relayClient.publishEvent(
event,
"Timed out posting pull request comment.",
"Failed to post pull request comment.",
);
}

export async function createProjectIssueComment({
content,
mediaTags,
mentionPubkeys = [],
issue,
project,
}: {
content: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
issue: ProjectIssue;
project: ProjectCommentTarget;
}): Promise<void> {
const body = content.trim();
if (!body) {
throw new Error("Comment cannot be empty.");
}

const { kind, channelTags } = projectCommentKindAndChannelTags(
project,
mentionPubkeys,
);
const recipients = new Set([
project.owner.toLowerCase(),
issue.author.toLowerCase(),
...issue.recipients.map((recipient) => recipient.toLowerCase()),
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
]);
const tags = [
...channelTags,
["e", issue.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
...(mediaTags ?? []),
];

const event = await signRelayEvent({
kind,
content: body,
tags,
});

await relayClient.publishEvent(
event,
"Timed out posting issue comment.",
"Failed to post issue comment.",
);
}
Loading