a73x

cb8a650a

Add patch labels: full write path + --label on patch list

a73x   2026-08-10 05:57

Commit message
Add patch labels: full write path + --label on patch list

Issues have labels but patches never gained the equivalent write path.
Adds Action::PatchLabel/PatchUnlabel (mirroring IssueLabel/IssueUnlabel),
a labels: Vec<String> field on PatchState (serde-default so a PatchState
serialized before this field existed loads as empty), `patch label` /
`patch unlabel` commands, and re-enables --label on `patch list` (OR
semantics, via the filter_sort_paginate plumbing issue list already
used). Labels render in patch show, patch list, --json, the web UI, and
the TUI patch detail pane.

Fixes e41c794e.

src/cli.rs
Old New
@@ -342,6 +342,9 @@ pub enum PatchCmd {
342 /// Sort order: recent (default), created, alpha 342 /// Sort order: recent (default), created, alpha
343 #[arg(long, default_value = "recent")] 343 #[arg(long, default_value = "recent")]
344 sort: SortMode, 344 sort: SortMode,
345 /// Filter by label (repeatable; matches patches carrying any of the given labels)
346 #[arg(long)]
347 label: Vec<String>,
345 }, 348 },
346 /// Show patch details 349 /// Show patch details
347 Show { 350 Show {
@@ -415,6 +418,20 @@ pub enum PatchCmd {
415 #[arg(long)] 418 #[arg(long)]
416 json: bool, 419 json: bool,
417 }, 420 },
421 /// Add a label to a patch
422 Label {
423 /// Patch ID (prefix match)
424 id: String,
425 /// Label to add
426 label: String,
427 },
428 /// Remove a label from a patch
429 Unlabel {
430 /// Patch ID (prefix match)
431 id: String,
432 /// Label to remove
433 label: String,
434 },
418 /// Close a patch 435 /// Close a patch
419 Close { 436 Close {
420 /// Patch ID (prefix match) 437 /// Patch ID (prefix match)
@@ -458,6 +475,8 @@ impl Commands {
458 | PatchCmd::Comment { .. } 475 | PatchCmd::Comment { .. }
459 | PatchCmd::Review { .. } 476 | PatchCmd::Review { .. }
460 | PatchCmd::Revise { .. } 477 | PatchCmd::Revise { .. }
478 | PatchCmd::Label { .. }
479 | PatchCmd::Unlabel { .. }
461 | PatchCmd::Close { .. } 480 | PatchCmd::Close { .. }
462 ), 481 ),
463 _ => false, 482 _ => false,
src/dag.rs
Old New
@@ -328,6 +328,8 @@ fn commit_message(action: &Action) -> String {
328 Action::IssueReopen => "issue: reopen".to_string(), 328 Action::IssueReopen => "issue: reopen".to_string(),
329 Action::IssueCommitLink { commit } => format!("issue: commit link {}", &commit[..commit.len().min(7)]), 329 Action::IssueCommitLink { commit } => format!("issue: commit link {}", &commit[..commit.len().min(7)]),
330 Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title), 330 Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title),
331 Action::PatchLabel { ref label } => format!("patch: label \"{}\"", label),
332 Action::PatchUnlabel { ref label } => format!("patch: unlabel \"{}\"", label),
331 Action::PatchRevision { .. } => "patch: revision".to_string(), 333 Action::PatchRevision { .. } => "patch: revision".to_string(),
332 Action::PatchReview { verdict, .. } => format!("patch: review ({})", verdict), 334 Action::PatchReview { verdict, .. } => format!("patch: review ({})", verdict),
333 Action::PatchComment { .. } => "patch: comment".to_string(), 335 Action::PatchComment { .. } => "patch: comment".to_string(),
src/event.rs
Old New
@@ -121,6 +121,10 @@ pub enum Action {
121 #[serde(default, skip_serializing_if = "Option::is_none")] 121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 revision: Option<u32>, 122 revision: Option<u32>,
123 }, 123 },
124 #[serde(rename = "patch.label")]
125 PatchLabel { label: String },
126 #[serde(rename = "patch.unlabel")]
127 PatchUnlabel { label: String },
124 #[serde(rename = "patch.comment")] 128 #[serde(rename = "patch.comment")]
125 PatchComment { body: String }, 129 PatchComment { body: String },
126 #[serde(rename = "patch.inline_comment")] 130 #[serde(rename = "patch.inline_comment")]
src/lib.rs
Old New
@@ -348,18 +348,24 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
348 offset, 348 offset,
349 json, 349 json,
350 sort, 350 sort,
351 label,
351 } => { 352 } => {
352 if json { 353 if json {
353 let output = patch::list_json(repo, all, archived, sort)?; 354 let output = patch::list_json(repo, all, archived, sort, &label)?;
354 println!("{}", output); 355 println!("{}", output);
355 return Ok(()); 356 return Ok(());
356 } 357 }
357 let entries = patch::list(repo, all, archived, limit, offset, sort)?; 358 let entries = patch::list(repo, all, archived, limit, offset, sort, &label)?;
358 if entries.is_empty() { 359 if entries.is_empty() {
359 println!("No patches found."); 360 println!("No patches found.");
360 } else { 361 } else {
361 for e in &entries { 362 for e in &entries {
362 let p = &e.patch; 363 let p = &e.patch;
364 let labels = if p.labels.is_empty() {
365 String::new()
366 } else {
367 format!(" [{}]", p.labels.join(", "))
368 };
363 let stale = match p.staleness(repo) { 369 let stale = match p.staleness(repo) {
364 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind), 370 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
365 Ok(_) => String::new(), 371 Ok(_) => String::new(),
@@ -370,8 +376,8 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
370 _ => String::new(), 376 _ => String::new(),
371 }; 377 };
372 println!( 378 println!(
373 "{:.8} {:6} {} (by {}){}{}", 379 "{:.8} {:6} {}{} (by {}){}{}",
374 p.id, p.status, p.title, p.author.name, stale, unread 380 p.id, p.status, p.title, labels, p.author.name, stale, unread
375 ); 381 );
376 } 382 }
377 } 383 }
@@ -418,6 +424,9 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
418 } 424 }
419 } 425 }
420 println!("Created: {}", p.created_at); 426 println!("Created: {}", p.created_at);
427 if !p.labels.is_empty() {
428 println!("Labels: {}", p.labels.join(", "));
429 }
421 if let Some(ref fixes) = p.fixes { 430 if let Some(ref fixes) = p.fixes {
422 println!("Fixes: {:.8}", fixes); 431 println!("Fixes: {:.8}", fixes);
423 } 432 }
@@ -560,6 +569,16 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
560 } 569 }
561 Ok(()) 570 Ok(())
562 } 571 }
572 PatchCmd::Label { id, label } => {
573 patch::label(repo, &id, &label)?;
574 println!("Label '{}' added.", label);
575 Ok(())
576 }
577 PatchCmd::Unlabel { id, label } => {
578 patch::unlabel(repo, &id, &label)?;
579 println!("Label '{}' removed.", label);
580 Ok(())
581 }
563 PatchCmd::Close { id, reason } => { 582 PatchCmd::Close { id, reason } => {
564 patch::close(repo, &id, reason.as_deref())?; 583 patch::close(repo, &id, reason.as_deref())?;
565 println!("Patch closed."); 584 println!("Patch closed.");
src/log.rs
Old New
@@ -121,6 +121,8 @@ fn action_type_name(action: &Action) -> String {
121 Action::IssueReopen => "IssueReopen".to_string(), 121 Action::IssueReopen => "IssueReopen".to_string(),
122 Action::IssueCommitLink { .. } => "IssueCommitLink".to_string(), 122 Action::IssueCommitLink { .. } => "IssueCommitLink".to_string(),
123 Action::PatchCreate { .. } => "PatchCreate".to_string(), 123 Action::PatchCreate { .. } => "PatchCreate".to_string(),
124 Action::PatchLabel { .. } => "PatchLabel".to_string(),
125 Action::PatchUnlabel { .. } => "PatchUnlabel".to_string(),
124 Action::PatchRevision { .. } => "PatchRevision".to_string(), 126 Action::PatchRevision { .. } => "PatchRevision".to_string(),
125 Action::PatchReview { .. } => "PatchReview".to_string(), 127 Action::PatchReview { .. } => "PatchReview".to_string(),
126 Action::PatchComment { .. } => "PatchComment".to_string(), 128 Action::PatchComment { .. } => "PatchComment".to_string(),
@@ -160,6 +162,8 @@ fn action_summary(action: &Action) -> String {
160 format!("commit link {}", &commit[..commit.len().min(7)]) 162 format!("commit link {}", &commit[..commit.len().min(7)])
161 } 163 }
162 Action::PatchCreate { title, .. } => format!("create \"{}\"", title), 164 Action::PatchCreate { title, .. } => format!("create \"{}\"", title),
165 Action::PatchLabel { label } => format!("label \"{}\"", label),
166 Action::PatchUnlabel { label } => format!("unlabel \"{}\"", label),
163 Action::PatchRevision { body, .. } => match body { 167 Action::PatchRevision { body, .. } => match body {
164 Some(b) => format!("revision: {}", truncate(b, 50)), 168 Some(b) => format!("revision: {}", truncate(b, 50)),
165 None => "revision".to_string(), 169 None => "revision".to_string(),
src/patch.rs
Old New
@@ -114,16 +114,14 @@ pub fn list(
114 limit: Option<usize>, 114 limit: Option<usize>,
115 offset: Option<usize>, 115 offset: Option<usize>,
116 sort: SortMode, 116 sort: SortMode,
117 labels: &[String],
117 ) -> Result<Vec<ListEntry>, crate::error::Error> { 118 ) -> Result<Vec<ListEntry>, crate::error::Error> {
118 let patches = if show_archived { 119 let patches = if show_archived {
119 state::list_patches_with_archived(repo)? 120 state::list_patches_with_archived(repo)?
120 } else { 121 } else {
121 state::list_patches(repo)? 122 state::list_patches(repo)?
122 }; 123 };
123 // Patches have no labelling mechanism yet (no `patch label` command, no 124 let filtered = cli::filter_sort_paginate(patches, show_closed, labels, sort, offset, limit);
124 // `labels` field on PatchState), so there is no CLI flag to plumb a
125 // label filter through here. Pass an empty slice, which is a no-op.
126 let filtered = cli::filter_sort_paginate(patches, show_closed, &[], sort, offset, limit);
127 let entries = filtered 125 let entries = filtered
128 .into_iter() 126 .into_iter()
129 .map(|patch| { 127 .map(|patch| {
@@ -134,6 +132,7 @@ pub fn list(
134 Ok(entries) 132 Ok(entries)
135 } 133 }
136 134
135 #[allow(clippy::too_many_arguments)]
137 pub fn list_to_writer( 136 pub fn list_to_writer(
138 repo: &Repository, 137 repo: &Repository,
139 show_closed: bool, 138 show_closed: bool,
@@ -141,9 +140,10 @@ pub fn list_to_writer(
141 limit: Option<usize>, 140 limit: Option<usize>,
142 offset: Option<usize>, 141 offset: Option<usize>,
143 sort: SortMode, 142 sort: SortMode,
143 labels: &[String],
144 writer: &mut dyn std::io::Write, 144 writer: &mut dyn std::io::Write,
145 ) -> Result<(), crate::error::Error> { 145 ) -> Result<(), crate::error::Error> {
146 let entries = list(repo, show_closed, show_archived, limit, offset, sort)?; 146 let entries = list(repo, show_closed, show_archived, limit, offset, sort, labels)?;
147 if entries.is_empty() { 147 if entries.is_empty() {
148 writeln!(writer, "No patches found.").ok(); 148 writeln!(writer, "No patches found.").ok();
149 return Ok(()); 149 return Ok(());
@@ -174,8 +174,9 @@ pub fn list_json(
174 show_closed: bool, 174 show_closed: bool,
175 show_archived: bool, 175 show_archived: bool,
176 sort: SortMode, 176 sort: SortMode,
177 labels: &[String],
177 ) -> Result<String, crate::error::Error> { 178 ) -> Result<String, crate::error::Error> {
178 let entries = list(repo, show_closed, show_archived, None, None, sort)?; 179 let entries = list(repo, show_closed, show_archived, None, None, sort, labels)?;
179 let patches: Vec<&PatchState> = entries.iter().map(|e| &e.patch).collect(); 180 let patches: Vec<&PatchState> = entries.iter().map(|e| &e.patch).collect();
180 Ok(serde_json::to_string_pretty(&patches)?) 181 Ok(serde_json::to_string_pretty(&patches)?)
181 } 182 }
@@ -667,6 +668,30 @@ pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error
667 Ok(id) 668 Ok(id)
668 } 669 }
669 670
671 pub fn label(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
672 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
673 dag::append_action(
674 repo,
675 &ref_name,
676 Action::PatchLabel {
677 label: label.to_string(),
678 },
679 )?;
680 Ok(())
681 }
682
683 pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
684 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
685 dag::append_action(
686 repo,
687 &ref_name,
688 Action::PatchUnlabel {
689 label: label.to_string(),
690 },
691 )?;
692 Ok(())
693 }
694
670 pub fn close( 695 pub fn close(
671 repo: &Repository, 696 repo: &Repository,
672 id_prefix: &str, 697 id_prefix: &str,
src/server/http/repo/patches.rs
Old New
@@ -50,6 +50,7 @@ pub struct PatchListItem {
50 pub status: String, 50 pub status: String,
51 pub title: String, 51 pub title: String,
52 pub author: String, 52 pub author: String,
53 pub labels: String,
53 pub branch: String, 54 pub branch: String,
54 pub updated: String, 55 pub updated: String,
55 } 56 }
@@ -105,6 +106,7 @@ pub struct PatchDetailView {
105 pub body: String, 106 pub body: String,
106 pub status: String, 107 pub status: String,
107 pub author: String, 108 pub author: String,
109 pub labels: String,
108 pub branch: String, 110 pub branch: String,
109 pub base_ref: String, 111 pub base_ref: String,
110 pub revisions: Vec<RevisionView>, 112 pub revisions: Vec<RevisionView>,
@@ -174,6 +176,7 @@ pub async fn patches(
174 status: p.status.as_str().to_string(), 176 status: p.status.as_str().to_string(),
175 title: p.title, 177 title: p.title,
176 author: p.author.name, 178 author: p.author.name,
179 labels: p.labels.join(", "),
177 branch: p.branch, 180 branch: p.branch,
178 updated: p.last_updated, 181 updated: p.last_updated,
179 } 182 }
@@ -218,6 +221,7 @@ pub async fn patch_detail(
218 body: ps.body, 221 body: ps.body,
219 status: ps.status.as_str().to_string(), 222 status: ps.status.as_str().to_string(),
220 author: ps.author.name, 223 author: ps.author.name,
224 labels: ps.labels.join(", "),
221 branch: ps.branch, 225 branch: ps.branch,
222 base_ref: ps.base_ref, 226 base_ref: ps.base_ref,
223 revisions: ps 227 revisions: ps
src/server/http/templates/patch_detail.html
Old New
@@ -9,6 +9,9 @@
9 &nbsp; by <strong>{{ patch.author }}</strong> 9 &nbsp; by <strong>{{ patch.author }}</strong>
10 &nbsp; <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> 10 &nbsp; <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span>
11 </p> 11 </p>
12 {% if !patch.labels.is_empty() %}
13 <p style="color: #666;">Labels: {{ patch.labels }}</p>
14 {% endif %}
12 {% if !patch.body.is_empty() %} 15 {% if !patch.body.is_empty() %}
13 <pre style="background: #f8f8f8; padding: 12px; border-radius: 4px; white-space: pre-wrap;">{{ patch.body }}</pre> 16 <pre style="background: #f8f8f8; padding: 12px; border-radius: 4px; white-space: pre-wrap;">{{ patch.body }}</pre>
14 {% endif %} 17 {% endif %}
src/server/http/templates/patches.html
Old New
@@ -21,6 +21,7 @@
21 <th>Status</th> 21 <th>Status</th>
22 <th>Title</th> 22 <th>Title</th>
23 <th>Author</th> 23 <th>Author</th>
24 <th>Labels</th>
24 <th>Branch</th> 25 <th>Branch</th>
25 <th>Updated</th> 26 <th>Updated</th>
26 </tr> 27 </tr>
@@ -32,6 +33,7 @@
32 <td><span class="status-{{ p.status }}">{{ p.status }}</span></td> 33 <td><span class="status-{{ p.status }}">{{ p.status }}</span></td>
33 <td><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.title }}</a></td> 34 <td><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.title }}</a></td>
34 <td style="color: #666;">{{ p.author }}</td> 35 <td style="color: #666;">{{ p.author }}</td>
36 <td style="color: #666;">{{ p.labels }}</td>
35 <td class="mono" style="color: #666;">{{ p.branch }}</td> 37 <td class="mono" style="color: #666;">{{ p.branch }}</td>
36 <td class="mono" style="color: #666;">{{ p.updated }}</td> 38 <td class="mono" style="color: #666;">{{ p.updated }}</td>
37 </tr> 39 </tr>
src/state.rs
Old New
@@ -237,6 +237,10 @@ pub struct PatchState {
237 /// Nothing resolves through it any more: a patch is addressed by its own 237 /// Nothing resolves through it any more: a patch is addressed by its own
238 /// revision refs, so an ephemeral or rewritten branch costs it nothing. 238 /// revision refs, so an ephemeral or rewritten branch costs it nothing.
239 pub branch: String, 239 pub branch: String,
240 /// Postdates patch labelling: absent on any `PatchState` serialized
241 /// before this field existed, which must load as an empty vec.
242 #[serde(default)]
243 pub labels: Vec<String>,
240 pub comments: Vec<Comment>, 244 pub comments: Vec<Comment>,
241 pub inline_comments: Vec<InlineComment>, 245 pub inline_comments: Vec<InlineComment>,
242 pub reviews: Vec<Review>, 246 pub reviews: Vec<Review>,
@@ -278,6 +282,9 @@ impl crate::cli::Listable for PatchState {
278 fn title(&self) -> &str { 282 fn title(&self) -> &str {
279 &self.title 283 &self.title
280 } 284 }
285 fn labels(&self) -> &[String] {
286 &self.labels
287 }
281 } 288 }
282 289
283 impl IssueState { 290 impl IssueState {
@@ -672,6 +679,7 @@ impl PatchState {
672 base_ref, 679 base_ref,
673 fixes, 680 fixes,
674 branch, 681 branch,
682 labels: Vec::new(),
675 comments: Vec::new(), 683 comments: Vec::new(),
676 inline_comments: Vec::new(), 684 inline_comments: Vec::new(),
677 reviews: Vec::new(), 685 reviews: Vec::new(),
@@ -681,6 +689,18 @@ impl PatchState {
681 author: event.author.clone(), 689 author: event.author.clone(),
682 }); 690 });
683 } 691 }
692 Action::PatchLabel { label } => {
693 if let Some(ref mut s) = state {
694 if !s.labels.contains(&label) {
695 s.labels.push(label);
696 }
697 }
698 }
699 Action::PatchUnlabel { label } => {
700 if let Some(ref mut s) = state {
701 s.labels.retain(|l| l != &label);
702 }
703 }
684 Action::PatchRevision { 704 Action::PatchRevision {
685 commit, 705 commit,
686 tree, 706 tree,
@@ -1471,4 +1491,29 @@ mod tests {
1471 let issue: IssueState = serde_json::from_str(json).unwrap(); 1491 let issue: IssueState = serde_json::from_str(json).unwrap();
1472 assert_eq!(issue.relates_to, Vec::<String>::new()); 1492 assert_eq!(issue.relates_to, Vec::<String>::new());
1473 } 1493 }
1494
1495 // `labels` on `PatchState` postdates patch labelling: cached state (and
1496 // any other on-disk JSON) written before this feature existed has no
1497 // `labels` field at all. It must still deserialize, as an empty vec.
1498 #[test]
1499 fn deserializes_missing_labels_field_as_empty_vec_on_patch_state() {
1500 let json = r#"{
1501 "id": "abc123",
1502 "title": "t",
1503 "body": "",
1504 "status": "open",
1505 "base_ref": "main",
1506 "fixes": null,
1507 "branch": "feature/x",
1508 "comments": [],
1509 "inline_comments": [],
1510 "reviews": [],
1511 "revisions": [],
1512 "created_at": "2026-01-01T00:00:00Z",
1513 "last_updated": "",
1514 "author": {"name": "A", "email": "a@example.com"}
1515 }"#;
1516 let patch: PatchState = serde_json::from_str(json).unwrap();
1517 assert_eq!(patch.labels, Vec::<String>::new());
1518 }
1474 } 1519 }
src/tui/mod.rs
Old New
@@ -80,6 +80,7 @@ mod tests {
80 base_ref: "main".into(), 80 base_ref: "main".into(),
81 fixes: None, 81 fixes: None,
82 branch: format!("feature/{}", id), 82 branch: format!("feature/{}", id),
83 labels: vec![],
83 comments: vec![], 84 comments: vec![],
84 inline_comments: vec![], 85 inline_comments: vec![],
85 reviews: vec![], 86 reviews: vec![],
@@ -276,6 +277,7 @@ mod tests {
276 base_ref: "main".to_string(), 277 base_ref: "main".to_string(),
277 fixes: None, 278 fixes: None,
278 branch: format!("feature/p{:07x}", i), 279 branch: format!("feature/p{:07x}", i),
280 labels: vec![],
279 comments: Vec::new(), 281 comments: Vec::new(),
280 inline_comments: Vec::new(), 282 inline_comments: Vec::new(),
281 reviews: Vec::new(), 283 reviews: Vec::new(),
@@ -1032,6 +1034,7 @@ mod tests {
1032 base_ref: "main".into(), 1034 base_ref: "main".into(),
1033 fixes: Some("i1".into()), 1035 fixes: Some("i1".into()),
1034 branch: "feature/fix-thing".into(), 1036 branch: "feature/fix-thing".into(),
1037 labels: vec!["needs-review".into()],
1035 comments: vec![crate::state::Comment { 1038 comments: vec![crate::state::Comment {
1036 author: make_author(), 1039 author: make_author(),
1037 body: "Thread comment".into(), 1040 body: "Thread comment".into(),
@@ -1276,6 +1279,17 @@ mod tests {
1276 } 1279 }
1277 1280
1278 #[test] 1281 #[test]
1282 fn test_render_patch_detail_shows_labels() {
1283 let mut app = make_app(3, 0);
1284 app.mode = ViewMode::PatchDetail;
1285 app.current_patch = Some(make_patch_with_revisions());
1286 app.patch_diff = String::new();
1287
1288 let buf = render_app(&mut app);
1289 assert_buffer_contains(&buf, "needs-review");
1290 }
1291
1292 #[test]
1279 fn test_render_patch_detail_shows_reviews() { 1293 fn test_render_patch_detail_shows_reviews() {
1280 let mut app = make_app(3, 0); 1294 let mut app = make_app(3, 0);
1281 app.mode = ViewMode::PatchDetail; 1295 app.mode = ViewMode::PatchDetail;
src/tui/widgets.rs
Old New
@@ -14,6 +14,8 @@ pub(crate) fn action_type_label(action: &Action) -> &str {
14 Action::IssueClose { .. } => "Issue Close", 14 Action::IssueClose { .. } => "Issue Close",
15 Action::IssueReopen => "Issue Reopen", 15 Action::IssueReopen => "Issue Reopen",
16 Action::PatchCreate { .. } => "Patch Create", 16 Action::PatchCreate { .. } => "Patch Create",
17 Action::PatchLabel { .. } => "Patch Label",
18 Action::PatchUnlabel { .. } => "Patch Unlabel",
17 Action::PatchRevision { .. } => "Patch Revision", 19 Action::PatchRevision { .. } => "Patch Revision",
18 Action::PatchReview { .. } => "Patch Review", 20 Action::PatchReview { .. } => "Patch Review",
19 Action::PatchComment { .. } => "Patch Comment", 21 Action::PatchComment { .. } => "Patch Comment",
@@ -106,10 +108,10 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str
106 } 108 }
107 } 109 }
108 } 110 }
109 Action::IssueLabel { label } => { 111 Action::IssueLabel { label } | Action::PatchLabel { label } => {
110 detail.push_str(&format!("\nLabel: {}\n", label)); 112 detail.push_str(&format!("\nLabel: {}\n", label));
111 } 113 }
112 Action::IssueUnlabel { label } => { 114 Action::IssueUnlabel { label } | Action::PatchUnlabel { label } => {
113 detail.push_str(&format!("\nRemoved Label: {}\n", label)); 115 detail.push_str(&format!("\nRemoved Label: {}\n", label));
114 } 116 }
115 Action::IssueRelate { relates_to } => { 117 Action::IssueRelate { relates_to } => {
@@ -627,6 +629,13 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
627 ]), 629 ]),
628 ]; 630 ];
629 631
632 if !patch.labels.is_empty() {
633 lines.push(Line::from(vec![
634 Span::styled("Labels: ", Style::default().fg(Color::DarkGray)),
635 Span::raw(patch.labels.join(", ")),
636 ]));
637 }
638
630 if let Some(ref fixes) = patch.fixes { 639 if let Some(ref fixes) = patch.fixes {
631 lines.push(Line::from(vec![ 640 lines.push(Line::from(vec![
632 Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), 641 Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)),
tests/cli_test.rs
Old New
@@ -591,29 +591,173 @@ fn test_patch_list_filters_by_status() {
591 assert!(out.contains("Closed patch")); 591 assert!(out.contains("Closed patch"));
592 } 592 }
593 593
594 // ===========================================================================
595 // Patch labels
596 // ===========================================================================
597
594 #[test] 598 #[test]
595 fn test_patch_list_rejects_label_flag() { 599 fn test_patch_label_and_show() {
596 // Patches have no way to carry labels (there is no `patch label`
597 // command, unlike `issue label`, and no `labels` field on PatchState).
598 // A `--label` flag that always returns an empty list would be worse
599 // than no flag at all -- it looks like "no patches have this label"
600 // when really the concept doesn't exist. So `patch list` must reject
601 // `--label` outright as an unknown argument rather than silently
602 // accepting and no-op-filtering it.
603 let repo = TestRepo::new("Alice", "alice@example.com"); 600 let repo = TestRepo::new("Alice", "alice@example.com");
604 repo.patch_create("Open patch"); 601 let id = repo.patch_create("Labeled patch");
605 602
606 let output = repo.run(&["patch", "list", "--label", "bug"]); 603 let out = repo.run_ok(&["patch", "label", &id, "bug"]);
607 assert!( 604 assert!(out.contains("Label") && out.contains("added"));
608 !output.status.success(), 605
609 "patch list --label should be rejected" 606 let out = repo.run_ok(&["patch", "show", &id]);
610 ); 607 assert!(out.contains("bug"));
611 let stderr = String::from_utf8(output.stderr).unwrap(); 608 }
612 assert!( 609
613 stderr.contains("unexpected argument") || stderr.contains("unrecognized"), 610 #[test]
614 "expected an unknown-argument error, got: {}", 611 fn test_patch_multiple_labels() {
615 stderr 612 let repo = TestRepo::new("Alice", "alice@example.com");
616 ); 613 let id = repo.patch_create("Multi-label patch");
614
615 repo.run_ok(&["patch", "label", &id, "bug"]);
616 repo.run_ok(&["patch", "label", &id, "priority"]);
617
618 let out = repo.run_ok(&["patch", "show", &id]);
619 assert!(out.contains("bug"));
620 assert!(out.contains("priority"));
621 }
622
623 #[test]
624 fn test_patch_unlabel() {
625 let repo = TestRepo::new("Alice", "alice@example.com");
626 let id = repo.patch_create("Remove label");
627
628 repo.run_ok(&["patch", "label", &id, "bug"]);
629 repo.run_ok(&["patch", "label", &id, "wontfix"]);
630 repo.run_ok(&["patch", "unlabel", &id, "bug"]);
631
632 let out = repo.run_ok(&["patch", "show", &id]);
633 assert!(!out.contains("bug"));
634 assert!(out.contains("wontfix"));
635 }
636
637 #[test]
638 fn test_patch_label_is_idempotent() {
639 // Adding the same label twice does not duplicate it, matching
640 // `issue label`'s behavior.
641 let repo = TestRepo::new("Alice", "alice@example.com");
642 let id = repo.patch_create("Idempotent label");
643
644 repo.run_ok(&["patch", "label", &id, "bug"]);
645 repo.run_ok(&["patch", "label", &id, "bug"]);
646
647 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
648 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
649 let labels = parsed["labels"].as_array().unwrap();
650 assert_eq!(labels.len(), 1);
651 }
652
653 #[test]
654 fn test_patch_unlabel_absent_is_noop() {
655 let repo = TestRepo::new("Alice", "alice@example.com");
656 let id = repo.patch_create("Unlabel noop");
657
658 repo.run_ok(&["patch", "label", &id, "bug"]);
659 repo.run_ok(&["patch", "unlabel", &id, "nonexistent"]);
660
661 let out = repo.run_ok(&["patch", "show", &id]);
662 assert!(out.contains("bug"));
663 }
664
665 #[test]
666 fn test_patch_label_shown_in_list() {
667 let repo = TestRepo::new("Alice", "alice@example.com");
668 let id = repo.patch_create("Listed with label");
669
670 repo.run_ok(&["patch", "label", &id, "enhancement"]);
671
672 let out = repo.run_ok(&["patch", "list"]);
673 assert!(out.contains("enhancement"));
674 }
675
676 #[test]
677 fn test_patch_list_filters_by_label() {
678 let repo = TestRepo::new("Alice", "alice@example.com");
679 let bug_id = repo.patch_create("A bug patch");
680 repo.patch_create("No label patch");
681 repo.run_ok(&["patch", "label", &bug_id, "bug"]);
682
683 let out = repo.run_ok(&["patch", "list", "--label", "bug"]);
684 assert!(out.contains("A bug patch"));
685 assert!(!out.contains("No label patch"));
686 }
687
688 #[test]
689 fn test_patch_list_label_or_semantics() {
690 let repo = TestRepo::new("Alice", "alice@example.com");
691 let bug_id = repo.patch_create("A bug patch");
692 let docs_id = repo.patch_create("Docs patch");
693 repo.patch_create("Unrelated patch");
694 repo.run_ok(&["patch", "label", &bug_id, "bug"]);
695 repo.run_ok(&["patch", "label", &docs_id, "docs"]);
696
697 let out = repo.run_ok(&["patch", "list", "--label", "bug", "--label", "docs"]);
698 assert!(out.contains("A bug patch"));
699 assert!(out.contains("Docs patch"));
700 assert!(!out.contains("Unrelated patch"));
701 }
702
703 #[test]
704 fn test_patch_list_label_no_match() {
705 let repo = TestRepo::new("Alice", "alice@example.com");
706 repo.patch_create("Unlabeled patch");
707
708 let out = repo.run_ok(&["patch", "list", "--label", "nonexistent"]);
709 assert!(out.contains("No patches found"));
710 }
711
712 #[test]
713 fn test_patch_list_label_composes_with_all_archived() {
714 let repo = TestRepo::new("Alice", "alice@example.com");
715 let id = repo.patch_create("Closed labeled");
716 repo.run_ok(&["patch", "label", &id, "bug"]);
717 repo.run_ok(&["patch", "close", &id]);
718
719 // Closed patches are archived, so --label alone (without --all --archived)
720 // must not show it, even though it carries a matching label.
721 let out = repo.run_ok(&["patch", "list", "--label", "bug"]);
722 assert!(!out.contains("Closed labeled"));
723
724 let out = repo.run_ok(&["patch", "list", "--all", "--archived", "--label", "bug"]);
725 assert!(out.contains("Closed labeled"));
726 }
727
728 #[test]
729 fn test_patch_list_label_limit_offset_applies_after_filter() {
730 let repo = TestRepo::new("Alice", "alice@example.com");
731 let a = repo.patch_create("Alpha patch");
732 let _b = repo.patch_create("Beta unlabeled patch");
733 let c = repo.patch_create("Gamma patch");
734 repo.run_ok(&["patch", "label", &a, "keep"]);
735 repo.run_ok(&["patch", "label", &c, "keep"]);
736
737 // Only "Alpha" and "Gamma" carry the "keep" label. Sorting alphabetically
738 // and requesting a single result with offset 1 should skip "Alpha" and
739 // land on "Gamma" -- proving limit/offset apply to the filtered set, not
740 // the unfiltered one (which would offset past "Beta unlabeled" instead).
741 let out = repo.run_ok(&[
742 "patch", "list", "--label", "keep", "--sort", "alpha", "--offset", "1", "-n", "1",
743 ]);
744 assert!(out.contains("Gamma"));
745 assert!(!out.contains("Alpha"));
746 assert!(!out.contains("Beta"));
747 }
748
749 #[test]
750 fn test_patch_list_label_json() {
751 let repo = TestRepo::new("Alice", "alice@example.com");
752 let bug_id = repo.patch_create("A bug patch");
753 repo.patch_create("No label patch");
754 repo.run_ok(&["patch", "label", &bug_id, "bug"]);
755
756 let out = repo.run_ok(&["patch", "list", "--json", "--label", "bug"]);
757 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
758 let arr = parsed.as_array().unwrap();
759 assert_eq!(arr.len(), 1);
760 assert_eq!(arr[0]["title"], "A bug patch");
617 } 761 }
618 762
619 #[test] 763 #[test]
tests/collab_test.rs
Old New
@@ -1305,6 +1305,7 @@ fn capture_patch_list(
1305 limit, 1305 limit,
1306 offset, 1306 offset,
1307 git_collab::cli::SortMode::Recent, 1307 git_collab::cli::SortMode::Recent,
1308 &[],
1308 &mut buf, 1309 &mut buf,
1309 ) 1310 )
1310 .unwrap(); 1311 .unwrap();
@@ -1578,9 +1579,14 @@ fn test_patch_list_json_output() {
1578 create_patch(&repo, &alice(), "Patch one"); 1579 create_patch(&repo, &alice(), "Patch one");
1579 create_patch(&repo, &bob(), "Patch two"); 1580 create_patch(&repo, &bob(), "Patch two");
1580 1581
1581 let json_str = 1582 let json_str = git_collab::patch::list_json(
1582 git_collab::patch::list_json(&repo, false, false, git_collab::cli::SortMode::Recent) 1583 &repo,
1583 .unwrap(); 1584 false,
1585 false,
1586 git_collab::cli::SortMode::Recent,
1587 &[],
1588 )
1589 .unwrap();
1584 let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); 1590 let value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1585 let arr = value.as_array().unwrap(); 1591 let arr = value.as_array().unwrap();
1586 assert_eq!(arr.len(), 2); 1592 assert_eq!(arr.len(), 2);
tests/server_behavior_test.rs
Old New
@@ -80,6 +80,25 @@ fn pushed_repository_content_renders_across_repo_http_pages() {
80 } 80 }
81 81
82 #[test] 82 #[test]
83 fn patch_label_renders_in_server_ui() {
84 let harness = ServerHarness::new("behavior-patch-labels");
85
86 harness.push_head();
87
88 let patch_id = harness.work_repo().patch_create("Labeled patch");
89 harness
90 .work_repo()
91 .run_ok(&["patch", "label", &patch_id, "needs-review"]);
92 harness.push_collab_refs();
93
94 let list_page = harness.get_ok(&format!("/{}/patches", harness.repo_name()));
95 assert!(list_page.body.contains("needs-review"));
96
97 let detail_page = harness.get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id));
98 assert!(detail_page.body.contains("needs-review"));
99 }
100
101 #[test]
83 fn private_repo_policy_hides_repo_from_ui_and_uses_configured_description() { 102 fn private_repo_policy_hides_repo_from_ui_and_uses_configured_description() {
84 let harness = ServerHarness::new("behavior-private"); 103 let harness = ServerHarness::new("behavior-private");
85 104
tests/sort_test.rs
Old New
@@ -231,7 +231,7 @@ fn test_patch_default_sort_by_recency() {
231 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); 231 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z");
232 232
233 let entries = 233 let entries =
234 git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent).unwrap(); 234 git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent, &[]).unwrap();
235 assert_eq!(entries.len(), 2); 235 assert_eq!(entries.len(), 2);
236 assert_eq!(entries[0].patch.title, "Alpha patch"); 236 assert_eq!(entries[0].patch.title, "Alpha patch");
237 assert_eq!(entries[1].patch.title, "Beta patch"); 237 assert_eq!(entries[1].patch.title, "Beta patch");
@@ -254,7 +254,7 @@ fn test_patch_sort_by_created() {
254 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); 254 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z");
255 255
256 let entries = 256 let entries =
257 git_collab::patch::list(&repo, true, false, None, None, SortMode::Created).unwrap(); 257 git_collab::patch::list(&repo, true, false, None, None, SortMode::Created, &[]).unwrap();
258 assert_eq!(entries.len(), 2); 258 assert_eq!(entries.len(), 2);
259 assert_eq!(entries[0].patch.title, "Beta patch"); 259 assert_eq!(entries[0].patch.title, "Beta patch");
260 assert_eq!(entries[1].patch.title, "Alpha patch"); 260 assert_eq!(entries[1].patch.title, "Alpha patch");
@@ -269,7 +269,7 @@ fn test_patch_sort_alpha() {
269 create_patch_at(&repo, &alice(), "Apple patch", "2025-06-01T00:00:00Z"); 269 create_patch_at(&repo, &alice(), "Apple patch", "2025-06-01T00:00:00Z");
270 create_patch_at(&repo, &alice(), "Mango patch", "2025-03-01T00:00:00Z"); 270 create_patch_at(&repo, &alice(), "Mango patch", "2025-03-01T00:00:00Z");
271 271
272 let entries = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha).unwrap(); 272 let entries = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha, &[]).unwrap();
273 assert_eq!(entries.len(), 3); 273 assert_eq!(entries.len(), 3);
274 assert_eq!(entries[0].patch.title, "Apple patch"); 274 assert_eq!(entries[0].patch.title, "Apple patch");
275 assert_eq!(entries[1].patch.title, "Mango patch"); 275 assert_eq!(entries[1].patch.title, "Mango patch");