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:
Thomas Petersen
2026-08-12 14:23:17 -04:00
parent 4f90bedb42
commit 86bae900ad
6 changed files with 638 additions and 33 deletions

View File

@@ -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));
}
}

View File

@@ -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();

View File

@@ -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]] : []),
],
});

View File

@@ -29,6 +29,7 @@ export type ProjectIssue = {
labels: string[];
recipients: string[];
assignees: string[];
assigneeOperationHeads: Record<string, string>;
status: ProjectIssueStatus;
statusEventId: string | null;
updatedAt: number;

View File

@@ -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:

View File

@@ -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);