77e770b7
Add issue relate/unrelate commands
a73x 2026-08-09 16:50
Commit message
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -210,6 +210,20 @@ pub enum IssueCmd { | |||
| 210 | /// Label to remove | 210 | /// Label to remove |
| 211 | label: String, | 211 | label: String, |
| 212 | }, | 212 | }, |
| 213 | /// Relate an issue to another issue | ||
| 214 | Relate { | ||
| 215 | /// Issue ID (prefix match) | ||
| 216 | id: String, | ||
| 217 | /// Other issue ID to relate to | ||
| 218 | other: String, | ||
| 219 | }, | ||
| 220 | /// Remove a relation between two issues | ||
| 221 | Unrelate { | ||
| 222 | /// Issue ID (prefix match) | ||
| 223 | id: String, | ||
| 224 | /// Other issue ID to unrelate from | ||
| 225 | other: String, | ||
| 226 | }, | ||
| 213 | /// Assign an issue to someone | 227 | /// Assign an issue to someone |
| 214 | Assign { | 228 | Assign { |
| 215 | /// Issue ID (prefix match) | 229 | /// Issue ID (prefix match) |
| @@ -417,6 +431,8 @@ impl Commands { | |||
| 417 | | IssueCmd::Edit { .. } | 431 | | IssueCmd::Edit { .. } |
| 418 | | IssueCmd::Label { .. } | 432 | | IssueCmd::Label { .. } |
| 419 | | IssueCmd::Unlabel { .. } | 433 | | IssueCmd::Unlabel { .. } |
| 434 | | IssueCmd::Relate { .. } | ||
| 435 | | IssueCmd::Unrelate { .. } | ||
| 420 | | IssueCmd::Assign { .. } | 436 | | IssueCmd::Assign { .. } |
| 421 | | IssueCmd::Unassign { .. } | 437 | | IssueCmd::Unassign { .. } |
| 422 | | IssueCmd::Reopen { .. } | 438 | | IssueCmd::Reopen { .. } |
src/dag.rs
| Old | New | ||
|---|---|---|---|
| @@ -319,6 +319,8 @@ fn commit_message(action: &Action) -> String { | |||
| 319 | Action::IssueEdit { .. } => "issue: edit".to_string(), | 319 | Action::IssueEdit { .. } => "issue: edit".to_string(), |
| 320 | Action::IssueLabel { ref label } => format!("issue: label \"{}\"", label), | 320 | Action::IssueLabel { ref label } => format!("issue: label \"{}\"", label), |
| 321 | Action::IssueUnlabel { ref label } => format!("issue: unlabel \"{}\"", label), | 321 | Action::IssueUnlabel { ref label } => format!("issue: unlabel \"{}\"", label), |
| 322 | Action::IssueRelate { ref relates_to } => format!("issue: relate {:.8}", relates_to), | ||
| 323 | Action::IssueUnrelate { ref relates_to } => format!("issue: unrelate {:.8}", relates_to), | ||
| 322 | Action::IssueAssign { ref assignee } => format!("issue: assign \"{}\"", assignee), | 324 | Action::IssueAssign { ref assignee } => format!("issue: assign \"{}\"", assignee), |
| 323 | Action::IssueUnassign { ref assignee } => format!("issue: unassign \"{}\"", assignee), | 325 | Action::IssueUnassign { ref assignee } => format!("issue: unassign \"{}\"", assignee), |
| 324 | Action::IssueComment { .. } => "issue: comment".to_string(), | 326 | Action::IssueComment { .. } => "issue: comment".to_string(), |
src/event.rs
| Old | New | ||
|---|---|---|---|
| @@ -38,6 +38,10 @@ pub enum Action { | |||
| 38 | IssueLabel { label: String }, | 38 | IssueLabel { label: String }, |
| 39 | #[serde(rename = "issue.unlabel")] | 39 | #[serde(rename = "issue.unlabel")] |
| 40 | IssueUnlabel { label: String }, | 40 | IssueUnlabel { label: String }, |
| 41 | #[serde(rename = "issue.relate")] | ||
| 42 | IssueRelate { relates_to: String }, | ||
| 43 | #[serde(rename = "issue.unrelate")] | ||
| 44 | IssueUnrelate { relates_to: String }, | ||
| 41 | #[serde(rename = "issue.assign")] | 45 | #[serde(rename = "issue.assign")] |
| 42 | IssueAssign { assignee: String }, | 46 | IssueAssign { assignee: String }, |
| 43 | #[serde(rename = "issue.unassign")] | 47 | #[serde(rename = "issue.unassign")] |
src/issue.rs
| Old | New | ||
|---|---|---|---|
| @@ -166,6 +166,30 @@ pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), cr | |||
| 166 | Ok(()) | 166 | Ok(()) |
| 167 | } | 167 | } |
| 168 | 168 | ||
| 169 | pub fn relate(repo: &Repository, id_prefix: &str, other: &str) -> Result<(), crate::error::Error> { | ||
| 170 | let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; | ||
| 171 | dag::append_action( | ||
| 172 | repo, | ||
| 173 | &ref_name, | ||
| 174 | Action::IssueRelate { | ||
| 175 | relates_to: other.to_string(), | ||
| 176 | }, | ||
| 177 | )?; | ||
| 178 | Ok(()) | ||
| 179 | } | ||
| 180 | |||
| 181 | pub fn unrelate(repo: &Repository, id_prefix: &str, other: &str) -> Result<(), crate::error::Error> { | ||
| 182 | let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; | ||
| 183 | dag::append_action( | ||
| 184 | repo, | ||
| 185 | &ref_name, | ||
| 186 | Action::IssueUnrelate { | ||
| 187 | relates_to: other.to_string(), | ||
| 188 | }, | ||
| 189 | )?; | ||
| 190 | Ok(()) | ||
| 191 | } | ||
| 192 | |||
| 169 | pub fn assign( | 193 | pub fn assign( |
| 170 | repo: &Repository, | 194 | repo: &Repository, |
| 171 | id_prefix: &str, | 195 | id_prefix: &str, |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -248,6 +248,16 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 248 | println!("Label '{}' removed.", label); | 248 | println!("Label '{}' removed.", label); |
| 249 | Ok(()) | 249 | Ok(()) |
| 250 | } | 250 | } |
| 251 | IssueCmd::Relate { id, other } => { | ||
| 252 | issue::relate(repo, &id, &other)?; | ||
| 253 | println!("Related to '{}'.", other); | ||
| 254 | Ok(()) | ||
| 255 | } | ||
| 256 | IssueCmd::Unrelate { id, other } => { | ||
| 257 | issue::unrelate(repo, &id, &other)?; | ||
| 258 | println!("Unrelated from '{}'.", other); | ||
| 259 | Ok(()) | ||
| 260 | } | ||
| 251 | IssueCmd::Assign { id, name } => { | 261 | IssueCmd::Assign { id, name } => { |
| 252 | issue::assign(repo, &id, &name)?; | 262 | issue::assign(repo, &id, &name)?; |
| 253 | println!("Assigned to '{}'.", name); | 263 | println!("Assigned to '{}'.", name); |
src/log.rs
| Old | New | ||
|---|---|---|---|
| @@ -112,6 +112,8 @@ fn action_type_name(action: &Action) -> String { | |||
| 112 | Action::IssueEdit { .. } => "IssueEdit".to_string(), | 112 | Action::IssueEdit { .. } => "IssueEdit".to_string(), |
| 113 | Action::IssueLabel { .. } => "IssueLabel".to_string(), | 113 | Action::IssueLabel { .. } => "IssueLabel".to_string(), |
| 114 | Action::IssueUnlabel { .. } => "IssueUnlabel".to_string(), | 114 | Action::IssueUnlabel { .. } => "IssueUnlabel".to_string(), |
| 115 | Action::IssueRelate { .. } => "IssueRelate".to_string(), | ||
| 116 | Action::IssueUnrelate { .. } => "IssueUnrelate".to_string(), | ||
| 115 | Action::IssueAssign { .. } => "IssueAssign".to_string(), | 117 | Action::IssueAssign { .. } => "IssueAssign".to_string(), |
| 116 | Action::IssueUnassign { .. } => "IssueUnassign".to_string(), | 118 | Action::IssueUnassign { .. } => "IssueUnassign".to_string(), |
| 117 | Action::IssueReopen => "IssueReopen".to_string(), | 119 | Action::IssueReopen => "IssueReopen".to_string(), |
| @@ -147,6 +149,8 @@ fn action_summary(action: &Action) -> String { | |||
| 147 | } | 149 | } |
| 148 | Action::IssueLabel { label } => format!("label \"{}\"", label), | 150 | Action::IssueLabel { label } => format!("label \"{}\"", label), |
| 149 | Action::IssueUnlabel { label } => format!("unlabel \"{}\"", label), | 151 | Action::IssueUnlabel { label } => format!("unlabel \"{}\"", label), |
| 152 | Action::IssueRelate { relates_to } => format!("relate {:.8}", relates_to), | ||
| 153 | Action::IssueUnrelate { relates_to } => format!("unrelate {:.8}", relates_to), | ||
| 150 | Action::IssueAssign { assignee } => format!("assign \"{}\"", assignee), | 154 | Action::IssueAssign { assignee } => format!("assign \"{}\"", assignee), |
| 151 | Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee), | 155 | Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee), |
| 152 | Action::IssueReopen => "reopen".to_string(), | 156 | Action::IssueReopen => "reopen".to_string(), |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -363,6 +363,22 @@ impl IssueState { | |||
| 363 | s.labels.retain(|l| l != &label); | 363 | s.labels.retain(|l| l != &label); |
| 364 | } | 364 | } |
| 365 | } | 365 | } |
| 366 | Action::IssueRelate { relates_to } => { | ||
| 367 | if let Some(ref mut s) = state { | ||
| 368 | s.relates_to = Some(relates_to); | ||
| 369 | } | ||
| 370 | } | ||
| 371 | Action::IssueUnrelate { relates_to } => { | ||
| 372 | if let Some(ref mut s) = state { | ||
| 373 | // relates_to is a single field, not a set: only clear | ||
| 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 | } | ||
| 381 | } | ||
| 366 | Action::IssueAssign { assignee } => { | 382 | Action::IssueAssign { assignee } => { |
| 367 | if let Some(ref mut s) = state { | 383 | if let Some(ref mut s) = state { |
| 368 | if !s.assignees.contains(&assignee) { | 384 | if !s.assignees.contains(&assignee) { |
src/tui/widgets.rs
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,8 @@ pub(crate) fn action_type_label(action: &Action) -> &str { | |||
| 24 | Action::IssueEdit { .. } => "Issue Edit", | 24 | Action::IssueEdit { .. } => "Issue Edit", |
| 25 | Action::IssueLabel { .. } => "Issue Label", | 25 | Action::IssueLabel { .. } => "Issue Label", |
| 26 | Action::IssueUnlabel { .. } => "Issue Unlabel", | 26 | Action::IssueUnlabel { .. } => "Issue Unlabel", |
| 27 | Action::IssueRelate { .. } => "Issue Relate", | ||
| 28 | Action::IssueUnrelate { .. } => "Issue Unrelate", | ||
| 27 | Action::IssueAssign { .. } => "Issue Assign", | 29 | Action::IssueAssign { .. } => "Issue Assign", |
| 28 | Action::IssueUnassign { .. } => "Issue Unassign", | 30 | Action::IssueUnassign { .. } => "Issue Unassign", |
| 29 | Action::IssueCommitLink { .. } => "Issue Commit Link", | 31 | Action::IssueCommitLink { .. } => "Issue Commit Link", |
| @@ -108,6 +110,12 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str | |||
| 108 | Action::IssueUnlabel { label } => { | 110 | Action::IssueUnlabel { label } => { |
| 109 | detail.push_str(&format!("\nRemoved Label: {}\n", label)); | 111 | detail.push_str(&format!("\nRemoved Label: {}\n", label)); |
| 110 | } | 112 | } |
| 113 | Action::IssueRelate { relates_to } => { | ||
| 114 | detail.push_str(&format!("\nRelates To: {:.8}\n", relates_to)); | ||
| 115 | } | ||
| 116 | Action::IssueUnrelate { relates_to } => { | ||
| 117 | detail.push_str(&format!("\nRemoved Relation: {:.8}\n", relates_to)); | ||
| 118 | } | ||
| 111 | Action::IssueAssign { assignee } => { | 119 | Action::IssueAssign { assignee } => { |
| 112 | detail.push_str(&format!("\nAssignee: {}\n", assignee)); | 120 | detail.push_str(&format!("\nAssignee: {}\n", assignee)); |
| 113 | } | 121 | } |
tests/cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -308,6 +308,130 @@ fn test_issue_unassign() { | |||
| 308 | } | 308 | } |
| 309 | 309 | ||
| 310 | // =========================================================================== | 310 | // =========================================================================== |
| 311 | // Issue relations | ||
| 312 | // =========================================================================== | ||
| 313 | |||
| 314 | #[test] | ||
| 315 | fn test_issue_relate_and_show() { | ||
| 316 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 317 | let id1 = repo.issue_open("First issue"); | ||
| 318 | let id2 = repo.issue_open("Second issue"); | ||
| 319 | |||
| 320 | let out = repo.run_ok(&["issue", "relate", &id2, &id1]); | ||
| 321 | assert!(out.contains("Related")); | ||
| 322 | |||
| 323 | let out = repo.run_ok(&["issue", "show", &id2]); | ||
| 324 | assert!(out.contains("Relates-to")); | ||
| 325 | assert!(out.contains(&id1[..8])); | ||
| 326 | } | ||
| 327 | |||
| 328 | #[test] | ||
| 329 | fn test_issue_relate_does_not_touch_target() { | ||
| 330 | // relate is one-directional, consistent with `--relates-to` at creation | ||
| 331 | // time: it only writes to the issue named first. | ||
| 332 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 333 | let id1 = repo.issue_open("First issue"); | ||
| 334 | let id2 = repo.issue_open("Second issue"); | ||
| 335 | |||
| 336 | repo.run_ok(&["issue", "relate", &id2, &id1]); | ||
| 337 | |||
| 338 | let out = repo.run_ok(&["issue", "show", &id1]); | ||
| 339 | assert!(!out.contains("Relates-to")); | ||
| 340 | } | ||
| 341 | |||
| 342 | #[test] | ||
| 343 | fn test_issue_relate_replaces_existing_relation() { | ||
| 344 | // relates_to is a single field, not a list: relating to a new target | ||
| 345 | // overwrites whatever was there before. | ||
| 346 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 347 | let id1 = repo.issue_open("First issue"); | ||
| 348 | let id2 = repo.issue_open("Second issue"); | ||
| 349 | let id3 = repo.issue_open("Third issue"); | ||
| 350 | |||
| 351 | repo.run_ok(&["issue", "relate", &id3, &id1]); | ||
| 352 | repo.run_ok(&["issue", "relate", &id3, &id2]); | ||
| 353 | |||
| 354 | let out = repo.run_ok(&["issue", "show", &id3]); | ||
| 355 | assert!(out.contains(&id2[..8])); | ||
| 356 | assert!(!out.contains(&id1[..8])); | ||
| 357 | } | ||
| 358 | |||
| 359 | #[test] | ||
| 360 | fn test_issue_unrelate_removes_relation() { | ||
| 361 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 362 | let id1 = repo.issue_open("First issue"); | ||
| 363 | let id2 = repo.issue_open("Second issue"); | ||
| 364 | |||
| 365 | repo.run_ok(&["issue", "relate", &id2, &id1]); | ||
| 366 | let out = repo.run_ok(&["issue", "unrelate", &id2, &id1]); | ||
| 367 | assert!(out.contains("Unrelated") || out.contains("removed")); | ||
| 368 | |||
| 369 | let out = repo.run_ok(&["issue", "show", &id2]); | ||
| 370 | assert!(!out.contains("Relates-to")); | ||
| 371 | } | ||
| 372 | |||
| 373 | #[test] | ||
| 374 | fn test_issue_unrelate_mismatched_target_is_noop() { | ||
| 375 | // Unrelating from an issue that isn't the current relation target | ||
| 376 | // leaves the existing relation untouched. | ||
| 377 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 378 | let id1 = repo.issue_open("First issue"); | ||
| 379 | let id2 = repo.issue_open("Second issue"); | ||
| 380 | let id3 = repo.issue_open("Third issue"); | ||
| 381 | |||
| 382 | repo.run_ok(&["issue", "relate", &id3, &id1]); | ||
| 383 | repo.run_ok(&["issue", "unrelate", &id3, &id2]); | ||
| 384 | |||
| 385 | let out = repo.run_ok(&["issue", "show", &id3]); | ||
| 386 | assert!(out.contains("Relates-to")); | ||
| 387 | assert!(out.contains(&id1[..8])); | ||
| 388 | } | ||
| 389 | |||
| 390 | #[test] | ||
| 391 | fn test_issue_relate_shown_in_json() { | ||
| 392 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 393 | let id1 = repo.issue_open("First issue"); | ||
| 394 | let id2 = repo.issue_open("Second issue"); | ||
| 395 | |||
| 396 | repo.run_ok(&["issue", "relate", &id2, &id1]); | ||
| 397 | |||
| 398 | let out = repo.run_ok(&["issue", "show", &id2, "--json"]); | ||
| 399 | let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); | ||
| 400 | assert!(parsed["relates_to"] | ||
| 401 | .as_str() | ||
| 402 | .unwrap() | ||
| 403 | .starts_with(&id1[..8])); | ||
| 404 | } | ||
| 405 | |||
| 406 | #[test] | ||
| 407 | fn test_issue_relate_consistent_with_open_relates_to() { | ||
| 408 | // `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 | ||
| 410 | // issue that names the other issue. | ||
| 411 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 412 | let id1 = repo.issue_open("First issue"); | ||
| 413 | |||
| 414 | let out = repo.run_ok(&["issue", "open", "-t", "Second issue", "--relates-to", &id1]); | ||
| 415 | let id2 = out | ||
| 416 | .trim() | ||
| 417 | .strip_prefix("Opened issue ") | ||
| 418 | .unwrap() | ||
| 419 | .to_string(); | ||
| 420 | let id3 = repo.issue_open("Third issue"); | ||
| 421 | |||
| 422 | repo.run_ok(&["issue", "relate", &id3, &id1]); | ||
| 423 | |||
| 424 | let out2 = repo.run_ok(&["issue", "show", &id2, "--json"]); | ||
| 425 | let out3 = repo.run_ok(&["issue", "show", &id3, "--json"]); | ||
| 426 | let parsed2: serde_json::Value = serde_json::from_str(&out2).unwrap(); | ||
| 427 | let parsed3: serde_json::Value = serde_json::from_str(&out3).unwrap(); | ||
| 428 | assert_eq!( | ||
| 429 | parsed2["relates_to"].is_string(), | ||
| 430 | parsed3["relates_to"].is_string() | ||
| 431 | ); | ||
| 432 | } | ||
| 433 | |||
| 434 | // =========================================================================== | ||
| 311 | // Patch commands | 435 | // Patch commands |
| 312 | // =========================================================================== | 436 | // =========================================================================== |
| 313 | 437 | ||