From 146cd8d51aa928c02fc2d1828e5e019b560e90a4 Mon Sep 17 00:00:00 2001 From: contentscoin Date: Sun, 30 Aug 2026 20:03:42 +0900 Subject: [PATCH 1/5] feat(issues): add graph task transitions Signed-off-by: contentscoin --- crates/buzz-cli/src/commands/issues.rs | 461 ++++++++++++++++++++++++- crates/buzz-cli/src/lib.rs | 96 +++++ crates/buzz-sdk/src/builders.rs | 132 +++++++ 3 files changed, 685 insertions(+), 4 deletions(-) diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 7c90d47b423..20c52d8b1c5 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -59,7 +59,7 @@ struct AssignmentEvent { tags: Vec>, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] struct AssignmentQueryEvent { id: String, kind: u16, @@ -220,6 +220,132 @@ struct IssueAssignmentContext { prior: Option, } +const TASK_TRANSITION_LABEL: &str = "task-transition"; +const GRAPH_LABEL: &str = "graph"; + +struct TaskTransitionContext { + head: Option, + state: Option, +} + +fn query_tag_values<'a>(event: &'a AssignmentQueryEvent, name: &str) -> Vec<&'a str> { + event + .tags + .iter() + .filter_map(|tag| { + (tag.first().map(String::as_str) == Some(name)) + .then(|| tag.get(1).map(String::as_str)) + .flatten() + .filter(|value| !value.is_empty()) + }) + .collect() +} + +fn issue_dependencies(event: &AssignmentQueryEvent) -> Vec { + query_tag_values(event, "depends-on") + .into_iter() + .map(str::to_ascii_lowercase) + .collect() +} + +fn dependency_graph_has_cycle( + issue: &str, + issues: &HashMap, + visiting: &mut HashSet, + visited: &mut HashSet, +) -> bool { + if visited.contains(issue) { + return false; + } + if !visiting.insert(issue.to_string()) { + return true; + } + if let Some(event) = issues.get(issue) { + for dependency in issue_dependencies(event) { + if dependency_graph_has_cycle(&dependency, issues, visiting, visited) { + return true; + } + } + } + visiting.remove(issue); + visited.insert(issue.to_string()); + false +} + +fn dependency_is_resolved( + dependency: &AssignmentQueryEvent, + repo_owner: &str, + status_events: &[&AssignmentQueryEvent], +) -> bool { + status_events + .iter() + .filter(|event| { + (event.pubkey.eq_ignore_ascii_case(&dependency.pubkey) + || event.pubkey.eq_ignore_ascii_case(repo_owner)) + && query_tag_values(event, "e") + .iter() + .any(|root| root.eq_ignore_ascii_case(&dependency.id)) + }) + .max_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }) + .is_some_and(|event| event.kind == 1631) +} + +fn reduce_task_transitions( + issue: &AssignmentQueryEvent, + repo_owner: &str, + assignees: &HashSet, + events: &[&AssignmentQueryEvent], +) -> TaskTransitionContext { + let mut events = events + .iter() + .filter(|event| { + event.kind == 1 + && query_tag_values(event, "e") + .iter() + .any(|root| root.eq_ignore_ascii_case(&issue.id)) + && query_tag_values(event, "t").contains(&TASK_TRANSITION_LABEL) + }) + .copied() + .collect::>(); + events.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let mut head: Option = None; + let mut state: Option = None; + for event in events { + let signer = event.pubkey.to_ascii_lowercase(); + if !event.pubkey.eq_ignore_ascii_case(&issue.pubkey) + && !event.pubkey.eq_ignore_ascii_case(repo_owner) + && !assignees.contains(&signer) + { + continue; + } + let from = query_tag_values(event, "from"); + let to = query_tag_values(event, "to"); + let prior = query_tag_values(event, "prior"); + if from.len() != 1 || to.len() != 1 || prior.len() > 1 { + continue; + } + let causal = match head.as_deref() { + None => prior.is_empty(), + Some(current) => prior.first().is_some_and(|value| *value == current), + }; + if !causal || state.as_deref().is_some_and(|current| current != from[0]) { + continue; + } + head = Some(event.id.to_ascii_lowercase()); + state = Some(to[0].to_string()); + } + TaskTransitionContext { head, state } +} + impl IssueAssignmentOperation { fn content(self, label: &str) -> String { match self { @@ -237,6 +363,7 @@ pub async fn cmd_create_issue( content: &str, labels: &[String], to: &[String], + dependencies: &[String], ) -> Result<(), CliError> { validate_hex64(repo_owner)?; validate_repo_id(repo_id)?; @@ -245,6 +372,7 @@ pub async fn cmd_create_issue( let meta = GitIssueMeta { labels: labels.to_vec(), recipients: to.to_vec(), + dependencies: dependencies.to_vec(), }; let repo = GitRepoCoord { @@ -477,6 +605,159 @@ async fn issue_assignment_context( Ok(IssueAssignmentContext { created_at, prior }) } +#[allow(clippy::too_many_arguments)] +pub async fn cmd_transition_issue( + client: &BuzzClient, + issue: &str, + repo_owner: &str, + repo_id: &str, + from: &str, + to: &str, + content: &str, + gate: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(issue)?; + validate_hex64(repo_owner)?; + validate_repo_id(repo_id)?; + let body = read_or_stdin(content)?; + let repo = GitRepoCoord { + owner: repo_owner.to_string(), + id: repo_id.to_string(), + }; + let repo_address = format!("30617:{repo_owner}:{repo_id}"); + let root_filter = serde_json::json!({ + "kinds": [1621], + "#a": [repo_address.clone()], + "limit": 1000 + }); + let operation_filter = serde_json::json!({ + "kinds": [1], + "#e": [issue], + "#t": [ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, TASK_TRANSITION_LABEL], + "limit": 500 + }); + let response = client.query_multi(&[root_filter, operation_filter]).await?; + let mut events = serde_json::from_str::>(&response) + .map_err(|error| CliError::Other(format!("parse task transition context: {error}")))?; + let root = events + .iter() + .find(|event| event.kind == 1621 && event.id.eq_ignore_ascii_case(issue)) + .cloned() + .ok_or_else(|| CliError::Other("issue root was not returned by the relay".into()))?; + if !query_tag_values(&root, "t") + .iter() + .any(|label| label.eq_ignore_ascii_case(GRAPH_LABEL)) + { + return Err(CliError::Usage( + "task transitions require the issue to have a graph label".into(), + )); + } + + let dependency_ids = issue_dependencies(&root); + if !dependency_ids.is_empty() { + let status_filter = serde_json::json!({ + "kinds": [1630, 1631, 1632, 1633], + "#e": dependency_ids, + "limit": 1000 + }); + let status_response = client.query(&status_filter).await?; + let mut status_events = serde_json::from_str::>(&status_response) + .map_err(|error| { + CliError::Other(format!("parse dependency status context: {error}")) + })?; + events.append(&mut status_events); + } + + let issues = events + .iter() + .filter(|event| event.kind == 1621) + .map(|event| (event.id.to_ascii_lowercase(), event)) + .collect::>(); + if dependency_graph_has_cycle( + &issue.to_ascii_lowercase(), + &issues, + &mut HashSet::new(), + &mut HashSet::new(), + ) { + return Err(CliError::Usage( + "task dependency graph contains a cycle".into(), + )); + } + let statuses = events + .iter() + .filter(|event| (1630..=1633).contains(&event.kind)) + .collect::>(); + for dependency_id in issue_dependencies(&root) { + let dependency = issues.get(&dependency_id).ok_or_else(|| { + CliError::Usage(format!( + "dependency {dependency_id} is missing from the requested repository" + )) + })?; + if !dependency_is_resolved(dependency, repo_owner, &statuses) { + return Err(CliError::Usage(format!( + "dependency {dependency_id} is not resolved" + ))); + } + } + + let comments = events + .iter() + .filter(|event| event.kind == 1) + .map(AssignmentEvent::from) + .collect::>(); + let assignment_state = reduce_assignment_operations(issue, &root.pubkey, repo_owner, &comments); + let signer = client.keys().public_key().to_hex(); + if !signer.eq_ignore_ascii_case(&root.pubkey) + && !signer.eq_ignore_ascii_case(repo_owner) + && !assignment_state + .assignees + .contains(&signer.to_ascii_lowercase()) + { + return Err(CliError::Usage( + "only the issue author, repo owner, or a current assignee may transition this task" + .into(), + )); + } + let operations = events + .iter() + .filter(|event| event.kind == 1) + .collect::>(); + let context = + reduce_task_transitions(&root, repo_owner, &assignment_state.assignees, &operations); + if context.state.as_deref().is_some_and(|state| state != from) { + return Err(CliError::Usage(format!( + "transition from {from} does not match current task state {}", + context.state.unwrap_or_default() + ))); + } + let latest_signer_comment = operations + .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_signer_comment.saturating_add(1)); + let builder = buzz_sdk::build_git_issue_transition( + &repo, + issue, + from, + to, + &body, + context.head.as_deref(), + gate, + ) + .map_err(sdk_err)? + .custom_created_at(Timestamp::from_secs(created_at)); + let event = client.sign_event(with_git_provenance(builder)?)?; + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> { validate_hex64(event)?; let filter = serde_json::json!({ @@ -599,6 +880,7 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), title, content, label, + depends_on, to, } => { let (repo_owner, repo_id) = resolve_issue_repo_target( @@ -608,7 +890,17 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), channel.as_deref(), ) .await?; - cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await + cmd_create_issue( + client, + &repo_owner, + &repo_id, + &title, + &content, + &label, + &to, + &depends_on, + ) + .await } IssuesCmd::Get { event } => cmd_get_issue(client, &event).await, IssuesCmd::List { @@ -683,14 +975,38 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), ) .await } + IssuesCmd::Transition { + issue, + repo_owner, + repo_id, + from, + to, + content, + gate, + } => { + cmd_transition_issue( + client, + &issue, + &repo_owner, + &repo_id, + &from, + &to, + &content, + gate.as_deref(), + ) + .await + } } } #[cfg(test)] mod tests { + use std::collections::{HashMap, HashSet}; + use super::{ - assignment_note_label, reduce_assignment_operations, AssignmentEvent, AssignmentQueryEvent, - ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, + assignment_note_label, dependency_graph_has_cycle, dependency_is_resolved, + reduce_assignment_operations, reduce_task_transitions, AssignmentEvent, + AssignmentQueryEvent, ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, }; const ISSUE: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; @@ -728,6 +1044,42 @@ mod tests { } } + fn query_event( + id: &str, + kind: u16, + pubkey: &str, + created_at: u64, + tags: Vec>, + ) -> AssignmentQueryEvent { + AssignmentQueryEvent { + id: id.into(), + kind, + pubkey: pubkey.into(), + created_at, + tags, + } + } + + fn transition_event( + id: &str, + pubkey: &str, + created_at: u64, + from: &str, + to: &str, + prior: Option<&str>, + ) -> AssignmentQueryEvent { + let mut tags = vec![ + vec!["e".into(), ISSUE.into(), "".into(), "root".into()], + vec!["t".into(), "task-transition".into()], + vec!["from".into(), from.into()], + vec!["to".into(), to.into()], + ]; + if let Some(prior) = prior { + tags.push(vec!["prior".into(), prior.into()]); + } + query_event(id, 1, pubkey, created_at, tags) + } + #[test] fn assignment_note_label_enforces_desktop_length_limit() { let assignees = vec!["a".repeat(64)]; @@ -842,4 +1194,105 @@ mod tests { assert!(!state.assignees.contains(VOLUNTEER)); assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_unassign)); } + + #[test] + fn task_transitions_require_authority_and_current_prior() { + let root = query_event( + ISSUE, + 1621, + AUTHOR, + 1, + vec![vec!["t".into(), "graph".into()]], + ); + let first = "1".repeat(64); + let second = "2".repeat(64); + let stale = "3".repeat(64); + let unauthorized = "4".repeat(64); + let events = vec![ + transition_event(&first, AUTHOR, 10, "requirements", "implementation", None), + transition_event( + &unauthorized, + &"9".repeat(64), + 20, + "implementation", + "done", + Some(&first), + ), + transition_event( + &second, + VOLUNTEER, + 30, + "implementation", + "quality-gate", + Some(&first), + ), + transition_event(&stale, OWNER, 40, "quality-gate", "done", Some(&first)), + ]; + let references = events.iter().collect::>(); + let context = reduce_task_transitions( + &root, + OWNER, + &HashSet::from([VOLUNTEER.to_string()]), + &references, + ); + assert_eq!(context.head, Some(second)); + assert_eq!(context.state.as_deref(), Some("quality-gate")); + } + + #[test] + fn dependency_graph_detects_cycles() { + let dependency = "d".repeat(64); + let root = query_event( + ISSUE, + 1621, + AUTHOR, + 1, + vec![vec!["depends-on".into(), dependency.clone()]], + ); + let other = query_event( + &dependency, + 1621, + AUTHOR, + 2, + vec![vec!["depends-on".into(), ISSUE.into()]], + ); + let issues = HashMap::from([(ISSUE.to_string(), &root), (dependency, &other)]); + assert!(dependency_graph_has_cycle( + ISSUE, + &issues, + &mut HashSet::new(), + &mut HashSet::new(), + )); + } + + #[test] + fn dependency_requires_latest_trusted_resolved_status() { + let dependency_id = "d".repeat(64); + let dependency = query_event(&dependency_id, 1621, AUTHOR, 1, vec![]); + let resolved = query_event( + &"1".repeat(64), + 1631, + AUTHOR, + 10, + vec![vec![ + "e".into(), + dependency_id.clone(), + "".into(), + "root".into(), + ]], + ); + let reopened = query_event( + &"2".repeat(64), + 1630, + OWNER, + 20, + vec![vec!["e".into(), dependency_id, "".into(), "root".into()]], + ); + assert!(dependency_is_resolved(&dependency, OWNER, &[&resolved])); + assert!(!dependency_is_resolved( + &dependency, + OWNER, + &[&resolved, &reopened], + )); + } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa2..27ccea60bfe 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1677,6 +1677,9 @@ pub enum IssuesCmd { /// Label — can be specified multiple times #[arg(long = "label")] label: Vec, + /// Issue event ID this task depends on. Can be specified multiple times. + #[arg(long = "depends-on")] + depends_on: Vec, /// Additional recipient pubkey(s) — can be specified multiple times #[arg(long = "to")] to: Vec, @@ -1772,6 +1775,30 @@ pub enum IssuesCmd { #[arg(long)] label: Option, }, + /// Record a graph-mode task state transition as a causal issue operation. + Transition { + /// 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, + /// Current graph state slug + #[arg(long)] + from: String, + /// Next graph state slug + #[arg(long)] + to: String, + /// Markdown reason/evidence for the transition ('-' to read from stdin) + #[arg(long)] + content: String, + /// Optional gate slug, such as tests or human-approval + #[arg(long)] + gate: Option, + }, } #[derive(Subcommand)] @@ -2638,4 +2665,73 @@ mod tests { "--visibility chartreuse on update must be rejected at parse time" ); } + + #[test] + fn issues_create_accepts_multiple_dependencies() { + let owner = "a".repeat(64); + let first = "b".repeat(64); + let second = "c".repeat(64); + assert!(Cli::try_parse_from([ + "buzz", + "issues", + "create", + "--repo-owner", + owner.as_str(), + "--repo-id", + "buzz", + "--title", + "Graph task", + "--content", + "body", + "--label", + "graph", + "--depends-on", + first.as_str(), + "--depends-on", + second.as_str(), + ]) + .is_ok()); + } + + #[test] + fn issues_transition_requires_reason_and_states() { + let owner = "a".repeat(64); + let issue = "b".repeat(64); + assert!(Cli::try_parse_from([ + "buzz", + "issues", + "transition", + "--issue", + issue.as_str(), + "--repo-owner", + owner.as_str(), + "--repo-id", + "buzz", + "--from", + "implementation", + "--to", + "quality-gate", + "--content", + "tests started", + "--gate", + "tests", + ]) + .is_ok()); + assert!(Cli::try_parse_from([ + "buzz", + "issues", + "transition", + "--issue", + issue.as_str(), + "--repo-owner", + owner.as_str(), + "--repo-id", + "buzz", + "--from", + "implementation", + "--to", + "quality-gate", + ]) + .is_err()); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73db..3915d528426 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1086,6 +1086,8 @@ pub struct GitIssueMeta { pub labels: Vec, /// Additional pubkeys to `p`-tag besides the repo owner. pub recipients: Vec, + /// Issue event IDs that must be resolved before this issue can proceed. + pub dependencies: Vec, } /// Build a git issue event (kind:1621, NIP-34). `content` is the markdown body. @@ -1117,10 +1119,80 @@ pub fn build_git_issue( for label in &meta.labels { tags.push(tag(&["t", label])?); } + for dependency in &meta.dependencies { + let dependency = check_hex_exact(dependency, 64, "issue dependency")?; + tags.push(tag(&["depends-on", &dependency])?); + } Ok(EventBuilder::new(Kind::Custom(KIND_GIT_ISSUE as u16), content).tags(tags)) } +/// Build a graph-mode task transition note rooted at a NIP-34 issue. +/// +/// The operation uses a regular kind:1 note so clients that do not understand +/// task graphs still render a readable audit entry. Graph-aware clients reduce +/// the `from`/`to` operation chain through the optional causal `prior` tag. +pub fn build_git_issue_transition( + repo: &GitRepoCoord, + issue_id: &str, + from: &str, + to: &str, + content: &str, + prior: Option<&str>, + gate: Option<&str>, +) -> Result { + check_content(content, 64 * 1024)?; + if content.trim().is_empty() { + return Err(SdkError::InvalidInput( + "task transition content must not be empty".into(), + )); + } + let issue = check_hex_exact(issue_id, 64, "issue")?; + let a_value = repo.to_a_tag_value()?; + let from = check_task_transition_value(from, "from")?; + let to = check_task_transition_value(to, "to")?; + if from == to { + return Err(SdkError::InvalidInput( + "task transition from and to must differ".into(), + )); + } + + let mut tags = vec![ + tag(&["e", &issue, "", "root"])?, + tag(&["a", &a_value])?, + tag(&["t", "task-transition"])?, + tag(&["from", &from])?, + tag(&["to", &to])?, + ]; + if let Some(prior) = prior { + let prior = check_hex_exact(prior, 64, "prior task transition")?; + tags.push(tag(&["prior", &prior])?); + } + if let Some(gate) = gate { + let gate = check_task_transition_value(gate, "gate")?; + tags.push(tag(&["gate", &gate])?); + } + Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags)) +} + +fn check_task_transition_value(value: &str, field: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > 64 { + return Err(SdkError::InvalidInput(format!( + "task transition {field} must be between 1 and 64 characters" + ))); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(SdkError::InvalidInput(format!( + "task transition {field} must use lowercase letters, digits, and hyphens" + ))); + } + Ok(value.to_string()) +} + /// Build an issue assignment note (kind:1) — a labeled comment whose `p` /// tags are the assignees, mirroring the Desktop app's assignment events. /// @@ -3587,6 +3659,7 @@ mod tests { let meta = GitIssueMeta { labels: vec!["bug".to_string(), "p1".to_string()], recipients: vec![], + dependencies: vec!["d".repeat(64)], }; let ev = sign(build_git_issue(&repo, "Crashes on startup", "steps to repro", &meta).unwrap()); @@ -3596,6 +3669,65 @@ mod tests { assert!(has_tag(&ev, "subject", "Crashes on startup")); assert!(has_tag(&ev, "t", "bug")); assert!(has_tag(&ev, "t", "p1")); + assert!(has_tag(&ev, "depends-on", &"d".repeat(64))); + } + + #[test] + fn git_issue_transition_builds_causal_operation() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let prior = "c".repeat(64); + let ev = sign( + build_git_issue_transition( + &repo, + &issue, + "quality-gate", + "implementation", + "Tests failed; returning to implementation.", + Some(&prior), + Some("tests"), + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 1); + assert!(has_tag(&ev, "e", &issue)); + assert!(has_tag(&ev, "t", "task-transition")); + assert!(has_tag(&ev, "from", "quality-gate")); + assert!(has_tag(&ev, "to", "implementation")); + assert!(has_tag(&ev, "prior", &prior)); + assert!(has_tag(&ev, "gate", "tests")); + } + + #[test] + fn git_issue_transition_rejects_invalid_state_or_empty_reason() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + assert!(build_git_issue_transition( + &repo, + &issue, + "In Progress", + "quality-gate", + "reason", + None, + None, + ) + .is_err()); + assert!(build_git_issue_transition( + &repo, + &issue, + "implementation", + "quality-gate", + " ", + None, + None, + ) + .is_err()); } #[test] From 97a2b538a3c938dc1417f59b2ccfcc704db35a28 Mon Sep 17 00:00:00 2001 From: contentscoin Date: Sun, 30 Aug 2026 20:35:49 +0900 Subject: [PATCH 2/5] fix(issues): order transitions after the causal head Signed-off-by: contentscoin --- crates/buzz-cli/src/commands/issues.rs | 62 ++++++++++++++++++++------ 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 20c52d8b1c5..3dd76db3c8c 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -335,7 +335,9 @@ fn reduce_task_transitions( } let causal = match head.as_deref() { None => prior.is_empty(), - Some(current) => prior.first().is_some_and(|value| *value == current), + Some(current) => prior + .first() + .is_some_and(|value| value.eq_ignore_ascii_case(current)), }; if !causal || state.as_deref().is_some_and(|current| current != from[0]) { continue; @@ -346,6 +348,21 @@ fn reduce_task_transitions( TaskTransitionContext { head, state } } +fn next_task_transition_created_at(now: u64, events: &[&AssignmentQueryEvent]) -> u64 { + let latest = events + .iter() + .filter(|event| { + event.kind == 1 + && query_tag_values(event, "t") + .iter() + .any(|label| *label == TASK_TRANSITION_LABEL) + }) + .map(|event| event.created_at) + .max() + .unwrap_or(0); + now.max(latest.saturating_add(1)) +} + impl IssueAssignmentOperation { fn content(self, label: &str) -> String { match self { @@ -730,17 +747,11 @@ pub async fn cmd_transition_issue( context.state.unwrap_or_default() ))); } - let latest_signer_comment = operations - .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() + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|error| CliError::Other(format!("read system clock: {error}")))? - .as_secs() - .max(latest_signer_comment.saturating_add(1)); + .as_secs(); + let created_at = next_task_transition_created_at(now, &operations); let builder = buzz_sdk::build_git_issue_transition( &repo, issue, @@ -1005,8 +1016,8 @@ mod tests { use super::{ assignment_note_label, dependency_graph_has_cycle, dependency_is_resolved, - reduce_assignment_operations, reduce_task_transitions, AssignmentEvent, - AssignmentQueryEvent, ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, + next_task_transition_created_at, reduce_assignment_operations, reduce_task_transitions, + AssignmentEvent, AssignmentQueryEvent, ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, }; const ISSUE: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; @@ -1204,7 +1215,7 @@ mod tests { 1, vec![vec!["t".into(), "graph".into()]], ); - let first = "1".repeat(64); + let first = "a".repeat(64); let second = "2".repeat(64); let stale = "3".repeat(64); let unauthorized = "4".repeat(64); @@ -1224,7 +1235,7 @@ mod tests { 30, "implementation", "quality-gate", - Some(&first), + Some(&first.to_ascii_uppercase()), ), transition_event(&stale, OWNER, 40, "quality-gate", "done", Some(&first)), ]; @@ -1295,4 +1306,27 @@ mod tests { &[&resolved, &reopened], )); } + + #[test] + fn next_transition_follows_future_head_from_another_signer() { + let future = transition_event( + &"1".repeat(64), + AUTHOR, + 10_000, + "implementation", + "quality-gate", + None, + ); + let unrelated = query_event( + &"2".repeat(64), + 1, + VOLUNTEER, + 20_000, + vec![vec!["t".into(), "assignment".into()]], + ); + assert_eq!( + next_task_transition_created_at(100, &[&future, &unrelated]), + 10_001 + ); + } } From 96050d89331103e4787f86926dfa18473d05e40f Mon Sep 17 00:00:00 2001 From: contentscoin Date: Sun, 30 Aug 2026 20:47:11 +0900 Subject: [PATCH 3/5] fix(issues): satisfy graph transition CI checks Signed-off-by: contentscoin --- crates/buzz-cli/src/commands/issues.rs | 5 ++--- crates/buzz-cli/src/lib.rs | 12 ++++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 3dd76db3c8c..0c78b33b11a 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -353,9 +353,7 @@ fn next_task_transition_created_at(now: u64, events: &[&AssignmentQueryEvent]) - .iter() .filter(|event| { event.kind == 1 - && query_tag_values(event, "t") - .iter() - .any(|label| *label == TASK_TRANSITION_LABEL) + && query_tag_values(event, "t").contains(&TASK_TRANSITION_LABEL) }) .map(|event| event.created_at) .max() @@ -372,6 +370,7 @@ impl IssueAssignmentOperation { } } +#[allow(clippy::too_many_arguments)] pub async fn cmd_create_issue( client: &BuzzClient, repo_owner: &str, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 27ccea60bfe..7d617e23a31 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2436,7 +2436,15 @@ mod tests { ); assert_eq!( names(&cmd, "issues"), - vec!["assign", "create", "get", "list", "status", "unassign"] + vec![ + "assign", + "create", + "get", + "list", + "status", + "transition", + "unassign" + ] ); assert_eq!(names(&cmd, "media"), vec!["get"]); assert_eq!(names(&cmd, "upload"), vec!["file"]); @@ -2465,7 +2473,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), - ("issues", 6), + ("issues", 7), ("media", 1), ("messages", 8), ("pack", 2), From 73df68f8e241bd0074353390563d48a5147a1125 Mon Sep 17 00:00:00 2001 From: contentscoin Date: Sun, 30 Aug 2026 20:50:00 +0900 Subject: [PATCH 4/5] style(issues): match pinned rustfmt output Signed-off-by: contentscoin --- crates/buzz-cli/src/commands/issues.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 0c78b33b11a..64b6e1bce71 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -352,8 +352,7 @@ fn next_task_transition_created_at(now: u64, events: &[&AssignmentQueryEvent]) - let latest = events .iter() .filter(|event| { - event.kind == 1 - && query_tag_values(event, "t").contains(&TASK_TRANSITION_LABEL) + event.kind == 1 && query_tag_values(event, "t").contains(&TASK_TRANSITION_LABEL) }) .map(|event| event.created_at) .max() From 67494c30890b7269305e6ab2678ad62b78a47f0d Mon Sep 17 00:00:00 2001 From: contentscoin Date: Sun, 30 Aug 2026 20:56:21 +0900 Subject: [PATCH 5/5] test(issues): avoid temporary transition vector Signed-off-by: contentscoin --- crates/buzz-cli/src/commands/issues.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 64b6e1bce71..c49679b6250 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1217,7 +1217,7 @@ mod tests { let second = "2".repeat(64); let stale = "3".repeat(64); let unauthorized = "4".repeat(64); - let events = vec![ + let events = [ transition_event(&first, AUTHOR, 10, "requirements", "implementation", None), transition_event( &unauthorized,