Files
buzz/crates/buzz-cli/src/commands/issues.rs
thomaspblock de188ebf06 feat(projects): add agent and CLI project-home support (#6590)
## Summary
- inject bounded project-home identity and repository context into
managed agent sessions
- add project-aware CLI flows for creating projects, repositories,
issues, and related channels
- match project homes through the existing relay query surface, then
filter channel metadata client-side

This is Part 1 of the channel-first Projects stack. Part 2 contains
project creation and model foundations.

## Testing
- `cargo fmt --all -- --check`
- `cargo clippy -p buzz-cli -p buzz-acp --all-targets -- -D warnings`
- `cargo test -p buzz-cli -p buzz-acp` — 1,184 tests passed, 1 doc test
ignored
- full pre-push gate passed

## Post-Deploy Monitoring & Validation
- validate project-home agent context and project-aware CLI commands
against a staging relay
- healthy signals: project context matches the active channel, explicit
repo coordinates remain stable, and normal channels receive no project
block
- failure signals: cross-channel project context, duplicate project
creation, or commands targeting an unrelated repository; mitigate by
reverting this PR

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
2026-08-26 12:09:06 -07:00

846 lines
26 KiB
Rust

use std::collections::{HashMap, HashSet};
use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::commands::GIT_ORIGIN_CHANNEL_ENV;
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,
repo_owner: &str,
repo_id: &str,
subject: &str,
content: &str,
labels: &[String],
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let body = read_or_stdin(content)?;
let meta = GitIssueMeta {
labels: labels.to_vec(),
recipients: to.to_vec(),
};
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let builder = with_git_provenance(
buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?,
)?;
let event = client.sign_event(builder)?;
let event_id = event.id.to_hex();
let resp = client.submit_event(event).await?;
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce issues with it (see base_prompt.md).
let link = crate::links::issue_link(&event_id, repo_owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}
async fn resolve_issue_repo_target(
client: &BuzzClient,
repo_owner: Option<&str>,
repo_id: Option<&str>,
channel: Option<&str>,
) -> Result<(String, String), CliError> {
let owner = repo_owner.map(str::trim).filter(|value| !value.is_empty());
let id = repo_id.map(str::trim).filter(|value| !value.is_empty());
match (owner, id) {
(Some(owner), Some(id)) => Ok((owner.to_string(), id.to_string())),
(None, None) => {
let channel = channel
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok());
let Some(channel) = channel else {
return Err(CliError::Usage(
"provide --repo-owner and --repo-id, or --channel (or set BUZZ_GIT_ORIGIN_CHANNEL_ID)".into(),
));
};
let resolved = crate::commands::project_channel::resolve_or_ensure_repo_for_channel(
client, &channel,
)
.await?;
Ok((resolved.repo_owner, resolved.repo_id))
}
_ => Err(CliError::Usage(
"provide both --repo-owner and --repo-id, or --channel".into(),
)),
}
}
/// 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!({
"kinds": [1621],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_issues(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
author: Option<&str>,
label: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let a_value = format!("30617:{repo_owner}:{repo_id}");
let mut filter = serde_json::json!({
"kinds": [1621],
"#a": [a_value]
});
if let Some(pk) = author {
validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
if let Some(l) = label {
filter["#t"] = serde_json::json!([l]);
}
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_issue_status(
client: &BuzzClient,
issue: &str,
status: &str,
content: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(issue)?;
let status = crate::commands::patches::parse_status(status)?;
let body = match content {
Some(c) => read_or_stdin(c)?,
None => String::new(),
};
let repo = match (repo_owner, repo_id) {
(Some(owner), Some(id)) => {
validate_hex64(owner)?;
validate_repo_id(id)?;
Some(GitRepoCoord {
owner: owner.to_string(),
id: id.to_string(),
})
}
(None, None) => None,
_ => {
return Err(CliError::Usage(
"--repo-owner and --repo-id must be given together".into(),
))
}
};
// Mirrors `buzz patches status`: default a `p` tag to the repo owner
// for discoverability, plus a `--to` escape hatch for the issue author
// or anyone else who should be notified of the status change.
let mut recipients = Vec::new();
if let Some(ref repo) = repo {
recipients.push(repo.owner.clone());
}
for recipient in to {
validate_hex64(recipient)?;
if !recipients.contains(recipient) {
recipients.push(recipient.clone());
}
}
let meta = GitStatusMeta {
root_event: issue.to_string(),
accepted_revision_root: None,
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches: vec![],
merge_commit: None,
applied_as_commits: vec![],
};
let builder =
with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::IssuesCmd;
match cmd {
IssuesCmd::Create {
repo_owner,
repo_id,
channel,
title,
content,
label,
to,
} => {
let (repo_owner, repo_id) = resolve_issue_repo_target(
client,
repo_owner.as_deref(),
repo_id.as_deref(),
channel.as_deref(),
)
.await?;
cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await
}
IssuesCmd::Get { event } => cmd_get_issue(client, &event).await,
IssuesCmd::List {
repo_owner,
repo_id,
author,
label,
limit,
} => {
cmd_list_issues(
client,
&repo_owner,
&repo_id,
author.as_deref(),
label.as_deref(),
limit,
)
.await
}
IssuesCmd::Status {
issue,
status,
content,
repo_owner,
repo_id,
euc,
to,
} => {
cmd_issue_status(
client,
&issue,
&status,
content.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
&to,
)
.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));
}
}