From 86bae900ad165bbb30857d50f5b58d5c788cf000 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 12 Aug 2026 14:23:17 -0400 Subject: [PATCH] projects: preserve causal issue self-service Let assignees respond to observed maintainer actions without allowing future-dated precommitted events to override authoritative decisions. Signed-off-by: Thomas Petersen --- crates/buzz-cli/src/commands/issues.rs | 413 +++++++++++++++++- crates/buzz-sdk/src/builders.rs | 103 +++++ .../src/features/projects/issueAssignments.ts | 6 + .../src/features/projects/projectIssues.d.mts | 1 + .../src/features/projects/projectIssues.mjs | 61 ++- .../features/projects/projectIssues.test.mjs | 87 ++++ 6 files changed, 638 insertions(+), 33 deletions(-) diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 4f861e504e..15284a0d7b 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,9 +1,15 @@ +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 { if let Some(label) = label { @@ -44,6 +50,175 @@ enum IssueAssignmentOperation { Unassign, } +#[derive(Debug)] +struct AssignmentEvent { + id: String, + pubkey: String, + created_at: u64, + tags: Vec>, +} + +#[derive(Debug, Deserialize)] +struct AssignmentQueryEvent { + id: String, + kind: u16, + pubkey: String, + created_at: u64, + tags: Vec>, +} + +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, + heads: HashMap, +} + +#[derive(Debug)] +struct ParsedAssignmentOperation { + id: String, + is_assignment: bool, + pubkeys: Vec, + prior: Option, +} + +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::>(); + 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::>(); + 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::>(); + 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, +} + impl IssueAssignmentOperation { fn content(self, label: &str) -> String { match self { @@ -153,49 +328,119 @@ async fn publish_issue_assignment_operation( let label = assignment_note_label(assignees, label)?; let content = operation.content(&label); - let created_at = next_issue_assignment_created_at(client, issue).await?; let repo = GitRepoCoord { owner: repo_owner.to_string(), id: repo_id.to_string(), }; - let builder = match operation { - IssueAssignmentOperation::Assign => { + 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 => { + (IssueAssignmentOperation::Unassign, false) => { buzz_sdk::build_git_issue_unassignment(&repo, issue, assignees, &content) } } - .map(|builder| builder.custom_created_at(Timestamp::from_secs(created_at))); + .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 next_issue_assignment_created_at( +async fn issue_assignment_context( client: &BuzzClient, issue: &str, -) -> Result { - let signer = client.keys().public_key().to_hex(); - let filter = serde_json::json!({ + repo: &GitRepoCoord, + signer: &str, + include_prior: bool, +) -> Result { + 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": 500 + "limit": 1 }); - let response = client.query(&filter).await?; - let latest = serde_json::from_str::>(&response) - .map_err(|error| CliError::Other(format!("parse issue comments: {error}")))? - .into_iter() - .filter_map(|event| event.get("created_at").and_then(serde_json::Value::as_u64)) + 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::>(&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::>(); + let latest = comments + .iter() + .filter(|event| event.pubkey.eq_ignore_ascii_case(signer)) + .map(|event| event.created_at) .max() .unwrap_or(0); - Ok(std::time::SystemTime::now() + 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))) + .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> { @@ -399,7 +644,45 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), #[cfg(test)] mod tests { - use super::assignment_note_label; + 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() { @@ -421,4 +704,98 @@ mod tests { 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::(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)); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index e22850aa83..30311ddcf4 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1135,6 +1135,20 @@ pub fn build_git_issue_assignment( issue_id: &str, assignees: &[String], content: &str, +) -> Result { + build_git_issue_assignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue assignment note with an optional causal assignment-operation +/// event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_assignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, ) -> Result { build_git_issue_assignee_operation( repo, @@ -1142,6 +1156,7 @@ pub fn build_git_issue_assignment( assignees, content, GitIssueAssigneeOperation::Assign, + prior, ) } @@ -1155,6 +1170,20 @@ pub fn build_git_issue_unassignment( issue_id: &str, assignees: &[String], content: &str, +) -> Result { + build_git_issue_unassignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue unassignment note with an optional causal +/// assignment-operation event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_unassignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, ) -> Result { build_git_issue_assignee_operation( repo, @@ -1162,6 +1191,7 @@ pub fn build_git_issue_unassignment( assignees, content, GitIssueAssigneeOperation::Unassign, + prior, ) } @@ -1186,6 +1216,7 @@ fn build_git_issue_assignee_operation( assignees: &[String], content: &str, operation: GitIssueAssigneeOperation, + prior: Option<&str>, ) -> Result { check_content(content, 64 * 1024)?; let issue = check_hex_exact(issue_id, 64, "issue")?; @@ -1207,6 +1238,10 @@ fn build_git_issue_assignee_operation( tags.push(tag(&["p", assignee])?); } tags.push(tag(&["t", operation.label()])?); + if let Some(prior) = prior { + let prior = check_hex_exact(prior, 64, "prior assignment operation")?; + tags.push(tag(&["prior", &prior])?); + } Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags)) } @@ -3619,6 +3654,48 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn git_issue_assignment_with_prior_emits_valid_causal_tag() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignee = "c".repeat(64); + let prior = "d".repeat(64); + let ev = sign( + build_git_issue_assignment_with_prior( + &repo, + &issue, + &[assignee], + "Assigned this issue", + Some(&prior), + ) + .unwrap(), + ); + + assert!(has_tag(&ev, "prior", &prior)); + let unassignment = sign( + build_git_issue_unassignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Unassigned this issue", + Some(&prior), + ) + .unwrap(), + ); + assert!(has_tag(&unassignment, "prior", &prior)); + assert!(build_git_issue_assignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Assigned this issue", + Some("invalid"), + ) + .is_err()); + } + #[test] fn git_issue_unassignment_happy_path() { let owner = "a".repeat(64); @@ -3646,6 +3723,32 @@ mod tests { assert!(!has_tag(&ev, "t", "assignment")); } + #[test] + fn legacy_issue_assignment_builders_omit_prior() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignees = vec!["c".repeat(64)]; + let assignment = sign( + build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue").unwrap(), + ); + let unassignment = sign( + build_git_issue_unassignment(&repo, &issue, &assignees, "Unassigned this issue") + .unwrap(), + ); + + assert!(!assignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + assert!(!unassignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + } + #[test] fn git_status_open_happy_path() { let root = event_id().to_hex(); diff --git a/desktop/src/features/projects/issueAssignments.ts b/desktop/src/features/projects/issueAssignments.ts index 473604462b..aa0810dea7 100644 --- a/desktop/src/features/projects/issueAssignments.ts +++ b/desktop/src/features/projects/issueAssignments.ts @@ -93,6 +93,11 @@ async function writeProjectIssueAssignment({ }); return; } + const normalizedSigner = signerPubkey.toLowerCase(); + const prior = + assigneePubkeys.length === 1 && assigneePubkeys[0] === normalizedSigner + ? issue.assigneeOperationHeads[normalizedSigner] + : undefined; const event = await signRelayEvent({ kind: KIND_TEXT_NOTE, content, @@ -102,6 +107,7 @@ async function writeProjectIssueAssignment({ ["a", project.repoAddress], ...assigneePubkeys.map((pubkey) => ["p", pubkey]), ["t", label], + ...(prior ? [["prior", prior]] : []), ], }); diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index de21fe2fe2..8abf67b4fa 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -29,6 +29,7 @@ export type ProjectIssue = { labels: string[]; recipients: string[]; assignees: string[]; + assigneeOperationHeads: Record; status: ProjectIssueStatus; statusEventId: string | null; updatedAt: number; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 7b44a515ef..49363d6551 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -89,20 +89,25 @@ function statusFromEvent(issue, statusEvent) { * tags are notification routing only. * * Trusted signers are the issue author and repo owner (who may change anyone), - * plus any community member whose operation names only themselves. Self-service - * operations are applied first and authoritative operations last, so an author - * or owner decision wins regardless of signer-controlled timestamps. Within - * each authority class, same-second events use their id as a deterministic - * tie-breaker because relay result order is not stable. + * plus any community member whose operation names only themselves. Uncaused + * self-service operations are applied first, authoritative operations second, + * and self-service operations that causally reference the current per-assignee + * operation head last. This prevents signer-controlled timestamps from + * overriding authority while allowing a later observed owner/author decision + * to be superseded by the affected assignee. */ -function assigneesForIssue(issue, issueCommentEvents) { +function assignmentStateForIssue(issue, issueCommentEvents) { const allowedActors = allowedActorsForRoot(issue); const assignees = new Set(); - const selfServiceOperations = []; + const operationHeads = new Map(); + const uncausedSelfServiceOperations = []; const authoritativeOperations = []; + const causalSelfServiceOperations = []; const events = sortEvents( - issueCommentEvents.filter((event) => - event.tags.some((tag) => tag[0] === "e" && tag[1] === issue.id), + issueCommentEvents.filter( + (event) => + event.kind === 1 && + event.tags.some((tag) => tag[0] === "e" && tag[1] === issue.id), ), ); for (const event of events) { @@ -116,26 +121,50 @@ function assigneesForIssue(issue, issueCommentEvents) { ); const isSelfOperation = pubkeys.length === 1 && pubkeys[0] === signer; if (!allowedActors.has(signer) && !isSelfOperation) continue; - const operation = { isAssignment, pubkeys }; + const operation = { + id: event.id.toLowerCase(), + isAssignment, + pubkeys, + }; if (allowedActors.has(signer)) { authoritativeOperations.push(operation); } else { - selfServiceOperations.push(operation); + const priorTags = event.tags.filter((tag) => tag[0] === "prior"); + if (priorTags.length === 0) { + uncausedSelfServiceOperations.push(operation); + continue; + } + if ( + priorTags.length !== 1 || + !/^[a-fA-F0-9]{64}$/.test(priorTags[0]?.[1] ?? "") + ) { + continue; + } + causalSelfServiceOperations.push({ + ...operation, + prior: priorTags[0][1].toLowerCase(), + }); } } - for (const { isAssignment, pubkeys } of [ - ...selfServiceOperations, + for (const { id, isAssignment, pubkeys, prior } of [ + ...uncausedSelfServiceOperations, ...authoritativeOperations, + ...causalSelfServiceOperations, ]) { + if (prior && operationHeads.get(pubkeys[0]) !== prior) continue; for (const pubkey of pubkeys) { if (isAssignment) { assignees.add(pubkey); } else { assignees.delete(pubkey); } + operationHeads.set(pubkey, id); } } - return [...assignees]; + return { + assignees: [...assignees], + heads: Object.fromEntries(operationHeads), + }; } function commentsForIssue(issueCommentEvents) { @@ -160,6 +189,7 @@ export function eventToProjectIssue( ), ); const comments = commentsForIssue(issueCommentEvents); + const assignmentState = assignmentStateForIssue(issue, issueCommentEvents); const title = getTag(issue, "subject") || issue.content.split("\n")[0] || @@ -177,7 +207,8 @@ export function eventToProjectIssue( originAgentName: getTag(issue, "buzz-origin-agent") ?? null, labels: getAllTags(issue, "t"), recipients: getAllTags(issue, "p"), - assignees: assigneesForIssue(issue, issueCommentEvents), + assignees: assignmentState.assignees, + assigneeOperationHeads: assignmentState.heads, status: statusFromEvent(issue, latestStatus), statusEventId: latestStatus?.id ?? null, updatedAt: diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 49032b6513..4c670213b5 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -52,6 +52,7 @@ function assignmentComment( id, label = ISSUE_ASSIGNMENT_LABEL, createdAt = 200, + prior, ) { return { id, @@ -67,6 +68,7 @@ function assignmentComment( ["a", REPO_ADDRESS], ...assignees.map((value) => ["p", value]), ["t", label], + ...(prior ? [["prior", prior]] : []), ], }; } @@ -295,6 +297,91 @@ test("owner assignment overrides a future-dated self-unassignment", () => { assert.deepEqual(issue.assignees, [volunteer]); }); +test("causal self-unassignment can follow an owner assignment", () => { + const volunteer = "5".repeat(64); + const ownerAssignmentId = "1".repeat(64); + const selfUnassignmentId = "2".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment(OWNER, [volunteer], ownerAssignmentId), + assignmentComment( + volunteer, + [volunteer], + selfUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + 300, + ownerAssignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); + assert.equal(issue.assigneeOperationHeads[volunteer], selfUnassignmentId); +}); + +test("causal self-assignment can follow an owner unassignment", () => { + const volunteer = "5".repeat(64); + const ownerUnassignmentId = "3".repeat(64); + const selfAssignmentId = "4".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment( + OWNER, + [volunteer], + ownerUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + ), + assignmentComment( + volunteer, + [volunteer], + selfAssignmentId, + ISSUE_ASSIGNMENT_LABEL, + 300, + ownerUnassignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, [volunteer]); + assert.equal(issue.assigneeOperationHeads[volunteer], selfAssignmentId); +}); + +test("ignores a causal self-operation with a stale prior", () => { + const volunteer = "5".repeat(64); + const initialAssignmentId = "6".repeat(64); + const ownerUnassignmentId = "7".repeat(64); + const staleSelfAssignmentId = "8".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment(OWNER, [volunteer], initialAssignmentId), + assignmentComment( + OWNER, + [volunteer], + ownerUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + 250, + ), + assignmentComment( + volunteer, + [volunteer], + staleSelfAssignmentId, + ISSUE_ASSIGNMENT_LABEL, + 300, + initialAssignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); + assert.equal(issue.assigneeOperationHeads[volunteer], ownerUnassignmentId); +}); + test("issue recipients remain notification routing, not assignments", () => { const recipient = "d".repeat(64); const otherRecipient = "f".repeat(64);