mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Projects v3: unify sharing, discussions, and issue ownership (#5792)
## Summary Projects v3 makes repository work shareable, discussion-aware, and easier to scan in one coherent workspace. People can copy canonical links, reopen the exact workspace tab, understand issue and pull-request context at a glance, find related channel conversations, and assign or unassign issues across Desktop and CLI. - **Unified workspace** — top-level sections sit above repository controls in one rounded workspace, with navigation positioned close to the page heading. README and Files retain branch selection; every section has a labeled icon header, and Issues and Pull Requests expose creation from a consistent right-aligned action. - **Repository management** — the repository selector is always available, including single-repository projects. Its integrated add flow lets project owners create a repository manually or select an existing repository without a separate toolbar button. - **Readable work-item lists** — issue and pull-request rows use plain-language context instead of opaque metadata. Files, commits, issues, pull requests, channels, and contributors share consistent row density and right-aligned timestamps, while deterministic fallback-avatar colors keep participants distinct on light backgrounds. Inbox pull-request metadata wraps between complete phrases and truncates long channel names instead of compressing copy into narrow columns. - **Reliable entity links** — projects, repositories, issues, pull requests, and commits have canonical `buzz://` links, preview cards, OS deep-link routing, and tab-aware navigation. Reopening the same link re-applies its destination instead of leaving the user on a locally selected tab. - **Related conversations** — repository and work-item views surface channels discussing the current entity, including participants, channel navigation, message context, and an explicit notice when discovery reaches its 500-result cap. - **Reversible issue ownership** — trusted assignment and unassignment events work across Desktop, Tauri, `buzz-sdk`, and `buzz issues`. Assignees appear in project views and the assigned inbox, while authorized users can remove assignments directly from the assignee row. Assignment state is derived chronologically from labeled Nostr notes. Issue authors and repository owners may change any assignee; other users may only assign or unassign themselves. Shared golden fixtures keep entity-link grammar and validation aligned across TypeScript and Rust. The branch also updates `webbrowser` to the patched release for RUSTSEC-2026-0257. ### Related issue N/A. ### Testing - [x] `just ci` — formatting, lint, typechecking, unit tests, and builds passed - [x] Full pre-push suite — organization, branch-skew, Desktop checks, typechecking, and tests passed on the latest push - [x] `cargo test -p buzz-cli` and focused `buzz-sdk` assignment tests passed - [x] Focused Tauri recipient-note and 500-result search-limit tests passed - [x] Desktop entity-link and issue-assignment unit tests passed - [x] Playwright smoke coverage passed for assignment, repeated entity-link navigation, repository create/select flows, section headers and actions, timestamp alignment, timeline icons, sentence-style issue/PR metadata, header spacing, avatar contrast, and Inbox metadata at stacked and side-rail breakpoints - [ ] Manual staging pass: link round-trips, Channels tab, assignment flows, and inbox routing ### Screenshots Pull requests explain who opened the request, where it lives, and which branch it comes from; fallback avatars remain visually distinct.  Issues use the same sentence-style hierarchy while keeping status and recency easy to scan.  The wide Inbox detail keeps author, timestamp, and origin context readable beside its metadata rail.  [View the complete six-state Projects v3 screenshot set](https://github.com/block/buzz/pull/5624#issuecomment-5268039672) and [the compact/wide Inbox comparison](https://github.com/block/buzz/pull/5624#issuecomment-5268614585). --- > Supersedes #5624, whose head commit accumulated permanently-queued required check suites (block-dco-check et al.) that GitHub never dispatched. History flattened into a single signed-off commit on latest main; tree verified byte-identical (`git merge-tree`) to merging the original branch into main. --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Co-authored-by: Wintermute <3f1797424fd9ad6653a83665c660517777cd7f8c228c0d5907f49e01537f3ca5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1,8 +1,232 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::client::BuzzClient;
|
||||
use crate::commands::with_git_provenance;
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id};
|
||||
use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta};
|
||||
use nostr::Timestamp;
|
||||
use serde::Deserialize;
|
||||
|
||||
const ISSUE_ASSIGNMENT_LABEL: &str = "assignment";
|
||||
const ISSUE_UNASSIGNMENT_LABEL: &str = "unassignment";
|
||||
|
||||
fn assignment_note_label(assignees: &[String], label: Option<&str>) -> Result<String, CliError> {
|
||||
if let Some(label) = label {
|
||||
let label = label.trim();
|
||||
if label.is_empty() || label.chars().count() > 128 {
|
||||
return Err(CliError::Usage(
|
||||
"--label must be between 1 and 128 characters".into(),
|
||||
));
|
||||
}
|
||||
return Ok(label.to_string());
|
||||
}
|
||||
let prefixes = assignees
|
||||
.iter()
|
||||
.map(|assignee| format!("{}…", assignee.chars().take(8).collect::<String>()))
|
||||
.collect::<Vec<_>>();
|
||||
for included in (0..=prefixes.len()).rev() {
|
||||
let omitted = prefixes.len() - included;
|
||||
let mut generated = prefixes[..included].join(", ");
|
||||
if omitted > 0 {
|
||||
let suffix = format!("{omitted} other{}", if omitted == 1 { "" } else { "s" });
|
||||
if !generated.is_empty() {
|
||||
generated.push_str(", and ");
|
||||
}
|
||||
generated.push_str(&suffix);
|
||||
}
|
||||
if !generated.is_empty() && generated.chars().count() <= 128 {
|
||||
return Ok(generated);
|
||||
}
|
||||
}
|
||||
Err(CliError::Usage(
|
||||
"Unable to generate an assignee label between 1 and 128 characters".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum IssueAssignmentOperation {
|
||||
Assign,
|
||||
Unassign,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AssignmentEvent {
|
||||
id: String,
|
||||
pubkey: String,
|
||||
created_at: u64,
|
||||
tags: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AssignmentQueryEvent {
|
||||
id: String,
|
||||
kind: u16,
|
||||
pubkey: String,
|
||||
created_at: u64,
|
||||
tags: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<&AssignmentQueryEvent> for AssignmentEvent {
|
||||
fn from(event: &AssignmentQueryEvent) -> Self {
|
||||
Self {
|
||||
id: event.id.clone(),
|
||||
pubkey: event.pubkey.clone(),
|
||||
created_at: event.created_at,
|
||||
tags: event.tags.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct AssignmentState {
|
||||
assignees: HashSet<String>,
|
||||
heads: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedAssignmentOperation {
|
||||
id: String,
|
||||
is_assignment: bool,
|
||||
pubkeys: Vec<String>,
|
||||
prior: Option<String>,
|
||||
}
|
||||
|
||||
fn tag_values<'a>(event: &'a AssignmentEvent, name: &str) -> Vec<&'a str> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
if tag.first().map(String::as_str) != Some(name) {
|
||||
return None;
|
||||
}
|
||||
tag.get(1)
|
||||
.map(String::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_root_tag(event: &AssignmentEvent, issue_id: &str) -> bool {
|
||||
event.tags.iter().any(|tag| {
|
||||
matches!(
|
||||
tag.as_slice(),
|
||||
[name, value, ..] if name == "e" && value == issue_id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_hex64(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn apply_assignment_operation(state: &mut AssignmentState, operation: ParsedAssignmentOperation) {
|
||||
if let Some(prior) = operation.prior.as_ref() {
|
||||
let Some(target) = operation.pubkeys.first() else {
|
||||
return;
|
||||
};
|
||||
if state.heads.get(target) != Some(prior) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for pubkey in operation.pubkeys {
|
||||
if operation.is_assignment {
|
||||
state.assignees.insert(pubkey.clone());
|
||||
} else {
|
||||
state.assignees.remove(&pubkey);
|
||||
}
|
||||
state.heads.insert(pubkey, operation.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn reduce_assignment_operations(
|
||||
issue_id: &str,
|
||||
issue_author: &str,
|
||||
repo_owner: &str,
|
||||
events: &[AssignmentEvent],
|
||||
) -> AssignmentState {
|
||||
let issue_author = issue_author.to_ascii_lowercase();
|
||||
let repo_owner = repo_owner.to_ascii_lowercase();
|
||||
let mut events = events.iter().collect::<Vec<_>>();
|
||||
events.sort_by(|left, right| {
|
||||
left.created_at
|
||||
.cmp(&right.created_at)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
|
||||
let mut uncaused_self_operations = Vec::new();
|
||||
let mut authoritative_operations = Vec::new();
|
||||
let mut causal_self_operations = Vec::new();
|
||||
for event in events {
|
||||
if !has_root_tag(event, issue_id) {
|
||||
continue;
|
||||
}
|
||||
let labels = tag_values(event, "t");
|
||||
let is_assignment = labels.contains(&ISSUE_ASSIGNMENT_LABEL);
|
||||
let is_unassignment = labels.contains(&ISSUE_UNASSIGNMENT_LABEL);
|
||||
if is_assignment == is_unassignment {
|
||||
continue;
|
||||
}
|
||||
let signer = event.pubkey.to_ascii_lowercase();
|
||||
let pubkeys = tag_values(event, "p")
|
||||
.into_iter()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect::<Vec<_>>();
|
||||
let is_authoritative = signer == issue_author || signer == repo_owner;
|
||||
let is_self_operation = pubkeys.len() == 1 && pubkeys[0] == signer;
|
||||
if !is_authoritative && !is_self_operation {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut operation = ParsedAssignmentOperation {
|
||||
id: event.id.to_ascii_lowercase(),
|
||||
is_assignment,
|
||||
pubkeys,
|
||||
prior: None,
|
||||
};
|
||||
if is_authoritative {
|
||||
authoritative_operations.push(operation);
|
||||
continue;
|
||||
}
|
||||
|
||||
let prior_tags = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.first().map(String::as_str) == Some("prior"))
|
||||
.collect::<Vec<_>>();
|
||||
if prior_tags.is_empty() {
|
||||
uncaused_self_operations.push(operation);
|
||||
} else if prior_tags.len() == 1 && prior_tags[0].get(1).is_some_and(|prior| is_hex64(prior))
|
||||
{
|
||||
operation.prior = prior_tags[0].get(1).map(|prior| prior.to_ascii_lowercase());
|
||||
causal_self_operations.push(operation);
|
||||
}
|
||||
}
|
||||
|
||||
let mut state = AssignmentState::default();
|
||||
for operation in uncaused_self_operations
|
||||
.into_iter()
|
||||
.chain(authoritative_operations)
|
||||
.chain(causal_self_operations)
|
||||
{
|
||||
apply_assignment_operation(&mut state, operation);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
struct IssueAssignmentContext {
|
||||
created_at: u64,
|
||||
prior: Option<String>,
|
||||
}
|
||||
|
||||
impl IssueAssignmentOperation {
|
||||
fn content(self, label: &str) -> String {
|
||||
match self {
|
||||
Self::Assign => format!("Assigned this issue to {label}"),
|
||||
Self::Unassign => format!("Unassigned {label} from this issue"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cmd_create_issue(
|
||||
client: &BuzzClient,
|
||||
@@ -40,6 +264,185 @@ pub async fn cmd_create_issue(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish an issue assignment: a kind:1 comment on the issue whose `p`
|
||||
/// tags are the assignees, labeled `t: assignment` (same event shape the
|
||||
/// Desktop app writes). Clients trust it when signed by the issue author
|
||||
/// or repo owner, or when it is a self-assignment.
|
||||
pub async fn cmd_assign_issue(
|
||||
client: &BuzzClient,
|
||||
issue: &str,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
assignees: &[String],
|
||||
label: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
publish_issue_assignment_operation(
|
||||
client,
|
||||
issue,
|
||||
repo_owner,
|
||||
repo_id,
|
||||
assignees,
|
||||
label,
|
||||
IssueAssignmentOperation::Assign,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Publish an issue unassignment with the same trust rules as assignment.
|
||||
pub async fn cmd_unassign_issue(
|
||||
client: &BuzzClient,
|
||||
issue: &str,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
assignees: &[String],
|
||||
label: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
publish_issue_assignment_operation(
|
||||
client,
|
||||
issue,
|
||||
repo_owner,
|
||||
repo_id,
|
||||
assignees,
|
||||
label,
|
||||
IssueAssignmentOperation::Unassign,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn publish_issue_assignment_operation(
|
||||
client: &BuzzClient,
|
||||
issue: &str,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
assignees: &[String],
|
||||
label: Option<&str>,
|
||||
operation: IssueAssignmentOperation,
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(issue)?;
|
||||
validate_hex64(repo_owner)?;
|
||||
validate_repo_id(repo_id)?;
|
||||
for assignee in assignees {
|
||||
validate_hex64(assignee)?;
|
||||
}
|
||||
|
||||
let label = assignment_note_label(assignees, label)?;
|
||||
let content = operation.content(&label);
|
||||
let repo = GitRepoCoord {
|
||||
owner: repo_owner.to_string(),
|
||||
id: repo_id.to_string(),
|
||||
};
|
||||
let signer = client.keys().public_key().to_hex();
|
||||
let is_self_service = assignees.len() == 1
|
||||
&& assignees[0].eq_ignore_ascii_case(&signer)
|
||||
&& !signer.eq_ignore_ascii_case(repo_owner);
|
||||
let context = issue_assignment_context(client, issue, &repo, &signer, is_self_service).await?;
|
||||
let builder = match (operation, is_self_service) {
|
||||
(IssueAssignmentOperation::Assign, true) => {
|
||||
buzz_sdk::build_git_issue_assignment_with_prior(
|
||||
&repo,
|
||||
issue,
|
||||
assignees,
|
||||
&content,
|
||||
context.prior.as_deref(),
|
||||
)
|
||||
}
|
||||
(IssueAssignmentOperation::Unassign, true) => {
|
||||
buzz_sdk::build_git_issue_unassignment_with_prior(
|
||||
&repo,
|
||||
issue,
|
||||
assignees,
|
||||
&content,
|
||||
context.prior.as_deref(),
|
||||
)
|
||||
}
|
||||
(IssueAssignmentOperation::Assign, false) => {
|
||||
buzz_sdk::build_git_issue_assignment(&repo, issue, assignees, &content)
|
||||
}
|
||||
(IssueAssignmentOperation::Unassign, false) => {
|
||||
buzz_sdk::build_git_issue_unassignment(&repo, issue, assignees, &content)
|
||||
}
|
||||
}
|
||||
.map(|builder| builder.custom_created_at(Timestamp::from_secs(context.created_at)));
|
||||
let event = client.sign_event(builder.map_err(sdk_err)?)?;
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn issue_assignment_context(
|
||||
client: &BuzzClient,
|
||||
issue: &str,
|
||||
repo: &GitRepoCoord,
|
||||
signer: &str,
|
||||
include_prior: bool,
|
||||
) -> Result<IssueAssignmentContext, CliError> {
|
||||
let root_filter = serde_json::json!({
|
||||
"kinds": [1621],
|
||||
"ids": [issue],
|
||||
"limit": 1
|
||||
});
|
||||
let assignment_filter = serde_json::json!({
|
||||
"kinds": [1],
|
||||
"#e": [issue],
|
||||
"#t": [ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL],
|
||||
"limit": 500
|
||||
});
|
||||
let signer_comment_filter = serde_json::json!({
|
||||
"kinds": [1],
|
||||
"#e": [issue],
|
||||
"authors": [signer],
|
||||
"limit": 1
|
||||
});
|
||||
let response = client
|
||||
.query_multi(&[root_filter, assignment_filter, signer_comment_filter])
|
||||
.await?;
|
||||
// CLI read responses intentionally omit signatures, so deserialize only
|
||||
// the event fields needed for assignment reduction.
|
||||
let events = serde_json::from_str::<Vec<AssignmentQueryEvent>>(&response)
|
||||
.map_err(|error| CliError::Other(format!("parse issue assignment context: {error}")))?;
|
||||
let root = events
|
||||
.iter()
|
||||
.find(|event| event.kind == 1621 && event.id == issue)
|
||||
.ok_or_else(|| CliError::Other("issue root was not returned by the relay".into()))?;
|
||||
let expected_repo = format!("30617:{}:{}", repo.owner.to_ascii_lowercase(), repo.id);
|
||||
let root_matches_repo = root.tags.iter().any(|tag| {
|
||||
tag.as_slice().first().map(String::as_str) == Some("a")
|
||||
&& tag.as_slice().get(1) == Some(&expected_repo)
|
||||
});
|
||||
if !root_matches_repo {
|
||||
return Err(CliError::Other(
|
||||
"issue root does not match the requested repository".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let comments = events
|
||||
.iter()
|
||||
.filter(|event| event.kind == 1)
|
||||
.map(AssignmentEvent::from)
|
||||
.collect::<Vec<_>>();
|
||||
let latest = comments
|
||||
.iter()
|
||||
.filter(|event| event.pubkey.eq_ignore_ascii_case(signer))
|
||||
.map(|event| event.created_at)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let created_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|error| CliError::Other(format!("read system clock: {error}")))?
|
||||
.as_secs()
|
||||
.max(latest.saturating_add(1));
|
||||
let prior = include_prior
|
||||
.then(|| {
|
||||
reduce_assignment_operations(issue, &root.pubkey, &repo.owner, &comments)
|
||||
.heads
|
||||
.get(&signer.to_ascii_lowercase())
|
||||
.cloned()
|
||||
})
|
||||
.flatten();
|
||||
Ok(IssueAssignmentContext { created_at, prior })
|
||||
}
|
||||
|
||||
pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> {
|
||||
validate_hex64(event)?;
|
||||
let filter = serde_json::json!({
|
||||
@@ -202,5 +605,197 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
IssuesCmd::Assign {
|
||||
issue,
|
||||
repo_owner,
|
||||
repo_id,
|
||||
assignee,
|
||||
label,
|
||||
} => {
|
||||
cmd_assign_issue(
|
||||
client,
|
||||
&issue,
|
||||
&repo_owner,
|
||||
&repo_id,
|
||||
&assignee,
|
||||
label.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
IssuesCmd::Unassign {
|
||||
issue,
|
||||
repo_owner,
|
||||
repo_id,
|
||||
assignee,
|
||||
label,
|
||||
} => {
|
||||
cmd_unassign_issue(
|
||||
client,
|
||||
&issue,
|
||||
&repo_owner,
|
||||
&repo_id,
|
||||
&assignee,
|
||||
label.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
assignment_note_label, reduce_assignment_operations, AssignmentEvent, AssignmentQueryEvent,
|
||||
ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL,
|
||||
};
|
||||
|
||||
const ISSUE: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
|
||||
const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
const OWNER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const VOLUNTEER: &str = "5555555555555555555555555555555555555555555555555555555555555555";
|
||||
|
||||
fn assignment_event(
|
||||
pubkey: &str,
|
||||
id: &str,
|
||||
assignment: bool,
|
||||
created_at: u64,
|
||||
prior: Option<&str>,
|
||||
) -> AssignmentEvent {
|
||||
let mut tags = vec![
|
||||
vec!["e".into(), ISSUE.into(), "".into(), "root".into()],
|
||||
vec!["p".into(), VOLUNTEER.into()],
|
||||
vec![
|
||||
"t".into(),
|
||||
if assignment {
|
||||
ISSUE_ASSIGNMENT_LABEL.into()
|
||||
} else {
|
||||
ISSUE_UNASSIGNMENT_LABEL.into()
|
||||
},
|
||||
],
|
||||
];
|
||||
if let Some(prior) = prior {
|
||||
tags.push(vec!["prior".into(), prior.into()]);
|
||||
}
|
||||
AssignmentEvent {
|
||||
id: id.into(),
|
||||
pubkey: pubkey.into(),
|
||||
created_at,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_note_label_enforces_desktop_length_limit() {
|
||||
let assignees = vec!["a".repeat(64)];
|
||||
assert_eq!(
|
||||
assignment_note_label(&assignees, Some(" Thomas ")).unwrap(),
|
||||
"Thomas"
|
||||
);
|
||||
assert!(assignment_note_label(&assignees, Some("")).is_err());
|
||||
assert!(assignment_note_label(&assignees, Some(&"x".repeat(129))).is_err());
|
||||
assert_eq!(
|
||||
assignment_note_label(&assignees, None).unwrap(),
|
||||
"aaaaaaaa…"
|
||||
);
|
||||
let many_assignees = (0..50)
|
||||
.map(|index| format!("{index:064x}"))
|
||||
.collect::<Vec<_>>();
|
||||
let generated = assignment_note_label(&many_assignees, None).unwrap();
|
||||
assert!(generated.chars().count() <= 128);
|
||||
assert!(generated.contains("others"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_query_event_accepts_sig_stripped_cli_reads() {
|
||||
let event = serde_json::from_value::<AssignmentQueryEvent>(serde_json::json!({
|
||||
"id": "1".repeat(64),
|
||||
"kind": 1,
|
||||
"pubkey": VOLUNTEER,
|
||||
"created_at": 200,
|
||||
"tags": [["e", ISSUE, "", "root"]]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(event.kind, 1);
|
||||
assert_eq!(event.pubkey, VOLUNTEER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncaused_future_self_operations_lose_to_authority() {
|
||||
let owner_unassign = "1".repeat(64);
|
||||
let state = reduce_assignment_operations(
|
||||
ISSUE,
|
||||
AUTHOR,
|
||||
OWNER,
|
||||
&[
|
||||
assignment_event(VOLUNTEER, &"2".repeat(64), true, 1_000, None),
|
||||
assignment_event(OWNER, &owner_unassign, false, 200, None),
|
||||
],
|
||||
);
|
||||
assert!(!state.assignees.contains(VOLUNTEER));
|
||||
assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_unassign));
|
||||
|
||||
let owner_assign = "3".repeat(64);
|
||||
let state = reduce_assignment_operations(
|
||||
ISSUE,
|
||||
AUTHOR,
|
||||
OWNER,
|
||||
&[
|
||||
assignment_event(VOLUNTEER, &"4".repeat(64), false, 1_000, None),
|
||||
assignment_event(OWNER, &owner_assign, true, 200, None),
|
||||
],
|
||||
);
|
||||
assert!(state.assignees.contains(VOLUNTEER));
|
||||
assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_assign));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn causal_self_operations_can_follow_authority() {
|
||||
let owner_assign = "5".repeat(64);
|
||||
let self_unassign = "6".repeat(64);
|
||||
let state = reduce_assignment_operations(
|
||||
ISSUE,
|
||||
AUTHOR,
|
||||
OWNER,
|
||||
&[
|
||||
assignment_event(OWNER, &owner_assign, true, 200, None),
|
||||
assignment_event(VOLUNTEER, &self_unassign, false, 300, Some(&owner_assign)),
|
||||
],
|
||||
);
|
||||
assert!(!state.assignees.contains(VOLUNTEER));
|
||||
assert_eq!(state.heads.get(VOLUNTEER), Some(&self_unassign));
|
||||
|
||||
let owner_unassign = "7".repeat(64);
|
||||
let self_assign = "8".repeat(64);
|
||||
let state = reduce_assignment_operations(
|
||||
ISSUE,
|
||||
AUTHOR,
|
||||
OWNER,
|
||||
&[
|
||||
assignment_event(OWNER, &owner_unassign, false, 200, None),
|
||||
assignment_event(VOLUNTEER, &self_assign, true, 300, Some(&owner_unassign)),
|
||||
],
|
||||
);
|
||||
assert!(state.assignees.contains(VOLUNTEER));
|
||||
assert_eq!(state.heads.get(VOLUNTEER), Some(&self_assign));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_causal_self_operation_is_ignored() {
|
||||
let initial_assign = "9".repeat(64);
|
||||
let owner_unassign = "a".repeat(64);
|
||||
let state = reduce_assignment_operations(
|
||||
ISSUE,
|
||||
AUTHOR,
|
||||
OWNER,
|
||||
&[
|
||||
assignment_event(OWNER, &initial_assign, true, 100, None),
|
||||
assignment_event(OWNER, &owner_unassign, false, 200, None),
|
||||
assignment_event(VOLUNTEER, &"c".repeat(64), true, 300, Some(&initial_assign)),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(!state.assignees.contains(VOLUNTEER));
|
||||
assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_unassign));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,13 +108,29 @@ fn make_tag(parts: &[&str]) -> Result<Tag, CliError> {
|
||||
|
||||
// ── Submit helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> {
|
||||
/// Submit a project event and print the relay's write response.
|
||||
///
|
||||
/// `link_slug` carries the project's d-tag on creates whose slug fits the
|
||||
/// `buzz://` link charset; the response then also carries a `link` field,
|
||||
/// which renders as a rich preview card in Buzz Desktop when included in a
|
||||
/// chat message — agents announce projects with it (see base_prompt.md).
|
||||
async fn submit_project(
|
||||
client: &BuzzClient,
|
||||
builder: EventBuilder,
|
||||
link_slug: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
let event = client.sign_event(builder)?;
|
||||
let owner = event.pubkey.to_hex();
|
||||
let raw = client.submit_event(event).await?;
|
||||
println!(
|
||||
"{}",
|
||||
parse_write_response(&raw, "project changed concurrently; retry")?
|
||||
);
|
||||
let response = parse_write_response(&raw, "project changed concurrently; retry")?;
|
||||
match link_slug {
|
||||
Some(slug) => crate::client::print_create_response(
|
||||
&response,
|
||||
"link",
|
||||
&crate::links::project_link(&owner, slug),
|
||||
),
|
||||
None => println!("{response}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -207,7 +223,15 @@ pub async fn cmd_create(
|
||||
// ── Build via Layer B (enforces all writer policy) ────────────────────
|
||||
let builder = build_project(slug, name, description, &members, channel, visibility)
|
||||
.map_err(|e| CliError::Usage(e.to_string()))?;
|
||||
submit_project(client, builder).await
|
||||
|
||||
// Slugs wider than the link charset stay linkless rather than emitting a
|
||||
// `link` no client can parse.
|
||||
submit_project(
|
||||
client,
|
||||
builder,
|
||||
crate::links::is_linkable_dtag(slug).then_some(slug),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `buzz projects get`
|
||||
@@ -320,7 +344,7 @@ pub async fn cmd_add_repo(
|
||||
}
|
||||
|
||||
let builder = rebuild_project(&head.content, tags, next_ts)?;
|
||||
submit_project(client, builder).await
|
||||
submit_project(client, builder, None).await
|
||||
}
|
||||
|
||||
/// `buzz projects remove-repo`
|
||||
@@ -383,7 +407,7 @@ pub async fn cmd_remove_repo(
|
||||
|
||||
// Single rebuild validates the full envelope and strips any remaining auth.
|
||||
let builder = rebuild_project(&head.content, tags, next_ts)?;
|
||||
submit_project(client, builder).await
|
||||
submit_project(client, builder, None).await
|
||||
}
|
||||
|
||||
/// `buzz projects update`
|
||||
@@ -484,7 +508,7 @@ pub async fn cmd_update(
|
||||
let builder = build_project_with_tags(&head.content, tags)
|
||||
.map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))?
|
||||
.custom_created_at(next_ts);
|
||||
submit_project(client, builder).await
|
||||
submit_project(client, builder, None).await
|
||||
}
|
||||
|
||||
/// `buzz projects delete`
|
||||
|
||||
@@ -1695,6 +1695,47 @@ pub enum IssuesCmd {
|
||||
#[arg(long = "to")]
|
||||
to: Vec<String>,
|
||||
},
|
||||
/// Assign an issue to one or more people or agents. Only assignments
|
||||
/// signed by the issue author or repo owner are trusted by clients;
|
||||
/// anyone may assign themselves (sole assignee = your own pubkey).
|
||||
Assign {
|
||||
/// Issue event id (64-char hex)
|
||||
#[arg(long)]
|
||||
issue: String,
|
||||
/// Repo owner pubkey (64-char hex)
|
||||
#[arg(long)]
|
||||
repo_owner: String,
|
||||
/// Repo identifier (d-tag)
|
||||
#[arg(long)]
|
||||
repo_id: String,
|
||||
/// Assignee pubkey (64-char hex) — can be specified multiple times
|
||||
#[arg(long = "assignee", required = true)]
|
||||
assignee: Vec<String>,
|
||||
/// Human-readable assignee name(s) for the note body, e.g. "Thomas".
|
||||
/// Defaults to the truncated assignee pubkeys.
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
},
|
||||
/// Remove one or more assignees from an issue. Issue authors and repo
|
||||
/// owners may remove anyone; other users may remove only themselves.
|
||||
Unassign {
|
||||
/// Issue event id (64-char hex)
|
||||
#[arg(long)]
|
||||
issue: String,
|
||||
/// Repo owner pubkey (64-char hex)
|
||||
#[arg(long)]
|
||||
repo_owner: String,
|
||||
/// Repo identifier (d-tag)
|
||||
#[arg(long)]
|
||||
repo_id: String,
|
||||
/// Assignee pubkey to remove — can be specified multiple times
|
||||
#[arg(long = "assignee", required = true)]
|
||||
assignee: Vec<String>,
|
||||
/// Human-readable assignee name(s) for the note body.
|
||||
/// Defaults to the truncated assignee pubkeys.
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -2290,7 +2331,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
names(&cmd, "issues"),
|
||||
vec!["create", "get", "list", "status"]
|
||||
vec!["assign", "create", "get", "list", "status", "unassign"]
|
||||
);
|
||||
assert_eq!(names(&cmd, "media"), vec!["get"]);
|
||||
assert_eq!(names(&cmd, "upload"), vec!["file"]);
|
||||
@@ -2319,7 +2360,7 @@ mod tests {
|
||||
("dms", 4),
|
||||
("emoji", 5),
|
||||
("feed", 1),
|
||||
("issues", 4),
|
||||
("issues", 6),
|
||||
("media", 1),
|
||||
("messages", 8),
|
||||
("pack", 2),
|
||||
|
||||
@@ -8,12 +8,41 @@
|
||||
//!
|
||||
//! Callers are expected to validate inputs first (`validate_hex64`,
|
||||
//! `validate_repo_id`); the identifier charsets need no URL encoding.
|
||||
//!
|
||||
//! Coordinate links additionally accept an optional `&tab=<tab>` parameter
|
||||
//! (`files|commits|issues|prs|contributors|channels`) selecting a workspace tab on
|
||||
//! the receiving side. The CLI builders emit the canonical no-tab form
|
||||
//! (overview); the parameter exists for the desktop's tab-aware copy-link
|
||||
//! button.
|
||||
|
||||
/// Whether a d-tag can be expressed in a `buzz://` link.
|
||||
///
|
||||
/// Project slugs accept up to 1024 bytes of arbitrary UTF-8, but the link
|
||||
/// format is restricted to `[a-zA-Z0-9._-]{1,64}` (no leading dot, no `..`)
|
||||
/// so links need no escaping and stay safe to paste. Callers must check
|
||||
/// before building a link and omit the field when it returns false, rather
|
||||
/// than emitting a link no client can parse. Mirrors `isValidDtag` in
|
||||
/// `desktop/src/shared/lib/entityLink.ts`.
|
||||
pub fn is_linkable_dtag(dtag: &str) -> bool {
|
||||
!dtag.is_empty()
|
||||
&& dtag.len() <= 64
|
||||
&& !dtag.starts_with('.')
|
||||
&& !dtag.contains("..")
|
||||
&& dtag
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// Build a `buzz://repo` link for a repository announcement (kind 30617).
|
||||
pub fn repo_link(owner: &str, repo_id: &str) -> String {
|
||||
format!("buzz://repo?owner={owner}&d={repo_id}")
|
||||
}
|
||||
|
||||
/// Build a `buzz://project` link for a project announcement (kind 30621).
|
||||
pub fn project_link(owner: &str, project_id: &str) -> String {
|
||||
format!("buzz://project?owner={owner}&d={project_id}")
|
||||
}
|
||||
|
||||
/// Build a `buzz://pr` link for a pull request event (kind 1618).
|
||||
pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String {
|
||||
format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}")
|
||||
@@ -27,25 +56,49 @@ pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
|
||||
const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
|
||||
fn golden() -> Value {
|
||||
serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json"))
|
||||
.expect("valid entity-links golden fixture")
|
||||
}
|
||||
|
||||
// Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs
|
||||
// ("builders emit the canonical cross-language link format").
|
||||
#[test]
|
||||
fn golden_format_matches_desktop() {
|
||||
let golden = golden();
|
||||
let owner = golden["owner"].as_str().unwrap();
|
||||
let event_id = golden["eventId"].as_str().unwrap();
|
||||
let dtag = golden["dtag"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
pull_request_link(EVENT_ID, OWNER, "buzz-world"),
|
||||
format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world")
|
||||
pull_request_link(event_id, owner, dtag),
|
||||
golden["links"]["pullRequest"].as_str().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
issue_link(EVENT_ID, OWNER, "buzz-world"),
|
||||
format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world")
|
||||
issue_link(event_id, owner, dtag),
|
||||
golden["links"]["issue"].as_str().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
repo_link(OWNER, "buzz-world"),
|
||||
format!("buzz://repo?owner={OWNER}&d=buzz-world")
|
||||
repo_link(owner, dtag),
|
||||
golden["links"]["repository"].as_str().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
project_link(owner, dtag),
|
||||
golden["links"]["project"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkable_dtag_matches_the_desktop_charset() {
|
||||
let golden = golden();
|
||||
for ok in golden["validDtags"].as_array().unwrap() {
|
||||
let ok = ok.as_str().unwrap();
|
||||
assert!(is_linkable_dtag(ok), "{ok:?} should be linkable");
|
||||
}
|
||||
assert!(is_linkable_dtag(&"a".repeat(64)));
|
||||
for bad in golden["invalidDtags"].as_array().unwrap() {
|
||||
let bad = bad.as_str().unwrap();
|
||||
assert!(!is_linkable_dtag(bad), "{bad:?} should not be linkable");
|
||||
}
|
||||
assert!(!is_linkable_dtag(&"a".repeat(65)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user