feat(desktop): make pull request reviews actionable (#2510)

This commit is contained in:
thomaspblock
2026-07-24 17:34:25 +02:00
committed by GitHub
parent 9cc9652c7d
commit 9081ab0ec9
32 changed files with 1563 additions and 573 deletions

View File

@@ -17,10 +17,13 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |
Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.
When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation.
## Conversational Agent Creation
When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous.

View File

@@ -31,6 +31,7 @@ pub async fn cmd_open_pr(
euc: Option<&str>,
labels: &[String],
to: &[String],
channel: Option<&str>,
revision_of: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
@@ -44,6 +45,7 @@ pub async fn cmd_open_pr(
let meta = GitPullRequestMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
channel_id: channel.map(str::to_string),
subject: subject.to_string(),
labels: labels.to_vec(),
commit: commit.to_string(),
@@ -227,6 +229,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE
euc,
label,
to,
channel,
revision_of,
} => {
cmd_open_pr(
@@ -243,6 +246,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE
euc.as_deref(),
&label,
&to,
channel.as_deref(),
revision_of.as_deref(),
)
.await

View File

@@ -1338,6 +1338,9 @@ pub enum PrCmd {
/// Additional recipient pubkey(s) — can be specified multiple times
#[arg(long = "to")]
to: Vec<String>,
/// Channel where this pull request originated (NIP-29 h-tag)
#[arg(long)]
channel: Option<String>,
/// Root patch event id this PR revises
#[arg(long)]
revision_of: Option<String>,

View File

@@ -1306,6 +1306,8 @@ pub struct GitPullRequestMeta {
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// NIP-29 channel where the pull request originated (`h` tag).
pub channel_id: Option<String>,
/// PR subject line (`subject` tag) — required, used as the header.
pub subject: String,
/// Labels (`t` tags).
@@ -1365,6 +1367,11 @@ pub fn build_git_pull_request(
tags.push(tag(&["t", label])?);
}
tags.push(tag(&["c", &meta.commit])?);
if let Some(ref channel_id) = meta.channel_id {
let channel_id = Uuid::parse_str(channel_id)
.map_err(|e| SdkError::InvalidInput(format!("channel_id must be a valid UUID: {e}")))?;
tags.push(tag(&["h", &channel_id.to_string()])?);
}
let mut clone_tag = vec!["clone"];
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
tags.push(tag(&clone_tag)?);
@@ -3410,6 +3417,7 @@ mod tests {
clone_urls: vec!["https://example.com/repo.git".to_string()],
branch_name: Some("feat/x".to_string()),
labels: vec!["enhancement".to_string()],
channel_id: Some("11111111-1111-4111-8111-111111111111".to_string()),
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "PR body", &meta).unwrap());
@@ -3420,6 +3428,7 @@ mod tests {
assert!(has_tag(&ev, "subject", "Add feature X"));
assert!(has_tag(&ev, "c", &"c".repeat(40)));
assert!(has_tag(&ev, "t", "enhancement"));
assert!(has_tag(&ev, "h", "11111111-1111-4111-8111-111111111111"));
assert!(has_tag(&ev, "branch-name", "feat/x"));
assert_eq!(
full_clone_tag(&ev),
@@ -3478,6 +3487,34 @@ mod tests {
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_rejects_invalid_channel_id() {
for channel_id in ["not-a-uuid", " 11111111-1111-4111-8111-111111111111 "] {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
channel_id: Some(channel_id.to_string()),
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
}
#[test]
fn git_pr_canonicalizes_channel_id() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
channel_id: Some("AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE".to_string()),
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "body", &meta).unwrap());
assert!(has_tag(&ev, "h", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"));
}
#[test]
fn git_pr_revision_of_emits_e_tag() {
let patch = event_id().to_hex();

View File

@@ -126,6 +126,18 @@ pub struct ProjectPullRequestReviewRequestInput {
reviewer_label: String,
}
/// Repository-scoped metadata for an agent-signed lifecycle status.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestStatusInput {
target_owner: String,
repo_address: String,
pull_request_id: String,
pull_request_author: String,
status: String,
created_at: u64,
}
/// A previously signed merged-status event that needs publishing again.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -249,6 +261,44 @@ fn build_merged_status_event(
.map_err(|error| format!("sign merged pull request status: {error}"))
}
fn build_pull_request_status_event(
keys: &Keys,
repo_address: &str,
pull_request_id: &str,
pull_request_author: &str,
status: &str,
created_at: u64,
) -> Result<String, String> {
let owner = keys.public_key().to_hex();
let (pull_request_id, pull_request_author) =
validate_merge_status_metadata(repo_address, &owner, pull_request_id, pull_request_author)?;
let kind = match status {
"open" => Kind::Custom(1630),
"closed" => Kind::Custom(1632),
"draft" => Kind::Custom(1633),
_ => return Err("Invalid pull request lifecycle status.".to_string()),
};
let mut raw_tags = vec![
vec!["e", pull_request_id.as_str(), "", "root"],
vec!["a", repo_address],
vec!["p", owner.as_str()],
];
if pull_request_author != owner {
raw_tags.push(vec!["p", pull_request_author.as_str()]);
}
let tags = raw_tags
.into_iter()
.map(Tag::parse)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("build pull request status tags: {error}"))?;
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at.max(Timestamp::now().as_secs())))
.sign_with_keys(keys)
.map(|event| event.as_json())
.map_err(|error| format!("sign pull request status: {error}"))
}
fn build_review_request_event(
keys: &Keys,
repo_address: &str,
@@ -433,6 +483,31 @@ pub async fn clone_project_repository(
.map_err(|error| format!("repo clone task failed: {error}"))?
}
#[tauri::command]
pub async fn sign_project_pull_request_status(
input: ProjectPullRequestStatusInput,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let target_owner = input.target_owner.trim().to_ascii_lowercase();
if normalize_event_id(&target_owner).is_none() {
return Err("Invalid target repository owner.".to_string());
}
let identity = project_owner_identity(&app, &state, &target_owner)?;
let event = Event::from_json(build_pull_request_status_event(
&identity.keys,
&input.repo_address,
&input.pull_request_id,
&input.pull_request_author,
&input.status,
input.created_at,
)?)
.map_err(|error| format!("parse signed pull request status: {error}"))?;
submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref())
.await?;
Ok(())
}
#[tauri::command]
pub async fn sign_project_pull_request_review_request(
input: ProjectPullRequestReviewRequestInput,
@@ -663,9 +738,9 @@ pub async fn merge_project_pull_request(
#[cfg(test)]
mod tests {
use super::{
align_unborn_head_branch, build_merged_status_event, build_review_request_event,
classify_merge_error, normalize_commit, same_repository, validate_merge_status_metadata,
ProjectPullRequestMergeError,
align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event,
build_review_request_event, classify_merge_error, normalize_commit, same_repository,
validate_merge_status_metadata, ProjectPullRequestMergeError,
};
use crate::commands::project_git_exec::{build_test_git_auth_config, run_git};
use nostr::{Event, JsonUtil, Keys, Timestamp};
@@ -834,6 +909,49 @@ mod tests {
.is_err());
}
#[test]
fn lifecycle_status_is_signed_by_repository_owner() {
let keys = Keys::generate();
let owner = keys.public_key().to_hex();
let author = "b".repeat(64);
let event = Event::from_json(
build_pull_request_status_event(
&keys,
&format!("30617:{owner}:buzz"),
&"d".repeat(64),
&author,
"closed",
Timestamp::now().as_secs(),
)
.unwrap(),
)
.unwrap();
assert_eq!(event.pubkey, keys.public_key());
assert_eq!(event.kind.as_u16(), 1632);
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["p", author.as_str()]));
assert!(event.verify().is_ok());
}
#[test]
fn lifecycle_status_rejects_merged_alias() {
let keys = Keys::generate();
let owner = keys.public_key().to_hex();
assert!(build_pull_request_status_event(
&keys,
&format!("30617:{owner}:buzz"),
&"d".repeat(64),
&"b".repeat(64),
"merged",
Timestamp::now().as_secs(),
)
.is_err());
}
#[test]
fn review_request_is_signed_by_repository_owner() {
let keys = Keys::generate();

View File

@@ -683,6 +683,7 @@ pub fn run() {
delete_project_remote_branch,
push_project_local_repository,
pull_project_local_repository,
sign_project_pull_request_status,
sign_project_pull_request_review_request,
publish_project_pull_request_merged_status,
merge_project_pull_request,

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { EditorContent } from "@tiptap/react";
import { ChevronDown } from "lucide-react";
import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
@@ -20,6 +21,13 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import type { ForumComposerProps } from "./ForumComposer.types";
import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes";
import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout";
@@ -35,7 +43,9 @@ export function ForumComposer({
header,
isSending,
onCancel,
onSecondarySubmit,
onSubmit,
secondarySubmitLabel,
compact = false,
autocompleteBelow = false,
profiles,
@@ -47,6 +57,9 @@ export function ForumComposer({
const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [submitMode, setSubmitMode] = React.useState<"primary" | "secondary">(
"primary",
);
const handleFormattingToggle = React.useCallback((pressed: boolean) => {
if (pressed) setIsEmojiPickerOpen(false);
@@ -70,10 +83,14 @@ export function ForumComposer({
const isSendingRef = React.useRef(isSending);
const isUploadingRef = React.useRef(media.isUploading);
const onSubmitRef = React.useRef(onSubmit);
const onSecondarySubmitRef = React.useRef(onSecondarySubmit);
const submitModeRef = React.useRef(submitMode);
disabledRef.current = disabled;
isSendingRef.current = isSending;
isUploadingRef.current = media.isUploading;
onSubmitRef.current = onSubmit;
onSecondarySubmitRef.current = onSecondarySubmit;
submitModeRef.current = onSecondarySubmit ? submitMode : "primary";
const isAutocompleteOpenRef = React.useRef(false);
isAutocompleteOpenRef.current =
@@ -193,78 +210,90 @@ export function ForumComposer({
]);
// ── Submit ──────────────────────────────────────────────────────────
const submitMessage = React.useCallback(() => {
const trimmed = contentRef.current.trim();
const currentPendingImeta = media.pendingImetaRef.current;
const hasMedia = currentPendingImeta.length > 0;
const submitMessage = React.useCallback(
(submitter = onSubmitRef.current) => {
const trimmed = contentRef.current.trim();
const currentPendingImeta = media.pendingImetaRef.current;
const hasMedia = currentPendingImeta.length > 0;
if (
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current ||
isUploadingRef.current
) {
return;
}
if (
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current ||
isUploadingRef.current
) {
return;
}
const pubkeys = mentions.extractMentionPubkeys(trimmed);
const pubkeys = mentions.extractMentionPubkeys(trimmed);
// Reuse the shared send-path builder so forum/notes posts emit the same
// body + imeta as chat: generic files become `[filename](url)` links with a
// `filename` imeta tag (FileCard renderer), images/video stay inline. Send
// semantics use `undefined` for "no attachments" (no imeta tags emitted).
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
currentPendingImeta,
// Reuse the shared send-path builder so forum/notes posts emit the same
// body + imeta as chat: generic files become `[filename](url)` links with a
// `filename` imeta tag (FileCard renderer), images/video stay inline. Send
// semantics use `undefined` for "no attachments" (no imeta tags emitted).
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
currentPendingImeta,
);
// Save draft state so we can restore on failure.
const savedContent = contentRef.current;
const savedImeta = [...currentPendingImeta];
setContent("");
contentRef.current = "";
richText.clearContent();
media.setPendingImeta([]);
mentions.clearMentions();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
const result = submitter(finalContent, pubkeys, mediaTags);
const completeSubmission = () => {
setSubmitMode("primary");
if (compact) setIsCompactExpanded(false);
};
// If onSubmit returns a promise, restore draft on failure.
if (result && typeof result.then === "function") {
result.then(completeSubmission).catch(() => {
setContent(savedContent);
contentRef.current = savedContent;
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
if (compact) setIsCompactExpanded(true);
});
} else {
completeSubmission();
}
},
[
compact,
media.pendingImetaRef,
media.setPendingImeta,
mentions.extractMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
richText.clearContent,
richText.setContent,
],
);
const submitSelectedMessage = React.useCallback(() => {
const secondarySubmit = onSecondarySubmitRef.current;
submitMessage(
submitModeRef.current === "secondary" && secondarySubmit
? secondarySubmit
: onSubmitRef.current,
);
// Save draft state so we can restore on failure.
const savedContent = contentRef.current;
const savedImeta = [...currentPendingImeta];
setContent("");
contentRef.current = "";
richText.clearContent();
media.setPendingImeta([]);
mentions.clearMentions();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
const result = onSubmitRef.current(finalContent, pubkeys, mediaTags);
const collapseCompactComposer = () => {
if (compact) setIsCompactExpanded(false);
};
// If onSubmit returns a promise, restore draft on failure.
if (result && typeof result.then === "function") {
result.then(collapseCompactComposer).catch(() => {
setContent(savedContent);
contentRef.current = savedContent;
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
if (compact) setIsCompactExpanded(true);
});
} else {
collapseCompactComposer();
}
}, [
compact,
media.pendingImetaRef,
media.setPendingImeta,
mentions.extractMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
richText.clearContent,
richText.setContent,
]);
submitMessageRef.current = submitMessage;
}, [submitMessage]);
submitMessageRef.current = submitSelectedMessage;
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
submitMessage();
submitSelectedMessage();
},
[submitMessage],
[submitSelectedMessage],
);
// ── Keyboard handling ───────────────────────────────────────────────
@@ -479,16 +508,56 @@ export function ForumComposer({
composerDisabled={disabled ?? false}
editor={richText.editor}
extraActions={
onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
onCancel || (onSecondarySubmit && secondarySubmitLabel) ? (
<>
{onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
) : null}
{onSecondarySubmit && secondarySubmitLabel ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={cn(
submitMode === "secondary" &&
"border-amber-500/40 text-amber-700 hover:bg-amber-500/10 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300",
)}
disabled={disabled || isSending}
size="sm"
type="button"
variant="outline"
>
{submitMode === "secondary"
? secondarySubmitLabel
: "Comment"}
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
onValueChange={(value) =>
setSubmitMode(value as "primary" | "secondary")
}
value={submitMode}
>
<DropdownMenuRadioItem value="primary">
Comment
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="secondary">
{secondarySubmitLabel}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
) : undefined
}
formattingDisabled={disabled ?? false}

View File

@@ -18,6 +18,13 @@ export type ForumComposerProps = {
mentionPubkeys: string[],
mediaTags?: string[][],
) => undefined | Promise<unknown>;
/** Optional alternate submission using the same composed content. */
onSecondarySubmit?: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => undefined | Promise<unknown>;
secondarySubmitLabel?: string;
/** Render as a single-line composer until the user focuses it. */
compact?: boolean;
/** When true, autocomplete renders below the input (for top-of-view composers). */

View File

@@ -48,7 +48,9 @@ import type {
ProjectPullRequestCommentAnchor,
} from "./projectPullRequests.mjs";
import {
nextProjectPullRequestReviewCreatedAt,
normalizeProjectPullRequestCommentAnchor,
PR_CHANGES_REQUESTED_LABEL,
PR_INLINE_COMMENT_LABEL,
projectPullRequestEventsToPullRequests,
} from "./projectPullRequests.mjs";
@@ -60,6 +62,8 @@ export type {
ProjectPullRequestCommentAnchor,
};
export type ProjectPullRequestCommentDecision = "request-changes";
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
export type Project = {
@@ -439,6 +443,7 @@ async function fetchProjectPullRequests(
async function createProjectPullRequestComment({
anchor,
content,
decision,
mediaTags,
mentionPubkeys = [],
project,
@@ -446,6 +451,7 @@ async function createProjectPullRequestComment({
}: {
anchor?: ProjectPullRequestCommentAnchor;
content: string;
decision?: ProjectPullRequestCommentDecision;
mediaTags?: string[][];
mentionPubkeys?: string[];
project: Project;
@@ -461,8 +467,8 @@ async function createProjectPullRequestComment({
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.");
if ((normalizedAnchor || decision) && !pullRequest.commit) {
throw new Error("Pull request commit is required for review comments.");
}
const recipients = new Set([
@@ -484,12 +490,26 @@ async function createProjectPullRequestComment({
["line", String(normalizedAnchor.line)],
]
: []),
...(decision
? [
["t", PR_CHANGES_REQUESTED_LABEL],
...(!normalizedAnchor ? [["c", pullRequest.commit as string]] : []),
]
: []),
...(mediaTags ?? []),
];
const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
content: body,
...(decision
? {
createdAt: nextProjectPullRequestReviewCreatedAt(
pullRequest,
Math.floor(Date.now() / 1_000),
),
}
: {}),
tags,
});
@@ -907,12 +927,14 @@ export function useCreateProjectPullRequestCommentMutation(
mutationFn: ({
anchor,
content,
decision,
mediaTags,
mentionPubkeys,
pullRequest,
}: {
anchor?: ProjectPullRequestCommentAnchor;
content: string;
decision?: ProjectPullRequestCommentDecision;
mediaTags?: string[][];
mentionPubkeys?: string[];
pullRequest: ProjectPullRequest;
@@ -921,6 +943,7 @@ export function useCreateProjectPullRequestCommentMutation(
return createProjectPullRequestComment({
anchor,
content,
decision,
mediaTags,
mentionPubkeys,
project,

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { relativeTime } from "./projectsViewHelpers.ts";
const DAY_SECONDS = 24 * 60 * 60;
function localSeconds(year, month, day) {
return Math.floor(new Date(year, month, day, 12).getTime() / 1_000);
}
test("relativeTime switches to an absolute date at seven days", () => {
const now = localSeconds(2025, 5, 15);
assert.equal(relativeTime(now - 7 * DAY_SECONDS + 1, now), "6 days ago");
const createdAt = now - 7 * DAY_SECONDS;
const expected = new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
assert.equal(relativeTime(createdAt, now), expected);
});
test("relativeTime includes the year only across a year boundary", () => {
const sameYearNow = localSeconds(2025, 5, 15);
const sameYearCreatedAt = sameYearNow - 7 * DAY_SECONDS;
const sameYearExpected = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
}).format(new Date(sameYearCreatedAt * 1_000));
assert.equal(relativeTime(sameYearCreatedAt, sameYearNow), sameYearExpected);
const crossYearNow = localSeconds(2025, 0, 8);
const crossYearCreatedAt = localSeconds(2024, 11, 31);
const crossYearExpected = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(crossYearCreatedAt * 1_000));
assert.equal(
relativeTime(crossYearCreatedAt, crossYearNow),
crossYearExpected,
);
});

View File

@@ -191,21 +191,30 @@ export function formatCreatedDate(createdAt: number) {
});
}
export function relativeTime(createdAt: number) {
const elapsedSeconds = Math.max(
1,
Math.floor(Date.now() / 1_000 - createdAt),
);
export function relativeTime(
createdAt: number,
nowSeconds = Math.floor(Date.now() / 1_000),
) {
const elapsedSeconds = Math.max(1, Math.floor(nowSeconds - createdAt));
const units = [
{ label: "year", seconds: 365 * 24 * 60 * 60 },
{ label: "month", seconds: 30 * 24 * 60 * 60 },
{ label: "week", seconds: 7 * 24 * 60 * 60 },
{ label: "day", seconds: 24 * 60 * 60 },
{ label: "hour", seconds: 60 * 60 },
{ label: "minute", seconds: 60 },
{ label: "second", seconds: 1 },
];
if (elapsedSeconds >= 7 * 24 * 60 * 60) {
const createdDate = new Date(createdAt * 1_000);
const nowDate = new Date(nowSeconds * 1_000);
return createdDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
...(createdDate.getFullYear() === nowDate.getFullYear()
? {}
: { year: "numeric" }),
});
}
for (const unit of units) {
const value = Math.floor(elapsedSeconds / unit.seconds);
if (value >= 1) {

View File

@@ -73,6 +73,8 @@ export type ProjectPullRequest = {
author: string;
createdAt: number;
repoAddress: string | null;
/** Channel where the pull request originated (`h` tag), when provided. */
channelId: string | null;
labels: string[];
recipients: string[];
/** Requested reviewers (root `p` tags + trusted review-request comments). */

View File

@@ -180,7 +180,7 @@ function eventToPullRequestComment(event) {
const parsedLine =
lineTag && /^[1-9]\d*$/.test(lineTag) ? Number(lineTag) : Number.NaN;
const anchor =
isReviewRequest || isApproval || isChangeRequest
isReviewRequest || isApproval
? null
: normalizeProjectPullRequestCommentAnchor({
line: parsedLine,
@@ -355,6 +355,7 @@ export function eventToProjectPullRequest(
author: pullRequest.pubkey,
createdAt: pullRequest.created_at,
repoAddress: getTag(pullRequest, "a") ?? null,
channelId: getTag(pullRequest, "h") ?? null,
labels: getAllTags(pullRequest, "t"),
recipients: getAllTags(pullRequest, "p"),
reviewers,

View File

@@ -41,6 +41,14 @@ test("reads source and target branches from the pull request", () => {
assert.equal(pullRequest.targetBranch, "release");
});
test("preserves an optional source channel from the pull request", () => {
const event = pullRequestEvent();
event.tags.push(["h", "source-channel-id"]);
assert.equal(eventToProjectPullRequest(event).channelId, "source-channel-id");
assert.equal(eventToProjectPullRequest(pullRequestEvent()).channelId, null);
});
function updateEvent({ pubkey, createdAt, commit, cloneUrl }) {
return {
id: `update-${pubkey.slice(0, 8)}-${createdAt}`,
@@ -234,6 +242,37 @@ test("parses commit-scoped inline comment anchors", () => {
assert.equal(pullRequest.comments[0].isInlineComment, true);
});
test("keeps anchors on comment-backed change requests", () => {
const commit = "1111111111111111111111111111111111111111";
const comment = commentEvent({
pubkey: OWNER,
createdAt: 150,
content: "Please handle the empty state before merging.",
labels: ["changes-requested"],
commit,
anchor: { path: "src/example.ts", side: "new", line: 12 },
});
const pullRequest = eventToProjectPullRequest(
pullRequestEvent(),
[],
[comment],
);
const parsedComment = pullRequest.comments[0];
assert.deepEqual(parsedComment.anchor, {
path: "src/example.ts",
side: "new",
line: 12,
});
assert.equal(parsedComment.isTrustedReviewDecision, true);
assert.equal(
projectPullRequestCommentTimelineKind(parsedComment),
"changes-requested",
);
assert.equal(pullRequest.changeRequests.length, 1);
});
test("marks inline comments on earlier commits as outdated", () => {
const initialCommit = "1111111111111111111111111111111111111111";
const currentCommit = "2222222222222222222222222222222222222222";

View File

@@ -1,9 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { signProjectPullRequestReviewRequest } from "@/shared/api/projectGit";
import {
signProjectPullRequestReviewRequest,
signProjectPullRequestStatus,
} from "@/shared/api/projectGit";
import { relayClient } from "@/shared/api/relayClient";
import { signRelayEvent } from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
@@ -40,12 +44,29 @@ const PR_STATUS_KIND_BY_LIFECYCLE: Record<
async function updateProjectPullRequestStatus({
project,
pullRequest,
signAsManagedOwner,
status,
}: {
project: Project;
pullRequest: ProjectPullRequest;
signAsManagedOwner: boolean;
status: ProjectPullRequestLifecycleStatus;
}): Promise<void> {
const createdAt = nextProjectPullRequestStatusCreatedAt(
pullRequest,
Math.floor(Date.now() / 1_000),
);
if (signAsManagedOwner) {
await signProjectPullRequestStatus({
targetOwner: project.owner,
repoAddress: project.repoAddress,
pullRequestId: pullRequest.id,
pullRequestAuthor: pullRequest.author,
status,
createdAt,
});
return;
}
const recipients = new Set([
project.owner.toLowerCase(),
pullRequest.author.toLowerCase(),
@@ -53,10 +74,7 @@ async function updateProjectPullRequestStatus({
const event = await signRelayEvent({
kind: PR_STATUS_KIND_BY_LIFECYCLE[status],
content: "",
createdAt: nextProjectPullRequestStatusCreatedAt(
pullRequest,
Math.floor(Date.now() / 1_000),
),
createdAt,
tags: [
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
@@ -125,6 +143,29 @@ async function requestProjectPullRequestReview({
type ProjectPullRequestReviewDecision = "approve" | "request-changes";
/** Whether a viewer may submit a review decision for this pull request. */
export function canReviewProjectPullRequest(
project: Project,
pullRequest: ProjectPullRequest,
viewerPubkey: string | null | undefined,
) {
if (
!viewerPubkey ||
!pullRequest.commit ||
(pullRequest.status !== "Open" && pullRequest.status !== "Draft")
) {
return false;
}
const viewer = normalizePubkey(viewerPubkey);
if (viewer === normalizePubkey(pullRequest.author)) return false;
return (
viewer === normalizePubkey(project.owner) ||
pullRequest.reviewers.some(
(reviewer) => normalizePubkey(reviewer) === viewer,
)
);
}
const REVIEW_DECISION_DETAILS: Record<
ProjectPullRequestReviewDecision,
{
@@ -149,11 +190,13 @@ const REVIEW_DECISION_DETAILS: Record<
};
async function submitProjectPullRequestReview({
content,
createdAt,
decision,
project,
pullRequest,
}: {
content?: string;
createdAt: number;
decision: ProjectPullRequestReviewDecision;
project: Project;
@@ -169,7 +212,7 @@ async function submitProjectPullRequestReview({
]);
const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
content: details.content,
content: content?.trim() || details.content,
createdAt,
tags: [
["e", pullRequest.id, "", "root"],
@@ -212,13 +255,20 @@ export function useUpdateProjectPullRequestStatusMutation(
return useMutation({
mutationFn: ({
pullRequest,
signAsManagedOwner = false,
status,
}: {
pullRequest: ProjectPullRequest;
signAsManagedOwner?: boolean;
status: ProjectPullRequestLifecycleStatus;
}) => {
if (!project) throw new Error("No project selected.");
return updateProjectPullRequestStatus({ project, pullRequest, status });
return updateProjectPullRequestStatus({
project,
pullRequest,
signAsManagedOwner,
status,
});
},
onSuccess: invalidate,
});
@@ -262,14 +312,17 @@ function useProjectPullRequestReviewDecisionMutation(
return useMutation({
mutationFn: ({
content,
createdAt,
pullRequest,
}: {
content?: string;
createdAt: number;
pullRequest: ProjectPullRequest;
}) => {
if (!project) throw new Error("No project selected.");
return submitProjectPullRequestReview({
content,
createdAt,
decision,
project,

View File

@@ -178,7 +178,7 @@ export function MergePullRequestButton({
<div className="contents">
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
<Button
className="h-8 gap-1.5 bg-purple-600 px-3.5 text-white shadow-sm hover:bg-purple-700"
className="h-8 gap-1.5 px-3.5"
disabled={mergeMutation.isPending || publishMergedMutation.isPending}
onClick={() => {
if (unpublishedStatusEvent) {

View File

@@ -10,6 +10,7 @@ import type {
ProjectRepoContributor,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectRepoCommit } from "@/shared/api/types";
import {
resolveUserLabel,
@@ -26,32 +27,6 @@ import {
} from "./ProjectFeedRow";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
function compactDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function relativeCommitTime(createdAt: number) {
const elapsedSeconds = Math.max(
1,
Math.floor(Date.now() / 1_000 - createdAt),
);
const units = [
{ label: "year", seconds: 365 * 24 * 60 * 60 },
{ label: "month", seconds: 30 * 24 * 60 * 60 },
{ label: "day", seconds: 24 * 60 * 60 },
{ label: "hour", seconds: 60 * 60 },
{ label: "min", seconds: 60 },
];
const unit =
units.find((item) => elapsedSeconds >= item.seconds) ??
units[units.length - 1];
const value = Math.max(1, Math.floor(elapsedSeconds / unit.seconds));
return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`;
}
function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
@@ -130,7 +105,7 @@ export function ContributorsPanel({
{row.lastCommitAt ? (
<>
<span>·</span>
<span>updated {compactDate(row.lastCommitAt)}</span>
<span>updated {relativeTime(row.lastCommitAt)}</span>
</>
) : null}
</div>
@@ -257,7 +232,7 @@ export function ActivityPanel({
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
title={new Date(commit.timestamp * 1_000).toLocaleString()}
>
{relativeCommitTime(commit.timestamp)}
{relativeTime(commit.timestamp)}
</span>
<ProjectFeedRowCluster>
<ProjectFeedRowMonoCell

View File

@@ -5,8 +5,8 @@ import {
GitCommitHorizontal,
GitPullRequest,
MessageSquare,
TriangleAlert,
UserPlus,
X,
} from "lucide-react";
import type { ComponentType } from "react";
@@ -73,10 +73,11 @@ export const PROJECT_EVENT_VISUALS: Record<
"border-green-600/30 text-green-700 dark:border-green-500/30 dark:text-green-400",
},
"changes-requested": {
icon: X,
iconClassName: "text-destructive",
badgeClassName: "bg-destructive/10 text-destructive",
detailClassName: "border-destructive/40 text-destructive",
icon: TriangleAlert,
iconClassName: "text-amber-600 dark:text-amber-400",
badgeClassName:
"bg-amber-500/10 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300",
detailClassName: "border-amber-500/40 text-amber-700 dark:text-amber-400",
},
"review-request": {
icon: UserPlus,

View File

@@ -25,13 +25,6 @@ import {
import { OverviewRailSection } from "./ProjectOverviewPanel";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
function compactDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
export function issueStatusClassName(status: ProjectIssue["status"]) {
if (status === "Done") return "text-purple-400";
if (status === "Closed") return "text-destructive";
@@ -242,7 +235,7 @@ function IssueDetail({
<AuthorIdentity
profiles={profiles}
pubkey={item.author}
role={compactDate(item.createdAt)}
role={relativeTime(item.createdAt)}
/>
</div>
<Markdown
@@ -325,13 +318,13 @@ function IssueMetaRail({
<div className="flex items-center justify-between gap-3">
<dt>Created</dt>
<dd className="font-medium text-foreground">
{compactDate(issue.createdAt)}
{relativeTime(issue.createdAt)}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt>Updated</dt>
<dd className="font-medium text-foreground">
{compactDate(issue.updatedAt)}
{relativeTime(issue.updatedAt)}
</dd>
</div>
</dl>

View File

@@ -33,8 +33,10 @@ import {
type ProjectPullRequestCommentAnchor,
useCreateProjectPullRequestCommentMutation,
} from "@/features/projects/hooks";
import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews";
import type { ProjectPullRequestComment } from "@/features/projects/projectPullRequests.mjs";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { cn } from "@/shared/lib/cn";
import type { ProjectRepoDiff, ProjectRepoDiffFile } from "@/shared/api/types";
import { ProjectPullRequestInlineCommentThread } from "./ProjectPullRequestInlineComments";
@@ -59,6 +61,7 @@ type DiffRow = {
type InlineCommentControls = {
activeAnchor: ProjectPullRequestCommentAnchor | null;
canRequestChanges: boolean;
comments: ProjectPullRequestComment[];
isSending: boolean;
onCancel: () => void;
@@ -68,6 +71,7 @@ type InlineCommentControls = {
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
decision?: "request-changes",
) => Promise<unknown>;
profiles?: UserProfileLookup;
};
@@ -440,12 +444,50 @@ function errorMessage(error: unknown) {
function DiffPreview({
file,
focusedAnchor,
inlineComments,
}: {
file: ProjectRepoDiffFile;
focusedAnchor?: ProjectPullRequestCommentAnchor | null;
inlineComments?: InlineCommentControls;
}) {
const rows = diffRows(file);
const focusedRowRef = React.useRef<HTMLDivElement | null>(null);
const [highlightedAnchor, setHighlightedAnchor] =
React.useState<ProjectPullRequestCommentAnchor | null>(null);
React.useEffect(() => {
if (!focusedAnchor || focusedAnchor.path !== file.path) {
setHighlightedAnchor(null);
return;
}
setHighlightedAnchor(focusedAnchor);
let isListeningForInteraction = false;
const clearHighlight = () => setHighlightedAnchor(null);
const clearHighlightOnKeyDown = (event: KeyboardEvent) => {
if (["Alt", "Control", "Meta", "Shift"].includes(event.key)) return;
clearHighlight();
};
const frame = window.requestAnimationFrame(() => {
focusedRowRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
focusedRowRef.current?.focus({ preventScroll: true });
window.addEventListener("pointerdown", clearHighlight, true);
window.addEventListener("keydown", clearHighlightOnKeyDown, true);
isListeningForInteraction = true;
});
return () => {
window.cancelAnimationFrame(frame);
if (isListeningForInteraction) {
window.removeEventListener("pointerdown", clearHighlight, true);
window.removeEventListener("keydown", clearHighlightOnKeyDown, true);
}
};
}, [file.path, focusedAnchor]);
if (rows.length === 0) {
return (
<div className="bg-muted/20 px-4 py-4 text-sm text-muted-foreground">
@@ -475,17 +517,27 @@ function DiffPreview({
inlineComments?.activeAnchor ?? null,
anchor,
);
const isFocused = anchorsEqual(highlightedAnchor, anchor);
return (
<React.Fragment key={row.key}>
<div
className={cn(
"group grid min-h-5 grid-cols-[3rem_3rem_2rem_1.5rem_minmax(0,1fr)]",
diffLineClassName(row.type),
isFocused && "bg-primary/10 ring-1 ring-primary/40 ring-inset",
)}
data-line={anchor?.line}
data-path={anchor?.path}
data-side={anchor?.side}
data-testid={anchor ? "project-diff-line" : undefined}
data-testid={
isFocused
? "project-diff-focused-line"
: anchor
? "project-diff-line"
: undefined
}
ref={isFocused ? focusedRowRef : undefined}
tabIndex={isFocused ? -1 : undefined}
>
<span className="select-none border-border/40 border-r px-2 text-right text-muted-foreground/70">
{row.oldLine ?? " "}
@@ -526,15 +578,17 @@ function DiffPreview({
{anchor && inlineComments ? (
<ProjectPullRequestInlineCommentThread
activeAnchor={isActive ? anchor : null}
canRequestChanges={inlineComments.canRequestChanges}
comments={comments}
isSending={inlineComments.isSending}
onCancel={inlineComments.onCancel}
onSubmit={(content, mentionPubkeys, mediaTags) =>
onSubmit={(content, mentionPubkeys, mediaTags, decision) =>
inlineComments.onSubmit(
anchor,
content,
mentionPubkeys,
mediaTags,
decision,
)
}
profiles={inlineComments.profiles}
@@ -600,6 +654,7 @@ function FileTreeItems({
export function ProjectPullRequestFilesChangedPanel({
error,
diff,
focusedAnchor,
isLoading,
profiles,
project,
@@ -607,11 +662,13 @@ export function ProjectPullRequestFilesChangedPanel({
}: {
error: unknown;
diff: ProjectRepoDiff | null | undefined;
focusedAnchor?: ProjectPullRequestCommentAnchor | null;
isLoading: boolean;
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest | null;
}) {
const identityQuery = useIdentityQuery();
const [activeAnchor, setActiveAnchor] =
React.useState<ProjectPullRequestCommentAnchor | null>(null);
const { isPending: isPostingComment, mutateAsync: postComment } =
@@ -630,18 +687,24 @@ export function ProjectPullRequestFilesChangedPanel({
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
decision?: "request-changes",
) => {
if (!pullRequest) throw new Error("No pull request selected.");
try {
await postComment({
anchor,
content,
decision,
mediaTags,
mentionPubkeys,
pullRequest,
});
setActiveAnchor(null);
toast.success("Line comment posted.");
toast.success(
decision === "request-changes"
? "Changes requested."
: "Line comment posted.",
);
} catch (error) {
toast.error(
error instanceof Error
@@ -659,6 +722,7 @@ export function ProjectPullRequestFilesChangedPanel({
diff={pullRequest ? diff : null}
embedded
error={error}
focusedAnchor={focusedAnchor}
headerLabel={
pullRequest
? `${pullRequest.title} · ${pullRequest.commit?.slice(0, 7) ?? "PR"}`
@@ -668,6 +732,11 @@ export function ProjectPullRequestFilesChangedPanel({
pullRequest
? {
activeAnchor,
canRequestChanges: canReviewProjectPullRequest(
project,
pullRequest,
identityQuery.data?.pubkey,
),
comments: inlineComments,
isSending: isPostingComment,
onCancel: () => setActiveAnchor(null),
@@ -688,6 +757,7 @@ export function ProjectDiffFilesPanel({
diff,
isLoading,
embedded = false,
focusedAnchor,
headerLabel,
inlineComments,
subjectLabel,
@@ -697,6 +767,7 @@ export function ProjectDiffFilesPanel({
isLoading: boolean;
/** Render without an outer border, for nesting inside an existing card. */
embedded?: boolean;
focusedAnchor?: ProjectPullRequestCommentAnchor | null;
headerLabel: string;
inlineComments?: InlineCommentControls;
subjectLabel: string;
@@ -724,6 +795,12 @@ export function ProjectDiffFilesPanel({
filteredFiles[0] ??
null;
React.useEffect(() => {
if (!focusedAnchor) return;
setQuery("");
setSelectedPath(focusedAnchor.path);
}, [focusedAnchor]);
React.useEffect(() => {
if (filteredFiles.length === 0) {
setSelectedPath(null);
@@ -854,6 +931,7 @@ export function ProjectDiffFilesPanel({
</header>
<DiffPreview
file={selectedFile}
focusedAnchor={focusedAnchor}
inlineComments={inlineComments}
/>
</article>

View File

@@ -5,6 +5,7 @@ import type {
ProjectPullRequestComment,
ProjectPullRequestCommentAnchor,
} from "@/features/projects/projectPullRequests.mjs";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
@@ -21,22 +22,17 @@ function commentAuthor(
);
}
function commentDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
}
export function ProjectPullRequestInlineCommentThread({
activeAnchor,
comments,
canRequestChanges,
isSending,
onCancel,
onSubmit,
profiles,
}: {
activeAnchor: ProjectPullRequestCommentAnchor | null;
canRequestChanges: boolean;
comments: ProjectPullRequestComment[];
isSending: boolean;
onCancel: () => void;
@@ -44,6 +40,7 @@ export function ProjectPullRequestInlineCommentThread({
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
decision?: "request-changes",
) => Promise<unknown>;
profiles?: UserProfileLookup;
}) {
@@ -68,7 +65,7 @@ export function ProjectPullRequestInlineCommentThread({
{commentAuthor(comment.author, profiles)}
</span>
<span className="shrink-0 text-muted-foreground">
{commentDate(comment.createdAt)}
{relativeTime(comment.createdAt)}
</span>
</div>
<Markdown
@@ -96,9 +93,21 @@ export function ProjectPullRequestInlineCommentThread({
}
isSending={isSending}
onCancel={onCancel}
onSecondarySubmit={
canRequestChanges
? (content, mentionPubkeys, mediaTags) =>
onSubmit(
content,
mentionPubkeys,
mediaTags,
"request-changes",
)
: undefined
}
onSubmit={onSubmit}
placeholder="Leave a comment on this line…"
profiles={profiles}
secondarySubmitLabel="Request changes"
/>
) : null}
</div>

View File

@@ -1,26 +1,37 @@
import {
Check,
ChevronDown,
ChevronUp,
FileCode2,
GitBranch,
GitCommitHorizontal,
GitMerge,
GitPullRequest,
History,
MessageSquare,
TriangleAlert,
UserPlus,
X,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import { useChannelsQuery } from "@/features/channels/hooks";
import { ForumComposer } from "@/features/forum/ui/ForumComposer";
import {
type Project,
type ProjectPullRequest,
type ProjectPullRequestCommentAnchor,
useCreateProjectPullRequestCommentMutation,
} from "@/features/projects/hooks";
import { projectPullRequestCommentTimelineKind } from "@/features/projects/projectPullRequests.mjs";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import {
formatExactTimestamp,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { ChannelMember } from "@/shared/api/types";
@@ -41,13 +52,6 @@ import {
import { PullRequestReviewersRow } from "./PullRequestReviewersRow";
import { PullRequestReviewCard } from "./PullRequestReviewCard";
function compactDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) {
return profiles?.[normalizePubkey(pubkey)] ?? null;
}
@@ -61,25 +65,6 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) {
);
}
function relativeCreatedAt(createdAt: number) {
const elapsedSeconds = Math.max(
1,
Math.floor(Date.now() / 1_000 - createdAt),
);
const units = [
{ label: "year", seconds: 365 * 24 * 60 * 60 },
{ label: "month", seconds: 30 * 24 * 60 * 60 },
{ label: "day", seconds: 24 * 60 * 60 },
{ label: "hour", seconds: 60 * 60 },
{ label: "minute", seconds: 60 },
];
const unit =
units.find((item) => elapsedSeconds >= item.seconds) ??
units[units.length - 1];
const value = Math.max(1, Math.floor(elapsedSeconds / unit.seconds));
return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`;
}
function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
@@ -128,11 +113,13 @@ function AuthorIdentity({
profiles,
pubkey,
role,
showLabel = true,
}: {
avatarSize?: "xs" | "sm" | "md";
profiles?: UserProfileLookup;
pubkey: string;
role?: React.ReactNode;
showLabel?: boolean;
}) {
const profile = profileForPubkey(pubkey, profiles);
return (
@@ -144,6 +131,7 @@ function AuthorIdentity({
label={labelForPubkey(pubkey, profiles)}
pubkey={pubkey}
role={role}
showLabel={showLabel}
/>
);
}
@@ -213,7 +201,10 @@ function PullRequestCommitRow({
/>
<span className="truncate">
<ProfileAuthorName pubkey={author}>{authorLabel}</ProfileAuthorName>{" "}
authored {relativeTime(createdAt)}
authored{" "}
<span title={formatExactTimestamp(createdAt)}>
{relativeTime(createdAt)}
</span>
</span>
{branch ? (
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/60 px-1.5 py-0.5 font-mono text-2xs">
@@ -276,7 +267,10 @@ function PullRequestRow({
<ProfileAuthorName pubkey={pullRequest.author}>
{authorLabel}
</ProfileAuthorName>{" "}
created this pull request {relativeCreatedAt(pullRequest.createdAt)}
created this pull request{" "}
<span title={formatExactTimestamp(pullRequest.createdAt)}>
{relativeTime(pullRequest.createdAt)}
</span>
</span>
{pullRequest.branchName ? (
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/60 px-1.5 py-0.5 font-mono text-2xs">
@@ -335,18 +329,60 @@ export function PullRequestDetailHeader({
pullRequest: ProjectPullRequest;
}) {
const authorLabel = labelForPubkey(pullRequest.author, profiles);
const sourceChannelId = pullRequest.channelId;
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery({
enabled: Boolean(sourceChannelId),
});
const sourceChannel = channelsQuery.data?.find(
(channel) => channel.id === sourceChannelId,
);
return (
<header className="min-w-0 space-y-1.5 p-4 pb-2">
<h3 className="line-clamp-2 min-w-0 text-base font-semibold text-foreground">
<header className="min-w-0 space-y-1 p-4 pb-4">
<h3 className="line-clamp-2 min-w-0 text-xl font-semibold text-foreground">
{pullRequest.title}{" "}
<span className="font-normal text-muted-foreground">
#{pullRequest.id.slice(0, 8)}
</span>
</h3>
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<p className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
Created {compactDate(pullRequest.createdAt)} by {authorLabel}
<span className="flex min-w-0 items-center gap-1">
<AuthorIdentity
avatarSize="xs"
profiles={profiles}
pubkey={pullRequest.author}
showLabel={false}
/>
<ProfileAuthorName pubkey={pullRequest.author}>
{authorLabel}
</ProfileAuthorName>
</span>
<span title={formatExactTimestamp(pullRequest.createdAt)}>
created {relativeTime(pullRequest.createdAt)}
</span>
{sourceChannelId ? (
<span
className="inline-flex min-w-0 items-center gap-1"
title="Source channel is claimed by the pull request author and is not relay-verified."
>
<span>linked from</span>
{sourceChannel ? (
<button
aria-label={`Open author-claimed source channel #${sourceChannel.name}`}
className="truncate font-medium text-foreground underline-offset-2 hover:underline"
onClick={() => void goChannel(sourceChannel.id)}
type="button"
>
#{sourceChannel.name}
</button>
) : (
<span>an unavailable channel</span>
)}
<span>(author-claimed)</span>
</span>
) : null}
</p>
</header>
);
@@ -430,14 +466,20 @@ export function PullRequestMetaRail({
<dl className="space-y-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<dt>Created</dt>
<dd className="font-medium text-foreground">
{compactDate(pullRequest.createdAt)}
<dd
className="font-medium text-foreground"
title={formatExactTimestamp(pullRequest.createdAt)}
>
{relativeTime(pullRequest.createdAt)}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt>Updated</dt>
<dd className="font-medium text-foreground">
{compactDate(pullRequest.updatedAt)}
<dd
className="font-medium text-foreground"
title={formatExactTimestamp(pullRequest.updatedAt)}
>
{relativeTime(pullRequest.updatedAt)}
</dd>
</div>
</dl>
@@ -448,6 +490,7 @@ export function PullRequestMetaRail({
function PullRequestDetail({
mode,
onOpenInlineComment,
onOpenCommit,
onOpenTerminal,
profiles,
@@ -455,31 +498,47 @@ function PullRequestDetail({
pullRequest,
}: {
mode: PullRequestPanelMode;
onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void;
onOpenCommit?: (commitHash: string) => void;
onOpenTerminal?: OpenMergeRecoveryTerminal;
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest;
}) {
const identityQuery = useIdentityQuery();
const commentMutation = useCreateProjectPullRequestCommentMutation(project);
const [
expandedReviewHistoryPullRequestIds,
setExpandedReviewHistoryPullRequestIds,
] = React.useState<Set<string>>(() => new Set());
const [
collapsedReviewHistoryPullRequestIds,
setCollapsedReviewHistoryPullRequestIds,
] = React.useState<Set<string>>(() => new Set());
const members = React.useMemo(
() => pullRequestMembers(project, pullRequest, profiles),
[profiles, project, pullRequest],
);
const handleCommentSubmit = React.useCallback(
const submitComment = React.useCallback(
async (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
decision?: "request-changes",
) => {
try {
await commentMutation.mutateAsync({
content,
decision,
mediaTags,
mentionPubkeys,
pullRequest,
});
toast.success("Comment posted.");
toast.success(
decision === "request-changes"
? "Changes requested."
: "Comment posted.",
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to post comment.",
@@ -489,6 +548,16 @@ function PullRequestDetail({
},
[commentMutation, pullRequest],
);
const handleCommentSubmit = React.useCallback(
(content: string, mentionPubkeys: string[], mediaTags?: string[][]) =>
submitComment(content, mentionPubkeys, mediaTags),
[submitComment],
);
const handleChangeRequestSubmit = React.useCallback(
(content: string, mentionPubkeys: string[], mediaTags?: string[][]) =>
submitComment(content, mentionPubkeys, mediaTags, "request-changes"),
[submitComment],
);
if (mode === "commits") {
const commitCount = Math.max(1, pullRequest.updates.length + 1);
@@ -536,8 +605,38 @@ function PullRequestDetail({
);
}
const reviewHistory = pullRequest.comments
.map((item) => ({
item,
timelineKind: projectPullRequestCommentTimelineKind(item),
}))
.sort(
(left, right) =>
left.item.createdAt - right.item.createdAt ||
left.item.id.localeCompare(right.item.id),
);
const reviewHistoryCollapsed = collapsedReviewHistoryPullRequestIds.has(
pullRequest.id,
);
const reviewHistoryExpanded = expandedReviewHistoryPullRequestIds.has(
pullRequest.id,
);
const earlierReviewHistoryCount = Math.max(0, reviewHistory.length - 3);
const visibleReviewHistory =
reviewHistoryExpanded || earlierReviewHistoryCount === 0
? reviewHistory
: reviewHistory.slice(-3);
const displayedReviewHistory = reviewHistoryCollapsed
? []
: visibleReviewHistory;
const canRequestChanges = canReviewProjectPullRequest(
project,
pullRequest,
identityQuery.data?.pubkey,
);
return (
<div className="divide-y divide-border/50">
<div>
{pullRequest.content ? (
<header className="p-4">
<Markdown
@@ -549,7 +648,7 @@ function PullRequestDetail({
) : null}
{pullRequest.updates.length > 0 ? (
<section className="space-y-3 p-4">
<section className="space-y-3 border-border/50 border-t p-4">
<h4 className="text-sm font-semibold text-foreground">Updates</h4>
{pullRequest.updates.map((update) => (
<article className="space-y-1" key={update.id}>
@@ -557,7 +656,11 @@ function PullRequestDetail({
<AuthorIdentity
profiles={profiles}
pubkey={update.author}
role={compactDate(update.createdAt)}
role={
<span title={formatExactTimestamp(update.createdAt)}>
{relativeTime(update.createdAt)}
</span>
}
/>
{update.commit ? (
<CommitHashChip
@@ -577,113 +680,214 @@ function PullRequestDetail({
) : null}
<section className="space-y-3 p-4">
{pullRequest.comments.length > 0 ? (
<div className="-mt-4">
{pullRequest.comments.map((item) => {
// Review decisions and requests render as compact timeline
// rows (GitHub-style) rather than full comment cards.
const timelineKind = projectPullRequestCommentTimelineKind(item);
if (timelineKind) {
const isHistoricalDecision =
item.reviewDecisionStatus === "historical";
return (
<div
className="-mx-4 flex min-h-10 min-w-0 items-center gap-2 border-b border-border/50 px-4 text-sm text-muted-foreground"
key={item.id}
>
<div className="group/timeline -mx-4 overflow-hidden border-border/50 border-b">
{reviewHistory.length > 0 ? (
<button
aria-expanded={!reviewHistoryCollapsed}
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
data-testid="project-pull-request-review-history-toggle"
onClick={() => {
setCollapsedReviewHistoryPullRequestIds((current) => {
const next = new Set(current);
if (reviewHistoryCollapsed) {
next.delete(pullRequest.id);
} else {
next.add(pullRequest.id);
}
return next;
});
}}
type="button"
>
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
{reviewHistoryCollapsed ? (
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
) : (
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
)}
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/35">
<History className="h-3 w-3" />
</span>
</span>
<span className="flex min-h-5 min-w-0 flex-1 items-center text-left">
{reviewHistoryCollapsed
? `Show ${reviewHistory.length} earlier ${
reviewHistory.length === 1 ? "activity" : "activities"
}`
: "Collapse review history"}
</span>
{reviewHistoryCollapsed ? (
<ChevronDown className="mt-0.5 h-3.5 w-3.5" />
) : (
<ChevronUp className="mt-0.5 h-3.5 w-3.5" />
)}
</button>
) : null}
{!reviewHistoryCollapsed &&
earlierReviewHistoryCount > 0 &&
!reviewHistoryExpanded ? (
<button
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
data-testid="project-pull-request-earlier-activities"
onClick={() => {
setExpandedReviewHistoryPullRequestIds((current) => {
const next = new Set(current);
next.add(pullRequest.id);
return next;
});
}}
type="button"
>
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
<ChevronDown className="h-3 w-3" />
</span>
</span>
<span className="min-w-0 flex-1 text-left">
Show {earlierReviewHistoryCount} earlier{" "}
{earlierReviewHistoryCount === 1 ? "activity" : "activities"}
</span>
</button>
) : null}
{displayedReviewHistory.map(({ item, timelineKind }, index) => {
const isHistoricalDecision =
item.reviewDecisionStatus === "historical";
const trimmedContent = item.content.trim();
const activityContent =
timelineKind === null
? trimmedContent
: timelineKind === "changes-requested" &&
!/^requested changes\.?$/i.test(trimmedContent)
? trimmedContent
: timelineKind === "approved" &&
!/^approved (these )?changes\.?$/i.test(trimmedContent)
? trimmedContent
: null;
return (
<div
className="flex min-h-10 min-w-0 items-start gap-2 px-3 py-2.5 text-sm text-muted-foreground"
data-testid="project-pull-request-timeline-row"
key={item.id}
>
<div className="relative flex w-5 shrink-0 justify-center self-stretch">
{index < displayedReviewHistory.length - 1 ? (
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
) : (
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
)}
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
{timelineKind === "approved" ? (
<Check
className={`h-3.5 w-3.5 shrink-0 ${
className={`h-3 w-3 ${
isHistoricalDecision
? "text-muted-foreground"
: "text-green-600 dark:text-green-500"
}`}
/>
) : timelineKind === "changes-requested" ? (
<X
className={`h-3.5 w-3.5 shrink-0 ${
<TriangleAlert
className={`h-3 w-3 ${
isHistoricalDecision
? "text-muted-foreground"
: "text-destructive"
: "text-amber-600 dark:text-amber-400"
}`}
/>
) : timelineKind === "review-request" ? (
<UserPlus className="h-3 w-3" />
) : (
<UserPlus className="h-3.5 w-3.5 shrink-0" />
<MessageSquare className="h-3 w-3" />
)}
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<span className="shrink-0 font-medium text-foreground">
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center">
<span className="min-w-0 truncate">
<ProfileAuthorName pubkey={item.author}>
{labelForPubkey(item.author, profiles)}
</span>
<span className="min-w-0 truncate">
{timelineKind === "approved"
? isHistoricalDecision
? "approved an earlier commit"
: "approved these changes"
: timelineKind === "changes-requested"
</ProfileAuthorName>
{timelineKind ? (
<>
{" "}
{timelineKind === "approved"
? isHistoricalDecision
? "requested changes on an earlier commit"
: "requested changes"
: item.content.trim() || "requested a review"}
</span>
? "approved an earlier commit"
: "approved these changes"
: timelineKind === "changes-requested"
? isHistoricalDecision
? "requested changes on an earlier commit"
: "requested changes"
: trimmedContent || "requested a review"}
</>
) : null}
</span>
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground/70">
{compactDate(item.createdAt)}
<span
className="ml-auto w-20 shrink-0 text-right text-xs text-muted-foreground/70"
title={formatExactTimestamp(item.createdAt)}
>
{relativeTime(item.createdAt)}
</span>
</div>
);
}
return (
<article className="py-3" key={item.id}>
{activityContent ? (
<Markdown
className="mt-1 text-sm text-foreground/90"
content={activityContent}
interactive={false}
/>
) : null}
{item.anchor ? (
<div className="mb-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5 shrink-0" />
<button
aria-label={`Open ${item.anchor.path} ${item.anchor.side} line ${item.anchor.line} in Files changed`}
className="mt-1 inline-flex min-w-0 items-center gap-1 rounded-md bg-muted/65 px-1.5 py-0.5 font-mono text-2xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
if (item.anchor) onOpenInlineComment?.(item.anchor);
}}
type="button"
>
<FileCode2 className="h-3 w-3 shrink-0" />
<span className="truncate">{item.anchor.path}</span>
<span className="shrink-0 font-mono">
<span className="shrink-0">
{item.anchor.side === "new" ? "+" : "-"}
{item.anchor.line}
</span>
{item.inlineCommentStatus === "outdated" ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-2xs">
<span className="shrink-0 text-destructive">
Outdated
</span>
) : null}
</div>
</button>
) : null}
<div className="mb-2">
<AuthorIdentity
profiles={profiles}
pubkey={item.author}
role={compactDate(item.createdAt)}
/>
</div>
<Markdown
className="text-sm"
content={item.content}
interactive={false}
/>
</article>
);
})}
</div>
</div>
);
})}
<div className="flex min-h-12 items-start justify-start px-3 py-2.5">
<PullRequestReviewCard
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
/>
</div>
) : null}
<PullRequestReviewCard
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
/>
</div>
<h4 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<MessageSquare className="h-3.5 w-3.5" />
Add Your Comment
</h4>
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
/>
<div data-testid="project-pull-request-comment-composer">
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSecondarySubmit={
canRequestChanges ? handleChangeRequestSubmit : undefined
}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
secondarySubmitLabel="Request changes"
/>
</div>
</section>
</div>
);
@@ -693,6 +897,7 @@ export function PullRequestsPanel({
error,
isLoading,
mode = "conversation",
onOpenInlineComment,
onOpenCommit,
onOpenTerminal,
onSelectedPullRequestIdChange,
@@ -704,6 +909,7 @@ export function PullRequestsPanel({
error: unknown;
isLoading: boolean;
mode?: PullRequestPanelMode;
onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void;
onOpenCommit?: (commitHash: string) => void;
onOpenTerminal?: OpenMergeRecoveryTerminal;
onSelectedPullRequestIdChange: (id: string | null) => void;
@@ -746,6 +952,7 @@ export function PullRequestsPanel({
return (
<PullRequestDetail
mode={mode}
onOpenInlineComment={onOpenInlineComment}
onOpenCommit={onOpenCommit}
onOpenTerminal={onOpenTerminal}
profiles={profiles}

View File

@@ -26,6 +26,7 @@ import type {
ProjectRepoFile,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import { useUserSearchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { UserSearchResult } from "@/shared/api/types";
@@ -40,31 +41,6 @@ import {
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
function relativeCommitTime(createdAt: number) {
const elapsedSeconds = Math.max(
1,
Math.floor(Date.now() / 1_000 - createdAt),
);
const units = [
{ label: "year", seconds: 365 * 24 * 60 * 60 },
{ label: "month", seconds: 30 * 24 * 60 * 60 },
{ label: "week", seconds: 7 * 24 * 60 * 60 },
{ label: "day", seconds: 24 * 60 * 60 },
{ label: "hour", seconds: 60 * 60 },
{ label: "minute", seconds: 60 },
{ label: "second", seconds: 1 },
];
for (const unit of units) {
const value = Math.floor(elapsedSeconds / unit.seconds);
if (value >= 1) {
return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`;
}
}
return "just now";
}
function pluralize(count: number, singular: string) {
return `${count} ${singular}${count === 1 ? "" : "s"}`;
}
@@ -852,7 +828,7 @@ export function RepositoryFilesPanel({
latestCommit.timestamp * 1_000,
).toISOString()}
>
{relativeCommitTime(latestCommit.timestamp)}
{relativeTime(latestCommit.timestamp)}
</time>
</div>
) : (
@@ -905,7 +881,7 @@ export function RepositoryFilesPanel({
latestCommit.timestamp * 1_000,
).toISOString()}
>
{relativeCommitTime(latestCommit.timestamp)}
{relativeTime(latestCommit.timestamp)}
</time>
) : (
"—"

View File

@@ -5,7 +5,7 @@ import { cn } from "@/shared/lib/cn";
import { TabsList, TabsTrigger } from "@/shared/ui/tabs";
const PROJECT_TAB_TRIGGER_CLASS =
"relative h-full shrink-0 rounded-none px-2.5 text-base leading-5 tracking-tight text-muted-foreground shadow-none after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:bg-current after:opacity-0 after:transition-opacity after:content-[''] hover:bg-transparent hover:font-semibold hover:text-foreground hover:after:opacity-100 data-[state=active]:bg-transparent data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:opacity-100";
"relative h-full shrink-0 rounded-none px-2.5 text-base leading-5 tracking-tight text-muted-foreground shadow-none after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:bg-current after:opacity-0 after:transition-opacity after:content-[''] hover:bg-transparent hover:text-foreground hover:after:opacity-100 data-[state=active]:bg-transparent data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:opacity-100";
const PROJECT_TAB_SELECTED_CLASS =
"font-semibold text-foreground after:opacity-100";

View File

@@ -12,6 +12,7 @@ import type {
Project,
ProjectLocalRepoSnapshot,
ProjectPullRequest,
ProjectPullRequestCommentAnchor,
ProjectRepoContributor,
ProjectRepoDiff,
ProjectRepoSnapshot,
@@ -196,6 +197,11 @@ export function WorkspaceTabs({
) ?? null;
const isPullRequestSelected = Boolean(selectedPullRequest);
const [selectedTab, setSelectedTab] = React.useState("overview");
const [pullRequestCommentTarget, setPullRequestCommentTarget] =
React.useState<{
anchor: ProjectPullRequestCommentAnchor;
pullRequestId: string;
} | null>(null);
const [createIssueOpen, setCreateIssueOpen] = React.useState(false);
const [createPullRequestOpen, setCreatePullRequestOpen] =
React.useState(false);
@@ -250,6 +256,17 @@ export function WorkspaceTabs({
onSelectedPullRequestIdChange,
],
);
const handleOpenPullRequestComment = React.useCallback(
(anchor: ProjectPullRequestCommentAnchor) => {
if (!selectedPullRequestId) return;
setPullRequestCommentTarget({
anchor: { ...anchor },
pullRequestId: selectedPullRequestId,
});
setSelectedTab("pr-files");
},
[selectedPullRequestId],
);
return (
<Tabs
@@ -307,6 +324,7 @@ export function WorkspaceTabs({
error={pullRequestsError}
isLoading={pullRequestsLoading}
mode={mode}
onOpenInlineComment={handleOpenPullRequestComment}
onOpenCommit={onSelectedCommitHashChange}
onOpenTerminal={onOpenMergeRecoveryTerminal}
onSelectedPullRequestIdChange={
@@ -323,6 +341,12 @@ export function WorkspaceTabs({
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
focusedAnchor={
pullRequestCommentTarget?.pullRequestId ===
selectedPullRequestId
? pullRequestCommentTarget.anchor
: null
}
isLoading={repoDiffLoading}
profiles={profiles}
project={project}

View File

@@ -5,6 +5,7 @@ import type {
ProjectIssue,
ProjectIssueListItem,
} from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import {
resolveUserLabel,
@@ -40,25 +41,6 @@ type ProjectsIssuesListProps = {
viewMode: "grid" | "list";
};
function formatRelativeTime(createdAt: number) {
const elapsedSeconds = Math.max(
1,
Math.floor(Date.now() / 1_000 - createdAt),
);
const units = [
{ label: "year", seconds: 365 * 24 * 60 * 60 },
{ label: "month", seconds: 30 * 24 * 60 * 60 },
{ label: "day", seconds: 24 * 60 * 60 },
{ label: "hour", seconds: 60 * 60 },
{ label: "minute", seconds: 60 },
];
const unit =
units.find((item) => elapsedSeconds >= item.seconds) ??
units[units.length - 1];
const value = Math.max(1, Math.floor(elapsedSeconds / unit.seconds));
return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`;
}
function nextStepLabel(status: ProjectIssue["status"]) {
if (status === "Done" || status === "Closed") return "View issue";
if (status === "In Review") return "Review issue";
@@ -86,10 +68,8 @@ function IssueHeader({
</div>
<p className={`truncate ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}>
{project.name}
{includeDate
? ` · created ${formatRelativeTime(issue.createdAt)}`
: null}{" "}
· by{" "}
{includeDate ? ` · created ${relativeTime(issue.createdAt)}` : null} ·
by{" "}
<UserProfilePopover pubkey={issue.author} triggerElement="span">
<button
className="relative z-10 rounded-sm hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
@@ -219,7 +199,7 @@ function IssueListRow({
data-testid="projects-row-date"
title={new Date(issue.createdAt * 1_000).toLocaleString()}
>
{formatRelativeTime(issue.createdAt)}
{relativeTime(issue.createdAt)}
</span>
<ProjectListRowMenu label={`More options for ${issue.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>

View File

@@ -5,6 +5,7 @@ import type {
ProjectPullRequest,
ProjectPullRequestListItem,
} from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import {
resolveUserLabel,
@@ -60,25 +61,6 @@ type ProjectsPullRequestsListProps = {
viewMode: "grid" | "list";
};
function formatRelativeTime(createdAt: number) {
const elapsedSeconds = Math.max(
1,
Math.floor(Date.now() / 1_000 - createdAt),
);
const units = [
{ label: "year", seconds: 365 * 24 * 60 * 60 },
{ label: "month", seconds: 30 * 24 * 60 * 60 },
{ label: "day", seconds: 24 * 60 * 60 },
{ label: "hour", seconds: 60 * 60 },
{ label: "minute", seconds: 60 },
];
const unit =
units.find((item) => elapsedSeconds >= item.seconds) ??
units[units.length - 1];
const value = Math.max(1, Math.floor(elapsedSeconds / unit.seconds));
return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`;
}
function nextStepLabel(status: ProjectPullRequest["status"]) {
if (status === "Draft") return "View draft";
if (status === "Merged") return "View merge";
@@ -152,7 +134,7 @@ function PullRequestGridCard({
<span className="font-medium text-foreground">
{pullRequest.status}
</span>
<span>created {formatRelativeTime(pullRequest.createdAt)}</span>
<span>created {relativeTime(pullRequest.createdAt)}</span>
<span>
by{" "}
<AuthorNameButton
@@ -243,7 +225,7 @@ function PullRequestListRow({
data-testid="projects-row-date"
title={new Date(pullRequest.createdAt * 1_000).toLocaleString()}
>
{formatRelativeTime(pullRequest.createdAt)}
{relativeTime(pullRequest.createdAt)}
</span>
<ProjectListRowMenu label={`More options for ${pullRequest.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, pullRequest)}>

View File

@@ -1,27 +1,46 @@
import { Check, GitPullRequest, GitPullRequestDraft, X } from "lucide-react";
import {
Check,
GitPullRequest,
GitPullRequestDraft,
MoreHorizontal,
RotateCcw,
X,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import type { Project, ProjectPullRequest } from "@/features/projects/hooks";
import { nextProjectPullRequestReviewCreatedAt } from "@/features/projects/projectPullRequests.mjs";
import {
nextProjectPullRequestReviewCreatedAt,
projectPullRequestReviewSummary,
} from "@/features/projects/projectPullRequests.mjs";
import {
canReviewProjectPullRequest,
useApproveProjectPullRequestMutation,
useRequestProjectPullRequestChangesMutation,
useUpdateProjectPullRequestStatusMutation,
} from "@/features/projects/pullRequestReviews";
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Textarea } from "@/shared/ui/textarea";
import {
MergePullRequestButton,
type OpenMergeRecoveryTerminal,
} from "./MergePullRequestButton";
/** GitHub-style review state and actions rendered in the conversation flow. */
/** Available pull-request actions rendered beside the latest review activity. */
export function PullRequestReviewCard({
onOpenTerminal,
project,
@@ -36,10 +55,8 @@ export function PullRequestReviewCard({
useUpdateProjectPullRequestStatusMutation(project);
const { isPending: isApproving, mutateAsync: approvePullRequest } =
useApproveProjectPullRequestMutation(project);
const {
isPending: isRequestingChanges,
mutateAsync: requestPullRequestChanges,
} = useRequestProjectPullRequestChangesMutation(project);
const [approveDialogOpen, setApproveDialogOpen] = React.useState(false);
const [approvalSummary, setApprovalSummary] = React.useState("");
const reviewDecisionInFlightRef = React.useRef(false);
const lastReviewDecisionCreatedAtRef = React.useRef(0);
@@ -47,47 +64,38 @@ export function PullRequestReviewCard({
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
const isAuthor = viewer === normalizePubkey(pullRequest.author);
const isOwner = viewer === normalizePubkey(project.owner);
const isRequestedReviewer = Boolean(
viewer &&
pullRequest.reviewers.some(
(reviewer) => normalizePubkey(reviewer) === viewer,
),
);
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
const canChangeStatus = Boolean(viewer) && (isAuthor || isOwner);
const canChangeStatus =
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
const hasApproved = Boolean(
viewer &&
pullRequest.approvals.some(
(approval) => normalizePubkey(approval.author) === viewer,
),
);
const hasRequestedChanges = Boolean(
viewer &&
pullRequest.changeRequests.some(
(request) => normalizePubkey(request.author) === viewer,
),
);
const canReview =
Boolean(viewer) &&
!isAuthor &&
(isOwner || isRequestedReviewer) &&
Boolean(pullRequest.commit) &&
(pullRequest.status === "Open" || pullRequest.status === "Draft");
const canReview = canReviewProjectPullRequest(project, pullRequest, viewer);
const canApprove = canReview && !hasApproved;
const canRequestChanges = canReview && !hasRequestedChanges;
const canMerge =
(isOwner || isManagedAgentOwner) &&
pullRequest.status === "Open" &&
Boolean(pullRequest.branchName && pullRequest.commit);
const handleStatusChange = React.useCallback(
async (status: "open" | "draft") => {
async (status: "open" | "draft" | "closed") => {
try {
await updatePullRequestStatus({ pullRequest, status });
await updatePullRequestStatus({
pullRequest,
signAsManagedOwner: isManagedAgentOwner && !isOwner,
status,
});
toast.success(
status === "draft"
? "Converted to draft."
: "Marked as ready for review.",
: status === "closed"
? "Pull request closed."
: pullRequest.status === "Closed"
? "Pull request reopened."
: "Marked as ready for review.",
);
} catch (error) {
toast.error(
@@ -95,17 +103,19 @@ export function PullRequestReviewCard({
);
}
},
[pullRequest, updatePullRequestStatus],
[isManagedAgentOwner, isOwner, pullRequest, updatePullRequestStatus],
);
const runReviewDecision = React.useCallback(
async (
mutate: (input: {
content?: string;
createdAt: number;
pullRequest: ProjectPullRequest;
}) => Promise<unknown>,
successMessage: string,
fallbackErrorMessage: string,
content?: string,
) => {
if (reviewDecisionInFlightRef.current) return;
reviewDecisionInFlightRef.current = true;
@@ -119,12 +129,14 @@ export function PullRequestReviewCard({
lastReviewDecisionCreatedAtRef.current = createdAt;
try {
await mutate({ createdAt, pullRequest });
await mutate({ content, createdAt, pullRequest });
toast.success(successMessage);
return true;
} catch (error) {
toast.error(
error instanceof Error ? error.message : fallbackErrorMessage,
);
return false;
} finally {
reviewDecisionInFlightRef.current = false;
}
@@ -133,152 +145,184 @@ export function PullRequestReviewCard({
);
const handleApprove = React.useCallback(async () => {
await runReviewDecision(
const approved = await runReviewDecision(
approvePullRequest,
"Pull request approved.",
"Failed to approve.",
approvalSummary,
);
}, [approvePullRequest, runReviewDecision]);
if (approved) {
setApproveDialogOpen(false);
setApprovalSummary("");
}
}, [approvalSummary, approvePullRequest, runReviewDecision]);
const handleRequestChanges = React.useCallback(async () => {
await runReviewDecision(
requestPullRequestChanges,
"Changes requested.",
"Failed to request changes.",
);
}, [requestPullRequestChanges, runReviewDecision]);
const {
approvalCount,
changeRequestCount,
detail: reviewStateDetail,
showState: showReviewState,
state: reviewState,
} = projectPullRequestReviewSummary(pullRequest);
const reviewDecisionPending = isApproving || isRequestingChanges;
const isDraft = pullRequest.status === "Draft";
const showActions =
hasApproved ||
hasRequestedChanges ||
const reviewDecisionPending = isApproving;
const canMarkReady = canChangeStatus && pullRequest.status === "Draft";
const canConvertToDraft = canChangeStatus && pullRequest.status === "Open";
const canClose =
canChangeStatus &&
(pullRequest.status === "Open" || pullRequest.status === "Draft");
const canReopen = canChangeStatus && pullRequest.status === "Closed";
const hasOverflowAction = canConvertToDraft || canClose;
const hasAvailableAction =
canApprove ||
canRequestChanges ||
canMerge ||
(canChangeStatus && isDraft);
const showDraftControl = canChangeStatus && pullRequest.status === "Open";
canMarkReady ||
canConvertToDraft ||
canClose ||
canReopen;
if (
approvalCount + changeRequestCount > 0 &&
!showActions &&
!showDraftControl
) {
return null;
}
if (!hasAvailableAction) return null;
return (
<div className="space-y-2.5 pt-3">
<div className="min-w-0 space-y-2.5 rounded-xl bg-muted/40 px-3 py-2.5">
{showReviewState ? (
<div className="flex min-w-0 items-start gap-2">
{isDraft ? (
<GitPullRequestDraft className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<GitPullRequest className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{reviewState}
</p>
{reviewStateDetail ? (
<p className="text-xs text-muted-foreground">
{reviewStateDetail}
</p>
) : null}
</div>
</div>
) : null}
{showActions ? (
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasApproved ? (
<span className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg border border-green-600/40 px-3.5 text-xs font-medium text-green-600 dark:text-green-500">
<Check className="h-3.5 w-3.5" />
Approved
</span>
) : null}
{hasRequestedChanges ? (
<span className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg border border-destructive/40 px-3.5 text-xs font-medium text-destructive">
<X className="h-3.5 w-3.5" />
Changes requested
</span>
) : null}
{canApprove ? (
<Button
className="h-8 gap-1.5 bg-green-600 px-3.5 text-white shadow-sm hover:bg-green-700"
disabled={reviewDecisionPending}
onClick={() => {
void handleApprove();
}}
size="xs"
type="button"
>
<Check className="h-3.5 w-3.5" />
Approve
</Button>
) : null}
{canRequestChanges ? (
<Button
className="h-8 gap-1.5 px-3.5 text-destructive hover:text-destructive"
disabled={reviewDecisionPending}
onClick={() => {
void handleRequestChanges();
}}
size="xs"
type="button"
variant="outline"
>
<X className="h-3.5 w-3.5" />
Request changes
</Button>
) : null}
{canMerge ? (
<MergePullRequestButton
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
/>
) : null}
{canChangeStatus && isDraft ? (
<Button
className="h-7 gap-1.5 px-3"
disabled={isUpdatingStatus}
onClick={() => {
void handleStatusChange("open");
}}
size="xs"
type="button"
variant="secondary"
>
<GitPullRequest className="h-3.5 w-3.5" />
Ready for review
</Button>
) : null}
</div>
) : null}
<>
<div className="pull-request-action-timeline flex min-w-0 flex-1 items-start gap-2">
<span
aria-hidden="true"
className="mt-3.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-background text-muted-foreground ring-1 ring-border/70"
>
<GitPullRequest className="h-3 w-3" />
</span>
<div
className="flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 rounded-lg border border-border/60 bg-muted/15 p-2"
data-testid="project-pull-request-actions"
>
{canApprove ? (
<Button
className="h-8 gap-1.5 bg-green-600 px-3.5 text-white shadow-sm hover:bg-green-700"
disabled={reviewDecisionPending}
onClick={() => setApproveDialogOpen(true)}
size="xs"
type="button"
>
<Check className="h-3.5 w-3.5" />
Approve
</Button>
) : null}
{canMerge ? (
<MergePullRequestButton
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
/>
) : null}
{canMarkReady ? (
<Button
className="h-8 gap-1.5 px-3"
disabled={isUpdatingStatus}
onClick={() => {
void handleStatusChange("open");
}}
size="xs"
type="button"
variant="secondary"
>
<GitPullRequest className="h-3.5 w-3.5" />
Ready for review
</Button>
) : null}
{canReopen ? (
<Button
className="h-8 gap-1.5 px-3"
disabled={isUpdatingStatus}
onClick={() => {
void handleStatusChange("open");
}}
size="xs"
type="button"
variant="secondary"
>
<RotateCcw className="h-3.5 w-3.5" />
Reopen pull request
</Button>
) : null}
{hasOverflowAction ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More pull request actions"
className="ml-auto h-8 w-8"
disabled={isUpdatingStatus}
size="icon-xs"
type="button"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canConvertToDraft ? (
<DropdownMenuItem
onSelect={() => {
void handleStatusChange("draft");
}}
>
<GitPullRequestDraft className="h-4 w-4" />
Convert to draft
</DropdownMenuItem>
) : null}
{canClose ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => {
void handleStatusChange("closed");
}}
>
<X className="h-4 w-4" />
Close pull request
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{showDraftControl ? (
<p className="px-1 text-xs text-muted-foreground">
Still in progress?{" "}
<button
className="font-medium underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
disabled={isUpdatingStatus}
onClick={() => {
void handleStatusChange("draft");
}}
type="button"
>
Convert to draft
</button>
</p>
) : null}
</div>
<Dialog
onOpenChange={(open) => {
if (!open && isApproving) return;
setApproveDialogOpen(open);
if (!open) setApprovalSummary("");
}}
open={approveDialogOpen}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Approve pull request</DialogTitle>
<DialogDescription>
Add an optional summary for the author and other reviewers.
</DialogDescription>
</DialogHeader>
<Textarea
aria-label="Approval summary"
disabled={isApproving}
onChange={(event) => setApprovalSummary(event.target.value)}
placeholder="What looks good?"
value={approvalSummary}
/>
<DialogFooter>
<Button
disabled={isApproving}
onClick={() => setApproveDialogOpen(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
className="bg-green-600 text-white hover:bg-green-700"
disabled={isApproving}
onClick={() => {
void handleApprove();
}}
type="button"
>
<Check className="h-4 w-4" />
Approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,4 +1,4 @@
import { Check, Search, UserPlus, X } from "lucide-react";
import { Check, Search, TriangleAlert } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -10,8 +10,15 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { UserSearchResult } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -146,12 +153,12 @@ export function PullRequestReviewersRow({
size="xs"
/>
{hasApproved ? (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full bg-green-600 text-white ring-2 ring-background">
<Check className="h-2.5 w-2.5" />
<span className="-right-0.5 -bottom-0.5 absolute flex h-2.5 w-2.5 items-center justify-center rounded-full bg-green-600 text-white ring-1 ring-background">
<Check className="h-1.5 w-1.5" />
</span>
) : hasRequestedChanges ? (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-destructive-foreground ring-2 ring-background">
<X className="h-2.5 w-2.5" />
<span className="-right-0.5 -bottom-0.5 absolute flex h-2.5 w-2.5 items-center justify-center rounded-full bg-amber-500 text-amber-950 ring-1 ring-background">
<TriangleAlert className="h-1.5 w-1.5" />
</span>
) : null}
</span>
@@ -168,34 +175,39 @@ export function PullRequestReviewersRow({
);
})}
{canRequest ? (
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<PopoverTrigger asChild>
<Dialog onOpenChange={setPickerOpen} open={pickerOpen}>
<DialogTrigger asChild>
<Button
className="h-6 gap-1 px-2 text-2xs text-muted-foreground hover:text-foreground"
className="h-6 px-1 text-xs text-muted-foreground hover:text-foreground"
disabled={requestReviewMutation.isPending}
size="xs"
type="button"
variant="outline"
variant="ghost"
>
<UserPlus className="h-3 w-3" />
Request
Add Reviewer
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-2">
<div className="flex items-center gap-2 border-b border-border/70 px-2 pb-2">
</DialogTrigger>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Add reviewer</DialogTitle>
<DialogDescription>
Choose a person or agent to review this pull request.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 border-b border-border/60 px-6 py-3">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Input
autoFocus
className="h-7 border-0 px-0 text-xs shadow-none focus-visible:ring-0"
className="h-8 border-0 px-0 text-sm shadow-none focus-visible:ring-0"
data-testid="project-reviewer-search"
onChange={(event) => setReviewerQuery(event.target.value)}
placeholder="Search people and agents"
value={reviewerQuery}
/>
</div>
<div className="mt-2 max-h-64 overflow-y-auto">
<div className="max-h-72 min-h-28 overflow-y-auto p-2">
{userSearchQuery.isLoading ? (
<p className="px-2 py-2 text-xs text-muted-foreground">
<p className="px-3 py-4 text-sm text-muted-foreground">
Searching…
</p>
) : candidates.length > 0 ? (
@@ -203,7 +215,7 @@ export function PullRequestReviewersRow({
const label = reviewerSearchLabel(candidate);
return (
<button
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
className="flex w-full min-w-0 items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`project-reviewer-result-${candidate.pubkey}`}
disabled={requestReviewMutation.isPending}
key={candidate.pubkey}
@@ -219,10 +231,10 @@ export function PullRequestReviewersRow({
size="xs"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-foreground">
<span className="block truncate text-sm font-medium text-foreground">
{label}
</span>
<span className="block truncate text-2xs text-muted-foreground">
<span className="block truncate text-xs text-muted-foreground">
{candidate.isAgent ? "Agent · " : ""}
{truncatePubkey(candidate.pubkey)}
</span>
@@ -231,13 +243,13 @@ export function PullRequestReviewersRow({
);
})
) : (
<p className="px-2 py-2 text-xs text-muted-foreground">
<p className="px-3 py-4 text-sm text-muted-foreground">
No matching people or agents.
</p>
)}
</div>
</PopoverContent>
</Popover>
</DialogContent>
</Dialog>
) : null}
</div>
);

View File

@@ -595,6 +595,17 @@ export async function signProjectPullRequestReviewRequest(input: {
});
}
export async function signProjectPullRequestStatus(input: {
targetOwner: string;
repoAddress: string;
pullRequestId: string;
pullRequestAuthor: string;
status: "open" | "draft" | "closed";
createdAt: number;
}): Promise<void> {
await invokeTauri<void>("sign_project_pull_request_status", { input });
}
export async function publishProjectPullRequestMergedStatus(input: {
targetOwner: string;
statusEvent: string;

View File

@@ -4986,6 +4986,7 @@ function buildMockProjectEvents(): RelayEvent[] {
...(kind === KIND_GIT_ISSUE ? [] : [["c", commitHash]]),
...(kind === KIND_GIT_PULL_REQUEST
? [
["h", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
["branch-name", `feature/mock-${dayOffset}-${index}`],
[
"clone",
@@ -9759,6 +9760,44 @@ export function maybeInstallE2eTauriMocks() {
message: `Deleted branch ${input.branch}.`,
};
}
case "sign_project_pull_request_status": {
const { input } = payload as {
input: {
createdAt: number;
pullRequestAuthor: string;
pullRequestId: string;
repoAddress: string;
status: "open" | "draft" | "closed";
targetOwner: string;
};
};
const kind = {
open: KIND_GIT_STATUS_OPEN,
draft: KIND_GIT_STATUS_DRAFT,
closed: KIND_GIT_STATUS_CLOSED,
}[input.status];
const recipientPubkeys = Array.from(
new Set(
[input.targetOwner, input.pullRequestAuthor].map((pubkey) =>
pubkey.trim().toLowerCase(),
),
),
);
const event = createMockEvent(
kind,
"",
[
["e", input.pullRequestId, "", "root"],
["a", input.repoAddress],
...recipientPubkeys.map((pubkey) => ["p", pubkey]),
],
input.targetOwner,
input.createdAt,
);
window.__BUZZ_E2E_SIGNED_EVENTS__?.push(event);
getMockProjectEventStore().push(event);
return null;
}
case "sign_project_pull_request_review_request": {
const { input } = payload as {
input: {

View File

@@ -32,6 +32,70 @@ async function openBuzzProject(page: import("@playwright/test").Page) {
await projectEntry.click();
}
test("same-second request changes supersedes approval", async ({ page }) => {
await enableProjectsFeature(page);
await page.addInitScript(() => {
Date.now = () => 1_900_000_000_000;
});
await installMockBridge(page);
await openBuzzProject(page);
await page.getByRole("tab", { name: "Pull Request" }).click();
const aliceRow = page
.getByTestId("project-pull-request-row")
.filter({ hasText: "alice" })
.first();
await expect(aliceRow).toBeVisible({ timeout: 10_000 });
await aliceRow.getByRole("button", { name: /^#/ }).click();
await page.getByRole("button", { name: "Approve", exact: true }).click();
const approveDialog = page.getByRole("dialog", {
name: "Approve pull request",
});
await approveDialog
.getByRole("textbox", { name: "Approval summary" })
.fill("Approved at the fixed second.");
await approveDialog
.getByRole("button", { name: "Approve", exact: true })
.click();
await expect(page.getByText("Pull request approved.")).toBeVisible();
await expect(
page.getByRole("button", { name: "Approve", exact: true }),
).toHaveCount(0);
const commentComposer = page.getByTestId(
"project-pull-request-comment-composer",
);
await commentComposer
.getByRole("button", { name: "Comment", exact: true })
.click();
await page.getByRole("menuitemradio", { name: "Request changes" }).click();
await commentComposer
.locator('[contenteditable="true"]')
.fill("Changes requested at the same fixed second.");
await commentComposer.getByRole("button", { name: "Send message" }).click();
await expect(page.getByText("Changes requested.")).toBeVisible();
const [approvalEvent, changeRequestEvent] = await page.evaluate(() => {
const decisions =
window.__BUZZ_E2E_SIGNED_EVENTS__?.filter(
(event) =>
event.kind === 1 &&
event.tags.some(
(tag) =>
tag[0] === "t" &&
(tag[1] === "approval" || tag[1] === "changes-requested"),
),
) ?? [];
return [decisions.at(-2), decisions.at(-1)];
});
expect(approvalEvent?.tags).toContainEqual(["t", "approval"]);
expect(changeRequestEvent?.tags).toContainEqual(["t", "changes-requested"]);
expect(changeRequestEvent?.createdAt).toBeGreaterThan(
approvalEvent?.createdAt ?? 0,
);
});
test("PR creator/owner can toggle draft, request reviews, and approve", async ({
page,
}) => {
@@ -54,22 +118,38 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
const header = page.getByRole("heading", { level: 3 });
await expect(header.first()).toBeVisible();
// Owner viewing an open PR: draft toggle and both review decisions are offered.
const convertToDraft = page.getByRole("button", {
name: "Convert to draft",
});
const approve = page.getByRole("button", { name: "Approve", exact: true });
const requestChanges = page.getByRole("button", {
name: "Request changes",
const sourceChannelLink = page.getByRole("button", {
name: "Open author-claimed source channel #general",
exact: true,
});
await expect(convertToDraft).toBeVisible();
await expect(approve).toBeVisible();
await expect(requestChanges).toBeVisible();
await expect(sourceChannelLink).toBeVisible();
// Request a review from bob via the reviewers dropdown.
await page.getByRole("button", { name: "Request", exact: true }).click();
// Owner viewing an open PR: draft toggle and both review decisions are offered.
const morePullRequestActions = page.getByRole("button", {
name: "More pull request actions",
});
const approve = page.getByRole("button", { name: "Approve", exact: true });
const commentComposer = page.getByTestId(
"project-pull-request-comment-composer",
);
const reviewMode = commentComposer.getByRole("button", {
name: "Comment",
exact: true,
});
await expect(morePullRequestActions).toBeVisible();
await expect(approve).toBeVisible();
await expect(reviewMode).toBeVisible();
// Request a review from bob via the centered reviewer dialog.
await page.getByRole("button", { name: "Add Reviewer", exact: true }).click();
await expect(
page.getByRole("dialog").getByRole("heading", { name: "Add reviewer" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/00-add-reviewer-dialog.png`,
});
await page.getByTestId("project-reviewer-search").fill("bob");
await page
.getByTestId(`project-reviewer-result-${TEST_IDENTITIES.bob.pubkey}`)
@@ -92,7 +172,14 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
),
)
.toBe(1);
// The requested reviewer appears in the reviewers row and the timeline.
const reviewHistoryToggle = page.getByTestId(
"project-pull-request-review-history-toggle",
);
await expect(reviewHistoryToggle).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByTestId("project-pull-request-timeline-row"),
).toHaveCount(1);
// The requested reviewer appears in the reviewers row and default timeline.
await expect(page.getByText("Requested a review from bob")).toBeVisible({
timeout: 10_000,
});
@@ -103,22 +190,25 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
path: `${SHOTS}/01-review-requested.png`,
});
// Fire opposite decisions in the same event turn. The first choice wins;
// the shared synchronous guard must prevent the approval from publishing.
await requestChanges.evaluate((requestChangesButton) => {
const approveButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Approve",
);
requestChangesButton.click();
approveButton?.click();
});
await reviewMode.click();
await page.getByRole("menuitemradio", { name: "Request changes" }).click();
await commentComposer
.locator('[contenteditable="true"]')
.fill("Please handle the empty state before merging.");
await commentComposer.getByRole("button", { name: "Send message" }).click();
await expect(page.getByText("Changes requested.")).toBeVisible();
await expect(reviewMode).toHaveText("Comment");
await expect(
page.getByText("requested changes", { exact: true }),
).toBeVisible({
timeout: 10_000,
});
await expect(requestChanges).toHaveCount(0);
page.getByText("Please handle the empty state before merging."),
).toBeVisible();
await expect(
page
.getByTestId("project-pull-request-timeline-row")
.filter({ hasText: "requested changes" }),
).toBeVisible({ timeout: 10_000 });
await expect(
page.getByText("Changes requested", { exact: true }),
).toHaveCount(0);
const changeRequestEvent = await page.evaluate(() =>
window.__BUZZ_E2E_SIGNED_EVENTS__
?.filter(
@@ -130,8 +220,11 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
)
.at(-1),
);
expect(changeRequestEvent?.content).toBe(
"Please handle the empty state before merging.",
);
expect(changeRequestEvent?.tags).toContainEqual(["c", expect.any(String)]);
const rapidDecisionEvents = await page.evaluate(
const reviewDecisionEvents = await page.evaluate(
() =>
window.__BUZZ_E2E_SIGNED_EVENTS__?.filter(
(event) =>
@@ -143,18 +236,56 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
),
) ?? [],
);
expect(rapidDecisionEvents).toHaveLength(1);
expect(reviewDecisionEvents).toHaveLength(1);
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/05-changes-requested.png`,
});
const changeRequestRow = page
.getByTestId("project-pull-request-timeline-row")
.filter({ hasText: "requested changes" })
.first();
await changeRequestRow.getByRole("button").first().hover();
await expect(page.getByTestId("user-profile-popover")).toBeVisible();
await page.mouse.move(0, 0);
await expect(page.getByTestId("user-profile-popover")).toBeHidden();
await expect(reviewHistoryToggle).toContainText("Collapse review history");
const expandedReviewRows = page.getByTestId(
"project-pull-request-timeline-row",
);
await expect(expandedReviewRows).toHaveCount(2);
await expect(expandedReviewRows.nth(0)).toContainText(
"Requested a review from bob",
);
await expect(expandedReviewRows.nth(1)).toContainText("requested changes");
await expect(approve).toBeVisible();
await reviewHistoryToggle.click();
await expect(reviewHistoryToggle).toContainText("Show 2 earlier activities");
await expect(
page.getByTestId("project-pull-request-timeline-row"),
).toHaveCount(0);
await expect(changeRequestRow).toBeHidden();
// Replace the completed change request with an approval. Both decisions
// remain tied to the current commit and their timestamps preserve order.
await approve.click();
await expect(page.getByText("Pull request approved.")).toBeVisible();
await expect(page.getByText("approved these changes")).toBeVisible({
timeout: 10_000,
const approveDialog = page.getByRole("dialog", {
name: "Approve pull request",
});
await approveDialog
.getByRole("textbox", { name: "Approval summary" })
.fill("Ready to merge.");
await approveDialog
.getByRole("button", { name: "Approve", exact: true })
.click();
await expect(page.getByText("Pull request approved.")).toBeVisible();
await expect(
page.getByRole("button", { name: "Approve", exact: true }),
).toHaveCount(0);
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
const approvalEvent = await page.evaluate(() =>
window.__BUZZ_E2E_SIGNED_EVENTS__
?.filter(
@@ -164,10 +295,18 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
)
.at(-1),
);
expect(approvalEvent?.content).toBe("Ready to merge.");
expect(approvalEvent?.tags).toContainEqual(["c", expect.any(String)]);
expect(approvalEvent?.createdAt).toBeGreaterThan(
changeRequestEvent?.createdAt ?? 0,
);
await reviewHistoryToggle.click();
await expect(page.getByText("Ready to merge.")).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByTestId("project-pull-request-timeline-row"),
).toHaveCount(3);
await waitForAnimations(page);
await page.screenshot({
@@ -175,14 +314,43 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
path: `${SHOTS}/02-approved.png`,
});
// Histories over three entries show only the latest three until explicitly
// expanded. Collapsing the whole timeline preserves that inner choice.
await commentComposer
.locator('[contenteditable="true"]')
.fill("Remember the expanded history state.");
await commentComposer.getByRole("button", { name: "Send message" }).click();
await expect(page.getByText("Comment posted.")).toBeVisible();
const timelineRows = page.getByTestId("project-pull-request-timeline-row");
const earlierActivities = page.getByTestId(
"project-pull-request-earlier-activities",
);
await expect(timelineRows).toHaveCount(3);
await expect(earlierActivities).toContainText("Show 1 earlier activity");
await reviewHistoryToggle.click();
await expect(timelineRows).toHaveCount(0);
await reviewHistoryToggle.click();
await expect(timelineRows).toHaveCount(3);
await expect(earlierActivities).toBeVisible();
await earlierActivities.click();
await expect(timelineRows).toHaveCount(4);
await reviewHistoryToggle.click();
await expect(timelineRows).toHaveCount(0);
await reviewHistoryToggle.click();
await expect(timelineRows).toHaveCount(4);
await expect(earlierActivities).toHaveCount(0);
// Convert to draft: badge flips to Draft and the ready button appears.
await convertToDraft.click();
await morePullRequestActions.click();
await page.getByRole("menuitem", { name: "Convert to draft" }).click();
await expect(page.getByText("Converted to draft.")).toBeVisible();
const readyForReview = page.getByRole("button", {
name: "Ready for review",
});
await expect(readyForReview).toBeVisible({ timeout: 10_000 });
await expect(convertToDraft).toHaveCount(0);
await expect(morePullRequestActions).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
@@ -193,9 +361,24 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
// And back: Ready for review restores the Open state.
await readyForReview.click();
await expect(page.getByText("Marked as ready for review.")).toBeVisible();
await expect(
page.getByRole("button", { name: "Convert to draft" }),
).toBeVisible({ timeout: 10_000 });
await expect(morePullRequestActions).toBeVisible({ timeout: 10_000 });
// Closing is reversible, unlike merging: a closed PR can be reopened.
await morePullRequestActions.click();
const closePullRequest = page.getByRole("menuitem", {
name: "Close pull request",
});
await closePullRequest.click();
await expect(page.getByText("Pull request closed.")).toBeVisible();
const reopenPullRequest = page.getByRole("button", {
name: "Reopen pull request",
});
await expect(reopenPullRequest).toBeVisible({ timeout: 10_000 });
await expect(closePullRequest).toHaveCount(0);
await reopenPullRequest.click();
await expect(page.getByText("Pull request reopened.")).toBeVisible();
await expect(morePullRequestActions).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Merge", exact: true }).click();
await expect(page.getByTestId("merge-pull-request-confirm")).toBeVisible();
@@ -266,6 +449,9 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
targetOwner: DEFAULT_MOCK_PUBKEY,
},
});
await sourceChannelLink.click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
test("merge conflicts offer persistent terminal recovery", async ({ page }) => {
@@ -366,8 +552,10 @@ test("reviewer can leave a commit-scoped inline diff comment", async ({
await composer
.locator("[contenteditable='true']")
.fill("Please add a type for this parameter.");
await composer.getByRole("button", { name: "Comment", exact: true }).click();
await page.getByRole("menuitemradio", { name: "Request changes" }).click();
await composer.getByRole("button", { name: "Send message" }).click();
await expect(page.getByText("Line comment posted.")).toBeVisible();
await expect(page.getByText("Changes requested.")).toBeVisible();
await expect
.poll(() =>
@@ -384,6 +572,7 @@ test("reviewer can leave a commit-scoped inline diff comment", async ({
),
);
expect(inlineCommentEvent?.tags).toContainEqual(["t", "inline-comment"]);
expect(inlineCommentEvent?.tags).toContainEqual(["t", "changes-requested"]);
expect(inlineCommentEvent?.tags).toContainEqual(["c", expect.any(String)]);
expect(inlineCommentEvent?.tags).toContainEqual([
"file",
@@ -396,12 +585,39 @@ test("reviewer can leave a commit-scoped inline diff comment", async ({
);
await page.getByRole("tab", { name: "Conversation" }).click();
await expect(
page.getByTestId("project-pull-request-review-history-toggle"),
).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByText("Please add a type for this parameter."),
).toBeVisible();
await expect(
page.getByText("desktop/src/features/projects/ui/ProjectDetailScreen.tsx"),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/04-inline-comment.png`,
});
await page
.getByRole("button", {
name: "Open desktop/src/features/projects/ui/ProjectDetailScreen.tsx new line 3 in Files changed",
})
.click();
await expect(
page.getByRole("tab", { name: /Files changed/ }),
).toHaveAttribute("data-state", "active");
const focusedLine = page.getByTestId("project-diff-focused-line");
await expect(focusedLine).toBeVisible();
await expect(focusedLine).toHaveAttribute(
"data-path",
"desktop/src/features/projects/ui/ProjectDetailScreen.tsx",
);
await expect(focusedLine).toHaveAttribute("data-side", "new");
await expect(focusedLine).toHaveAttribute("data-line", "3");
await focusedLine.click();
await expect(page.getByTestId("project-diff-focused-line")).toHaveCount(0);
});
test("managed agent repository owner can merge", async ({ page }) => {
@@ -430,7 +646,7 @@ test("managed agent repository owner can merge", async ({ page }) => {
.first();
await expect(agentRow).toBeVisible({ timeout: 10_000 });
await agentRow.getByRole("button", { name: /^#/ }).click();
await page.getByRole("button", { name: "Request", exact: true }).click();
await page.getByRole("button", { name: "Add Reviewer", exact: true }).click();
await page.getByTestId("project-reviewer-search").fill("Reviewer Bot");
await page
.getByTestId(`project-reviewer-result-${REVIEWER_AGENT_PUBKEY}`)
@@ -447,6 +663,35 @@ test("managed agent repository owner can merge", async ({ page }) => {
targetOwner: TEST_IDENTITIES.alice.pubkey,
},
});
await page.getByRole("button", { name: "More pull request actions" }).click();
const closePullRequest = page.getByRole("menuitem", {
name: "Close pull request",
});
await expect(closePullRequest).toBeVisible();
await closePullRequest.click();
await expect(page.getByText("Pull request closed.")).toBeVisible();
await page.getByRole("button", { name: "Reopen pull request" }).click();
await expect(page.getByText("Pull request reopened.")).toBeVisible();
const statusPayloads = await page.evaluate(() =>
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
(entry) => entry.command === "sign_project_pull_request_status",
),
);
expect(statusPayloads).toHaveLength(2);
expect(statusPayloads?.map((entry) => entry.payload)).toEqual([
expect.objectContaining({
input: expect.objectContaining({
status: "closed",
targetOwner: TEST_IDENTITIES.alice.pubkey,
}),
}),
expect.objectContaining({
input: expect.objectContaining({
status: "open",
targetOwner: TEST_IDENTITIES.alice.pubkey,
}),
}),
]);
await page.getByRole("button", { name: "Merge", exact: true }).click();
await page.getByTestId("merge-pull-request-confirm-button").click();
await expect(page.getByText("Merged feature into main.")).toBeVisible();