44552499
Make relates_to a set instead of a single value
a73x 2026-08-09 16:50
Commit message
src/cache.rs
| Old | New | ||
|---|---|---|---|
| @@ -18,7 +18,9 @@ fn sanitize_ref_name(ref_name: &str) -> String { | |||
| 18 | /// Cache format version. Bump whenever state-fold logic changes so that | 18 | /// Cache format version. Bump whenever state-fold logic changes so that |
| 19 | /// entries computed with older logic are discarded even if the ref tip | 19 | /// entries computed with older logic are discarded even if the ref tip |
| 20 | /// hasn't moved. v2: review vote supersession per (author, revision). | 20 | /// hasn't moved. v2: review vote supersession per (author, revision). |
| 21 | const CACHE_FORMAT_VERSION: u32 = 2; | 21 | /// v3: `IssueState::relates_to` became a multi-member set instead of a |
| 22 | /// single optional value. | ||
| 23 | const CACHE_FORMAT_VERSION: u32 = 3; | ||
| 22 | 24 | ||
| 23 | /// Cache entry stored on disk: the tip OID at cache time + serialized state. | 25 | /// Cache entry stored on disk: the tip OID at cache time + serialized state. |
| 24 | #[derive(serde::Serialize, serde::Deserialize)] | 26 | #[derive(serde::Serialize, serde::Deserialize)] |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -176,8 +176,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 176 | if !i.assignees.is_empty() { | 176 | if !i.assignees.is_empty() { |
| 177 | println!("Assignees: {}", i.assignees.join(", ")); | 177 | println!("Assignees: {}", i.assignees.join(", ")); |
| 178 | } | 178 | } |
| 179 | if let Some(ref relates_to) = i.relates_to { | 179 | if !i.relates_to.is_empty() { |
| 180 | println!("Relates-to: {:.8}", relates_to); | 180 | let short: Vec<String> = |
| 181 | i.relates_to.iter().map(|r| format!("{:.8}", r)).collect(); | ||
| 182 | println!("Relates-to: {}", short.join(", ")); | ||
| 181 | } | 183 | } |
| 182 | if let Some(ref reason) = i.close_reason { | 184 | if let Some(ref reason) = i.close_reason { |
| 183 | println!("Closed: {}", reason); | 185 | println!("Closed: {}", reason); |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -52,6 +52,27 @@ fn deserialize_verdict<'de, D: serde::Deserializer<'de>>(d: D) -> Result<ReviewV | |||
| 52 | s.parse().map_err(serde::de::Error::custom) | 52 | s.parse().map_err(serde::de::Error::custom) |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | /// `relates_to` used to be a single `Option<String>`, written only at issue | ||
| 56 | /// creation. It is now a `Vec<String>` so an issue can relate to more than | ||
| 57 | /// one other issue. Cached state (and any other JSON on disk) written by an | ||
| 58 | /// older git-collab version still has the old shape, so this accepts a bare | ||
| 59 | /// string (-> one-element vec), `null`/absent (-> empty vec, via `#[serde(default)]` | ||
| 60 | /// on the field), or the current array shape. | ||
| 61 | #[derive(Deserialize)] | ||
| 62 | #[serde(untagged)] | ||
| 63 | enum RelatesToShape { | ||
| 64 | Many(Vec<String>), | ||
| 65 | One(String), | ||
| 66 | } | ||
| 67 | |||
| 68 | fn deserialize_relates_to<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> { | ||
| 69 | match Option::<RelatesToShape>::deserialize(d)? { | ||
| 70 | None => Ok(Vec::new()), | ||
| 71 | Some(RelatesToShape::Many(v)) => Ok(v), | ||
| 72 | Some(RelatesToShape::One(s)) => Ok(vec![s]), | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 55 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | 76 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 56 | #[serde(rename_all = "lowercase")] | 77 | #[serde(rename_all = "lowercase")] |
| 57 | pub enum IssueStatus { | 78 | pub enum IssueStatus { |
| @@ -115,8 +136,8 @@ pub struct IssueState { | |||
| 115 | #[serde(default)] | 136 | #[serde(default)] |
| 116 | pub last_updated: String, | 137 | pub last_updated: String, |
| 117 | pub author: Author, | 138 | pub author: Author, |
| 118 | #[serde(default, skip_serializing_if = "Option::is_none")] | 139 | #[serde(default, deserialize_with = "deserialize_relates_to")] |
| 119 | pub relates_to: Option<String>, | 140 | pub relates_to: Vec<String>, |
| 120 | } | 141 | } |
| 121 | 142 | ||
| 122 | #[derive(Debug, Clone, Serialize, Deserialize)] | 143 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| @@ -317,7 +338,9 @@ impl IssueState { | |||
| 317 | created_at: event.timestamp.clone(), | 338 | created_at: event.timestamp.clone(), |
| 318 | last_updated: String::new(), | 339 | last_updated: String::new(), |
| 319 | author: event.author.clone(), | 340 | author: event.author.clone(), |
| 320 | relates_to, | 341 | // `--relates-to` at open time seeds the relation set |
| 342 | // with a single member. | ||
| 343 | relates_to: relates_to.into_iter().collect(), | ||
| 321 | }); | 344 | }); |
| 322 | } | 345 | } |
| 323 | Action::IssueComment { body } => { | 346 | Action::IssueComment { body } => { |
| @@ -365,18 +388,14 @@ impl IssueState { | |||
| 365 | } | 388 | } |
| 366 | Action::IssueRelate { relates_to } => { | 389 | Action::IssueRelate { relates_to } => { |
| 367 | if let Some(ref mut s) = state { | 390 | if let Some(ref mut s) = state { |
| 368 | s.relates_to = Some(relates_to); | 391 | if !s.relates_to.contains(&relates_to) { |
| 392 | s.relates_to.push(relates_to); | ||
| 393 | } | ||
| 369 | } | 394 | } |
| 370 | } | 395 | } |
| 371 | Action::IssueUnrelate { relates_to } => { | 396 | Action::IssueUnrelate { relates_to } => { |
| 372 | if let Some(ref mut s) = state { | 397 | if let Some(ref mut s) = state { |
| 373 | // relates_to is a single field, not a set: only clear | 398 | s.relates_to.retain(|r| r != &relates_to); |
| 374 | // it if it still points at the target being removed, | ||
| 375 | // so an unrelate against a stale/mismatched target is | ||
| 376 | // a no-op rather than clobbering a newer relation. | ||
| 377 | if s.relates_to.as_deref() == Some(relates_to.as_str()) { | ||
| 378 | s.relates_to = None; | ||
| 379 | } | ||
| 380 | } | 399 | } |
| 381 | } | 400 | } |
| 382 | Action::IssueAssign { assignee } => { | 401 | Action::IssueAssign { assignee } => { |
| @@ -933,3 +952,80 @@ pub fn resolve_patch_ref( | |||
| 933 | ) -> Result<(String, String), crate::error::Error> { | 952 | ) -> Result<(String, String), crate::error::Error> { |
| 934 | resolve_ref(repo, "patches", "patch", prefix) | 953 | resolve_ref(repo, "patches", "patch", prefix) |
| 935 | } | 954 | } |
| 955 | |||
| 956 | #[cfg(test)] | ||
| 957 | mod tests { | ||
| 958 | use super::*; | ||
| 959 | |||
| 960 | // `relates_to` used to be a single `Option<String>`. Cached state and any | ||
| 961 | // other on-disk JSON written by older git-collab versions can still hold | ||
| 962 | // that shape, so `IssueState` must keep reading it even though it now | ||
| 963 | // materializes a `Vec<String>`. | ||
| 964 | |||
| 965 | fn issue_json_with_relates_to(relates_to_json: &str) -> String { | ||
| 966 | format!( | ||
| 967 | r#"{{ | ||
| 968 | "id": "abc123", | ||
| 969 | "title": "t", | ||
| 970 | "body": "", | ||
| 971 | "status": "open", | ||
| 972 | "close_reason": null, | ||
| 973 | "closed_by": null, | ||
| 974 | "labels": [], | ||
| 975 | "assignees": [], | ||
| 976 | "comments": [], | ||
| 977 | "linked_commits": [], | ||
| 978 | "created_at": "2026-01-01T00:00:00Z", | ||
| 979 | "last_updated": "", | ||
| 980 | "author": {{"name": "A", "email": "a@example.com"}}, | ||
| 981 | "relates_to": {relates_to_json} | ||
| 982 | }}"# | ||
| 983 | ) | ||
| 984 | } | ||
| 985 | |||
| 986 | #[test] | ||
| 987 | fn deserializes_legacy_singular_string_relates_to_as_one_element_vec() { | ||
| 988 | let json = issue_json_with_relates_to(r#""def456""#); | ||
| 989 | let issue: IssueState = serde_json::from_str(&json).unwrap(); | ||
| 990 | assert_eq!(issue.relates_to, vec!["def456".to_string()]); | ||
| 991 | } | ||
| 992 | |||
| 993 | #[test] | ||
| 994 | fn deserializes_null_relates_to_as_empty_vec() { | ||
| 995 | let json = issue_json_with_relates_to("null"); | ||
| 996 | let issue: IssueState = serde_json::from_str(&json).unwrap(); | ||
| 997 | assert_eq!(issue.relates_to, Vec::<String>::new()); | ||
| 998 | } | ||
| 999 | |||
| 1000 | #[test] | ||
| 1001 | fn deserializes_new_array_relates_to() { | ||
| 1002 | let json = issue_json_with_relates_to(r#"["def456", "ghi789"]"#); | ||
| 1003 | let issue: IssueState = serde_json::from_str(&json).unwrap(); | ||
| 1004 | assert_eq!( | ||
| 1005 | issue.relates_to, | ||
| 1006 | vec!["def456".to_string(), "ghi789".to_string()] | ||
| 1007 | ); | ||
| 1008 | } | ||
| 1009 | |||
| 1010 | #[test] | ||
| 1011 | fn deserializes_missing_relates_to_field_as_empty_vec() { | ||
| 1012 | // Predates the relates_to field existing at all. | ||
| 1013 | let json = r#"{ | ||
| 1014 | "id": "abc123", | ||
| 1015 | "title": "t", | ||
| 1016 | "body": "", | ||
| 1017 | "status": "open", | ||
| 1018 | "close_reason": null, | ||
| 1019 | "closed_by": null, | ||
| 1020 | "labels": [], | ||
| 1021 | "assignees": [], | ||
| 1022 | "comments": [], | ||
| 1023 | "linked_commits": [], | ||
| 1024 | "created_at": "2026-01-01T00:00:00Z", | ||
| 1025 | "last_updated": "", | ||
| 1026 | "author": {"name": "A", "email": "a@example.com"} | ||
| 1027 | }"#; | ||
| 1028 | let issue: IssueState = serde_json::from_str(json).unwrap(); | ||
| 1029 | assert_eq!(issue.relates_to, Vec::<String>::new()); | ||
| 1030 | } | ||
| 1031 | } | ||
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -67,7 +67,7 @@ mod tests { | |||
| 67 | created_at: String::new(), | 67 | created_at: String::new(), |
| 68 | last_updated: String::new(), | 68 | last_updated: String::new(), |
| 69 | author: make_author(), | 69 | author: make_author(), |
| 70 | relates_to: None, | 70 | relates_to: vec![], |
| 71 | } | 71 | } |
| 72 | } | 72 | } |
| 73 | 73 | ||
| @@ -258,7 +258,7 @@ mod tests { | |||
| 258 | created_at: "2026-01-01T00:00:00Z".to_string(), | 258 | created_at: "2026-01-01T00:00:00Z".to_string(), |
| 259 | last_updated: "2026-01-01T00:00:00Z".to_string(), | 259 | last_updated: "2026-01-01T00:00:00Z".to_string(), |
| 260 | author: test_author(), | 260 | author: test_author(), |
| 261 | relates_to: None, | 261 | relates_to: vec![], |
| 262 | }) | 262 | }) |
| 263 | .collect() | 263 | .collect() |
| 264 | } | 264 | } |
tests/archive_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -250,7 +250,7 @@ fn test_issue_open_with_relates_to() { | |||
| 250 | 250 | ||
| 251 | let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | 251 | let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); |
| 252 | assert_eq!(issue.title, "Child issue"); | 252 | assert_eq!(issue.title, "Child issue"); |
| 253 | assert_eq!(issue.relates_to.as_deref(), Some(id1.as_str())); | 253 | assert_eq!(issue.relates_to, vec![id1]); |
| 254 | } | 254 | } |
| 255 | 255 | ||
| 256 | #[test] | 256 | #[test] |
| @@ -261,5 +261,5 @@ fn test_issue_open_without_relates_to() { | |||
| 261 | let (ref_name, id) = open_issue(&repo, &alice(), "Solo issue"); | 261 | let (ref_name, id) = open_issue(&repo, &alice(), "Solo issue"); |
| 262 | 262 | ||
| 263 | let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | 263 | let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); |
| 264 | assert_eq!(issue.relates_to, None); | 264 | assert_eq!(issue.relates_to, Vec::<String>::new()); |
| 265 | } | 265 | } |
tests/cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -340,9 +340,9 @@ fn test_issue_relate_does_not_touch_target() { | |||
| 340 | } | 340 | } |
| 341 | 341 | ||
| 342 | #[test] | 342 | #[test] |
| 343 | fn test_issue_relate_replaces_existing_relation() { | 343 | fn test_issue_relate_to_multiple_issues() { |
| 344 | // relates_to is a single field, not a list: relating to a new target | 344 | // The motivating case: one issue can depend on more than one other. |
| 345 | // overwrites whatever was there before. | 345 | // c65ac0e2 relates to both 8db8d346 and 20c577fc. |
| 346 | let repo = TestRepo::new("Alice", "alice@example.com"); | 346 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 347 | let id1 = repo.issue_open("First issue"); | 347 | let id1 = repo.issue_open("First issue"); |
| 348 | let id2 = repo.issue_open("Second issue"); | 348 | let id2 = repo.issue_open("Second issue"); |
| @@ -352,8 +352,25 @@ fn test_issue_relate_replaces_existing_relation() { | |||
| 352 | repo.run_ok(&["issue", "relate", &id3, &id2]); | 352 | repo.run_ok(&["issue", "relate", &id3, &id2]); |
| 353 | 353 | ||
| 354 | let out = repo.run_ok(&["issue", "show", &id3]); | 354 | let out = repo.run_ok(&["issue", "show", &id3]); |
| 355 | assert!(out.contains(&id1[..8])); | ||
| 355 | assert!(out.contains(&id2[..8])); | 356 | assert!(out.contains(&id2[..8])); |
| 356 | assert!(!out.contains(&id1[..8])); | 357 | } |
| 358 | |||
| 359 | #[test] | ||
| 360 | fn test_issue_relate_is_idempotent() { | ||
| 361 | // Relating to the same target twice is not an error and does not | ||
| 362 | // duplicate the entry. | ||
| 363 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 364 | let id1 = repo.issue_open("First issue"); | ||
| 365 | let id2 = repo.issue_open("Second issue"); | ||
| 366 | |||
| 367 | repo.run_ok(&["issue", "relate", &id2, &id1]); | ||
| 368 | repo.run_ok(&["issue", "relate", &id2, &id1]); | ||
| 369 | |||
| 370 | let out = repo.run_ok(&["issue", "show", &id2, "--json"]); | ||
| 371 | let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); | ||
| 372 | let relates_to = parsed["relates_to"].as_array().unwrap(); | ||
| 373 | assert_eq!(relates_to.len(), 1); | ||
| 357 | } | 374 | } |
| 358 | 375 | ||
| 359 | #[test] | 376 | #[test] |
| @@ -371,9 +388,26 @@ fn test_issue_unrelate_removes_relation() { | |||
| 371 | } | 388 | } |
| 372 | 389 | ||
| 373 | #[test] | 390 | #[test] |
| 374 | fn test_issue_unrelate_mismatched_target_is_noop() { | 391 | fn test_issue_unrelate_removes_only_the_named_member() { |
| 375 | // Unrelating from an issue that isn't the current relation target | 392 | // Unrelating one member of the set leaves the others intact. |
| 376 | // leaves the existing relation untouched. | 393 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 394 | let id1 = repo.issue_open("First issue"); | ||
| 395 | let id2 = repo.issue_open("Second issue"); | ||
| 396 | let id3 = repo.issue_open("Third issue"); | ||
| 397 | |||
| 398 | repo.run_ok(&["issue", "relate", &id3, &id1]); | ||
| 399 | repo.run_ok(&["issue", "relate", &id3, &id2]); | ||
| 400 | repo.run_ok(&["issue", "unrelate", &id3, &id1]); | ||
| 401 | |||
| 402 | let out = repo.run_ok(&["issue", "show", &id3]); | ||
| 403 | assert!(!out.contains(&id1[..8])); | ||
| 404 | assert!(out.contains(&id2[..8])); | ||
| 405 | } | ||
| 406 | |||
| 407 | #[test] | ||
| 408 | fn test_issue_unrelate_absent_target_is_noop() { | ||
| 409 | // Unrelating a target that was never related leaves the existing | ||
| 410 | // relation(s) untouched. | ||
| 377 | let repo = TestRepo::new("Alice", "alice@example.com"); | 411 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 378 | let id1 = repo.issue_open("First issue"); | 412 | let id1 = repo.issue_open("First issue"); |
| 379 | let id2 = repo.issue_open("Second issue"); | 413 | let id2 = repo.issue_open("Second issue"); |
| @@ -392,22 +426,28 @@ fn test_issue_relate_shown_in_json() { | |||
| 392 | let repo = TestRepo::new("Alice", "alice@example.com"); | 426 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 393 | let id1 = repo.issue_open("First issue"); | 427 | let id1 = repo.issue_open("First issue"); |
| 394 | let id2 = repo.issue_open("Second issue"); | 428 | let id2 = repo.issue_open("Second issue"); |
| 429 | let id3 = repo.issue_open("Third issue"); | ||
| 395 | 430 | ||
| 396 | repo.run_ok(&["issue", "relate", &id2, &id1]); | 431 | repo.run_ok(&["issue", "relate", &id3, &id1]); |
| 432 | repo.run_ok(&["issue", "relate", &id3, &id2]); | ||
| 397 | 433 | ||
| 398 | let out = repo.run_ok(&["issue", "show", &id2, "--json"]); | 434 | let out = repo.run_ok(&["issue", "show", &id3, "--json"]); |
| 399 | let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); | 435 | let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); |
| 400 | assert!(parsed["relates_to"] | 436 | let relates_to: Vec<&str> = parsed["relates_to"] |
| 401 | .as_str() | 437 | .as_array() |
| 402 | .unwrap() | 438 | .unwrap() |
| 403 | .starts_with(&id1[..8])); | 439 | .iter() |
| 440 | .map(|v| v.as_str().unwrap()) | ||
| 441 | .collect(); | ||
| 442 | assert!(relates_to.contains(&id1.as_str())); | ||
| 443 | assert!(relates_to.contains(&id2.as_str())); | ||
| 404 | } | 444 | } |
| 405 | 445 | ||
| 406 | #[test] | 446 | #[test] |
| 407 | fn test_issue_relate_consistent_with_open_relates_to() { | 447 | fn test_issue_relate_consistent_with_open_relates_to() { |
| 408 | // `issue relate` after the fact should leave the same state shape as | 448 | // `issue relate` after the fact should leave the same state shape as |
| 409 | // `--relates-to` at creation time: a single relates_to field on the | 449 | // `--relates-to` at creation time: `--relates-to` seeds a one-element |
| 410 | // issue that names the other issue. | 450 | // relates_to set, just like a single `relate` call would. |
| 411 | let repo = TestRepo::new("Alice", "alice@example.com"); | 451 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 412 | let id1 = repo.issue_open("First issue"); | 452 | let id1 = repo.issue_open("First issue"); |
| 413 | 453 | ||
| @@ -425,10 +465,8 @@ fn test_issue_relate_consistent_with_open_relates_to() { | |||
| 425 | let out3 = repo.run_ok(&["issue", "show", &id3, "--json"]); | 465 | let out3 = repo.run_ok(&["issue", "show", &id3, "--json"]); |
| 426 | let parsed2: serde_json::Value = serde_json::from_str(&out2).unwrap(); | 466 | let parsed2: serde_json::Value = serde_json::from_str(&out2).unwrap(); |
| 427 | let parsed3: serde_json::Value = serde_json::from_str(&out3).unwrap(); | 467 | let parsed3: serde_json::Value = serde_json::from_str(&out3).unwrap(); |
| 428 | assert_eq!( | 468 | assert_eq!(parsed2["relates_to"], serde_json::json!([id1])); |
| 429 | parsed2["relates_to"].is_string(), | 469 | assert_eq!(parsed3["relates_to"], serde_json::json!([id1])); |
| 430 | parsed3["relates_to"].is_string() | ||
| 431 | ); | ||
| 432 | } | 470 | } |
| 433 | 471 | ||
| 434 | // =========================================================================== | 472 | // =========================================================================== |