mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): make pull request reviews actionable (#2510)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
"—"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user