a73x

77e770b7

Add issue relate/unrelate commands

a73x   2026-08-09 16:50

Commit message
Add issue relate/unrelate commands

relates_to could previously only be set via --relates-to at issue open
time, but dependencies between issues usually only become clear after
both already exist (issue dd4cfa39). Add `issue relate <id> <other>`
and `issue unrelate <id> <other>` on top of the existing relates_to
field, without adding relation kinds or direction.

relates_to is a single Option<String> written only to the issue named
first, never mirrored onto the target -- it was never symmetric. relate
and unrelate preserve that: one IssueRelate/IssueUnrelate event on the
named issue's own ref. unrelate only clears the field if it still
points at the given target, so a stale/mismatched unrelate is a no-op
rather than clobbering a newer relation.

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