324b0548
Wire up 'git-collab issue reopen <id>'
a73x 2026-08-09 16:50
Commit message
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -237,6 +237,11 @@ pub enum IssueCmd { | |||
| 237 | /// Issue ID (prefix match) | 237 | /// Issue ID (prefix match) |
| 238 | id: String, | 238 | id: String, |
| 239 | }, | 239 | }, |
| 240 | /// Reopen a closed issue | ||
| 241 | Reopen { | ||
| 242 | /// Issue ID (prefix match) | ||
| 243 | id: String, | ||
| 244 | }, | ||
| 240 | } | 245 | } |
| 241 | 246 | ||
| 242 | #[derive(Subcommand)] | 247 | #[derive(Subcommand)] |
| @@ -414,6 +419,7 @@ impl Commands { | |||
| 414 | | IssueCmd::Unlabel { .. } | 419 | | IssueCmd::Unlabel { .. } |
| 415 | | IssueCmd::Assign { .. } | 420 | | IssueCmd::Assign { .. } |
| 416 | | IssueCmd::Unassign { .. } | 421 | | IssueCmd::Unassign { .. } |
| 422 | | IssueCmd::Reopen { .. } | ||
| 417 | ), | 423 | ), |
| 418 | Commands::Patch(cmd) => matches!( | 424 | Commands::Patch(cmd) => matches!( |
| 419 | cmd, | 425 | cmd, |
src/issue.rs
| Old | New | ||
|---|---|---|---|
| @@ -260,7 +260,12 @@ pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error | |||
| 260 | } | 260 | } |
| 261 | 261 | ||
| 262 | pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { | 262 | pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { |
| 263 | let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; | 263 | let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; |
| 264 | dag::append_action(repo, &ref_name, Action::IssueReopen)?; | 264 | dag::append_action(repo, &ref_name, Action::IssueReopen)?; |
| 265 | // Move the ref back out of the archive namespace (undoes what close() did), | ||
| 266 | // so the reopened issue is visible to the default `issue list` again. | ||
| 267 | if ref_name.starts_with("refs/collab/archive/issues/") { | ||
| 268 | state::unarchive_issue_ref(repo, &id)?; | ||
| 269 | } | ||
| 265 | Ok(()) | 270 | Ok(()) |
| 266 | } | 271 | } |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -278,6 +278,11 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 278 | println!("Deleted issue {:.8}", full_id); | 278 | println!("Deleted issue {:.8}", full_id); |
| 279 | Ok(()) | 279 | Ok(()) |
| 280 | } | 280 | } |
| 281 | IssueCmd::Reopen { id } => { | ||
| 282 | issue::reopen(repo, &id)?; | ||
| 283 | println!("Issue reopened."); | ||
| 284 | Ok(()) | ||
| 285 | } | ||
| 281 | }, | 286 | }, |
| 282 | Commands::Patch(cmd) => match cmd { | 287 | Commands::Patch(cmd) => match cmd { |
| 283 | PatchCmd::Create { | 288 | PatchCmd::Create { |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -882,6 +882,16 @@ pub fn archive_issue_ref(repo: &Repository, id: &str) -> Result<(), crate::error | |||
| 882 | Ok(()) | 882 | Ok(()) |
| 883 | } | 883 | } |
| 884 | 884 | ||
| 885 | /// Move an issue ref from archive back to the active namespace. | ||
| 886 | pub fn unarchive_issue_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { | ||
| 887 | let old_ref = format!("refs/collab/archive/issues/{}", id); | ||
| 888 | let oid = repo.refname_to_id(&old_ref)?; | ||
| 889 | let new_ref = format!("refs/collab/issues/{}", id); | ||
| 890 | repo.reference(&new_ref, oid, false, "unarchive issue")?; | ||
| 891 | repo.find_reference(&old_ref)?.delete()?; | ||
| 892 | Ok(()) | ||
| 893 | } | ||
| 894 | |||
| 885 | /// Move a patch ref from active to archive namespace. | 895 | /// Move a patch ref from active to archive namespace. |
| 886 | pub fn archive_patch_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { | 896 | pub fn archive_patch_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { |
| 887 | let old_ref = format!("refs/collab/patches/{}", id); | 897 | let old_ref = format!("refs/collab/patches/{}", id); |
tests/archive_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ use tempfile::TempDir; | |||
| 4 | 4 | ||
| 5 | use git_collab::dag; | 5 | use git_collab::dag; |
| 6 | use git_collab::event::{Action, Event}; | 6 | use git_collab::event::{Action, Event}; |
| 7 | use git_collab::issue; | ||
| 7 | use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 8 | use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 8 | 9 | ||
| 9 | use common::{alice, close_issue, create_patch, init_repo, now, open_issue, test_signing_key}; | 10 | use common::{alice, close_issue, create_patch, init_repo, now, open_issue, test_signing_key}; |
| @@ -82,6 +83,40 @@ fn test_resolve_issue_ref_finds_archived() { | |||
| 82 | assert!(resolved_ref.contains("archive")); | 83 | assert!(resolved_ref.contains("archive")); |
| 83 | } | 84 | } |
| 84 | 85 | ||
| 86 | #[test] | ||
| 87 | fn test_reopen_issue_moves_ref_out_of_archive() { | ||
| 88 | let tmp = TempDir::new().unwrap(); | ||
| 89 | let repo = init_repo(tmp.path(), &alice()); | ||
| 90 | |||
| 91 | let (ref_name, id) = open_issue(&repo, &alice(), "Reopen me"); | ||
| 92 | issue::close(&repo, &id, None).unwrap(); | ||
| 93 | |||
| 94 | // Sanity check: closing archived the ref. | ||
| 95 | let archive_ref = format!("refs/collab/archive/issues/{}", id); | ||
| 96 | assert!(repo.refname_to_id(&archive_ref).is_ok()); | ||
| 97 | assert!(repo.refname_to_id(&ref_name).is_err()); | ||
| 98 | |||
| 99 | issue::reopen(&repo, &id).unwrap(); | ||
| 100 | |||
| 101 | // The ref must move back out of the archive namespace, or the reopened | ||
| 102 | // issue stays invisible to the default `issue list`. | ||
| 103 | assert!( | ||
| 104 | repo.refname_to_id(&ref_name).is_ok(), | ||
| 105 | "active ref should be restored" | ||
| 106 | ); | ||
| 107 | assert!( | ||
| 108 | repo.refname_to_id(&archive_ref).is_err(), | ||
| 109 | "archive ref should be removed" | ||
| 110 | ); | ||
| 111 | |||
| 112 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 113 | assert_eq!(state.status, IssueStatus::Open); | ||
| 114 | |||
| 115 | let issues = state::list_issues(&repo).unwrap(); | ||
| 116 | assert_eq!(issues.len(), 1); | ||
| 117 | assert_eq!(issues[0].title, "Reopen me"); | ||
| 118 | } | ||
| 119 | |||
| 85 | // --------------------------------------------------------------------------- | 120 | // --------------------------------------------------------------------------- |
| 86 | // Archive on close: patches | 121 | // Archive on close: patches |
| 87 | // --------------------------------------------------------------------------- | 122 | // --------------------------------------------------------------------------- |
tests/cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -89,6 +89,27 @@ fn test_issue_close_without_reason() { | |||
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | #[test] | 91 | #[test] |
| 92 | fn test_issue_reopen() { | ||
| 93 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 94 | let id = repo.issue_open("Closed by mistake"); | ||
| 95 | |||
| 96 | repo.run_ok(&["issue", "close", &id]); | ||
| 97 | let out = repo.run_ok(&["issue", "show", &id]); | ||
| 98 | assert!(out.contains("[closed]")); | ||
| 99 | |||
| 100 | let out = repo.run_ok(&["issue", "reopen", &id]); | ||
| 101 | assert!(out.contains("Issue reopened")); | ||
| 102 | |||
| 103 | let out = repo.run_ok(&["issue", "show", &id]); | ||
| 104 | assert!(out.contains("[open]")); | ||
| 105 | |||
| 106 | // Reopening must move the ref back out of refs/collab/archive/, or the | ||
| 107 | // issue stays invisible to the default (non-archived) list. | ||
| 108 | let out = repo.run_ok(&["issue", "list"]); | ||
| 109 | assert!(out.contains("Closed by mistake")); | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 92 | fn test_issue_prefix_resolution() { | 113 | fn test_issue_prefix_resolution() { |
| 93 | let repo = TestRepo::new("Alice", "alice@example.com"); | 114 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 94 | let id = repo.issue_open("Prefix test"); | 115 | let id = repo.issue_open("Prefix test"); |