mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
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 <thomasp@squareup.com>
This commit is contained in:
@@ -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<String, CliError> {
|
||||
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<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 {
|
||||
@@ -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<u64, CliError> {
|
||||
let signer = client.keys().public_key().to_hex();
|
||||
let filter = serde_json::json!({
|
||||
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": 500
|
||||
"limit": 1
|
||||
});
|
||||
let response = client.query(&filter).await?;
|
||||
let latest = serde_json::from_str::<Vec<serde_json::Value>>(&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::<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);
|
||||
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::<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1135,6 +1135,20 @@ pub fn build_git_issue_assignment(
|
||||
issue_id: &str,
|
||||
assignees: &[String],
|
||||
content: &str,
|
||||
) -> Result<EventBuilder, SdkError> {
|
||||
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", <event-id>]` 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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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", <event-id>]` 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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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();
|
||||
|
||||
@@ -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]] : []),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ProjectIssue = {
|
||||
labels: string[];
|
||||
recipients: string[];
|
||||
assignees: string[];
|
||||
assigneeOperationHeads: Record<string, string>;
|
||||
status: ProjectIssueStatus;
|
||||
statusEventId: string | null;
|
||||
updatedAt: number;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user