1use anyhow::Result;
6use serde_json::{Value, json};
7
8use super::STACK_SECTION;
9use super::sections::{body_with_section, extract_section};
10use crate::providers::{ReviewProvider, ReviewRequest, ReviewState};
11
12const DATA_PREFIX: &str = "<!-- git-stk:data ";
13const COMMENT_END: &str = "-->";
14const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
15const LOGO_URL: &str =
16 "https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
23struct NoteEntry {
24 id: String,
25 url: String,
26 title: String,
27 state: String,
28}
29
30impl NoteEntry {
31 fn from_review(review: &ReviewRequest) -> Self {
32 Self {
33 id: review.id.clone(),
34 url: review.url.clone(),
35 title: review.title.clone(),
36 state: review.state.to_string(),
37 }
38 }
39
40 fn to_review(&self) -> ReviewRequest {
43 let state = match self.state.as_str() {
44 "open" => ReviewState::Open,
45 "merged" => ReviewState::Merged,
46 "closed" => ReviewState::Closed,
47 other => ReviewState::Unknown(other.to_owned()),
48 };
49 ReviewRequest {
50 id: self.id.clone(),
51 branch: String::new(),
52 base: String::new(),
53 state,
54 url: self.url.clone(),
55 title: self.title.clone(),
56 draft: false,
57 }
58 }
59
60 fn matches(&self, other: &Self) -> bool {
63 (!self.id.is_empty() && self.id == other.id)
64 || (!self.url.is_empty() && self.url == other.url)
65 }
66}
67
68pub fn update_stack_notes(
75 review_provider: &dyn ReviewProvider,
76 branch_parents: &[(String, String)],
77 dry_run: bool,
78 rebuild: bool,
79) -> Result<()> {
80 let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
82 return Ok(());
83 };
84
85 let mut live = Vec::new();
86 for (branch, _) in branch_parents {
87 match review_provider.review_for_branch_including_closed(branch)? {
91 Some(review) if review.branch == *branch => live.push(review),
92 _ => {
93 if !dry_run {
96 println!("skipped stack notes: no review found for {branch}");
97 }
98 return Ok(());
99 }
100 }
101 }
102
103 if dry_run && !rebuild {
106 for review in &live {
107 println!("would update stack note in {}", review.id);
108 }
109 return Ok(());
110 }
111
112 let mut bodies = Vec::new();
116 for review in &live {
117 bodies.push(review_provider.review_body(review)?);
118 }
119
120 let mut superseded: Vec<NoteEntry> = Vec::new();
123 for (branch, _) in branch_parents {
124 if let Some(old) = crate::stack::renamed_from(branch)?
125 && let Some(review) = review_provider.review_for_branch_including_closed(&old)?
126 {
127 superseded.push(NoteEntry::from_review(&review));
128 }
129 }
130
131 let live_entries: Vec<NoteEntry> = live.iter().map(NoteEntry::from_review).collect();
132 let mut historical: Vec<NoteEntry> = Vec::new();
133 let mut dropped: Vec<NoteEntry> = Vec::new();
134 for body in &bodies {
135 let Some(section) = extract_section(body, STACK_SECTION) else {
136 continue;
137 };
138 for entry in parse_ledger(section) {
139 if superseded.iter().any(|stale| stale.matches(&entry)) {
140 continue;
141 }
142 let known = live_entries
143 .iter()
144 .chain(historical.iter())
145 .chain(dropped.iter());
146 if known.into_iter().any(|seen| seen.matches(&entry)) {
147 continue;
148 }
149 if rebuild && entry.state != "merged" {
152 dropped.push(entry);
153 } else {
154 historical.push(entry);
155 }
156 }
157 }
158
159 if dry_run {
160 for review in &live {
161 println!("would update stack note in {}", review.id);
162 }
163 for entry in &dropped {
164 println!(
165 "would drop drifted entry {} ({})",
166 if entry.id.is_empty() { "?" } else { &entry.id },
167 entry.state
168 );
169 }
170 return Ok(());
171 }
172
173 let mut entries = historical.clone();
176 entries.extend(live_entries);
177
178 for (offset, review) in live.iter().enumerate() {
179 let note = build_stack_note(&entries, historical.len() + offset, &trunk);
180 let updated = body_with_section(&bodies[offset], STACK_SECTION, ¬e);
181 if updated == bodies[offset] {
182 continue;
183 }
184
185 review_provider.update_review_body(review, &updated)?;
186 println!("updated stack note in {}", review.id);
187 }
188
189 for (index, entry) in historical.iter().enumerate() {
193 if entry.id.is_empty() {
194 continue;
195 }
196 let review = entry.to_review();
197 let Ok(body) = review_provider.review_body(&review) else {
198 println!("skipped stack note in {}: could not read body", review.id);
199 continue;
200 };
201
202 let note = build_stack_note(&entries, index, &trunk);
203 let updated = body_with_section(&body, STACK_SECTION, ¬e);
204 if updated == body {
205 continue;
206 }
207
208 if review_provider
209 .update_review_body(&review, &updated)
210 .is_err()
211 {
212 println!("skipped stack note in {}: could not update body", review.id);
213 continue;
214 }
215 println!("updated stack note in {}", review.id);
216 }
217
218 Ok(())
219}
220
221fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
226 let mut lines = vec![data_line(entries)];
227 for (index, entry) in entries.iter().enumerate().rev() {
228 lines.push(render_entry(entry, index == current));
229 }
230 lines.push(format!("- `{trunk}`"));
231
232 format!(
233 "{}\n\n---\n\nStack managed by \
234 <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
235 [git-stk]({TOOL_URL})",
236 lines.join("\n")
237 )
238}
239
240fn render_entry(entry: &NoteEntry, current: bool) -> String {
243 let label = crate::providers::label(&entry.title, &entry.id);
244 let link = format!("[{label}]({})", entry.url);
245
246 let mut line = match entry.state.as_str() {
247 "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
248 "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
249 _ => format!("- \u{1F7E2} {link}"),
250 };
251 if current {
252 line.push_str(" \u{1F448}");
253 }
254 line
255}
256
257fn data_line(entries: &[NoteEntry]) -> String {
260 let data = Value::Array(
261 entries
262 .iter()
263 .map(|entry| {
264 json!({
265 "id": entry.id,
266 "url": entry.url,
267 "title": entry.title,
268 "state": entry.state,
269 })
270 })
271 .collect(),
272 );
273
274 let encoded = data.to_string().replace('>', "\\u003e");
277 format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
278}
279
280fn parse_ledger(section: &str) -> Vec<NoteEntry> {
284 for line in section.lines() {
285 if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
286 && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
287 && let Some(entries) = parse_data_json(encoded.trim())
288 {
289 return entries;
290 }
291 }
292
293 section.lines().filter_map(parse_entry_line).collect()
294}
295
296fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
297 let value: Value = serde_json::from_str(encoded).ok()?;
298 let mut entries = Vec::new();
299 for item in value.as_array()? {
300 entries.push(NoteEntry {
301 id: item.get("id")?.as_str()?.to_owned(),
302 url: item.get("url")?.as_str()?.to_owned(),
303 title: item
304 .get("title")
305 .and_then(Value::as_str)
306 .unwrap_or_default()
307 .to_owned(),
308 state: item
309 .get("state")
310 .and_then(Value::as_str)
311 .unwrap_or("open")
312 .to_owned(),
313 });
314 }
315 Some(entries)
316}
317
318fn parse_entry_line(line: &str) -> Option<NoteEntry> {
322 let rest = line.trim().strip_prefix("- ")?;
323 if rest.starts_with('`') {
324 return None;
325 }
326
327 let open = rest.find('[')?;
328 let split = rest[open..].find("](")? + open;
329 let close = rest[split + 2..].find(')')? + split + 2;
330 let label = &rest[open + 1..split];
331 let url = &rest[split + 2..close];
332 let tail = &rest[close + 1..];
333
334 let state = if tail.contains("(merged)") {
335 "merged"
336 } else if tail.contains("(closed)") {
337 "closed"
338 } else {
339 "open"
340 };
341
342 let (title, id) = match rest[open + 1..split].rfind(" (") {
344 Some(position) if label.ends_with(')') => {
345 let id = &label[position + 2..label.len() - 1];
346 if id.starts_with('#') || id.starts_with('!') {
347 (label[..position].to_owned(), id.to_owned())
348 } else {
349 (label.to_owned(), String::new())
350 }
351 }
352 _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
353 _ => (label.to_owned(), String::new()),
354 };
355
356 Some(NoteEntry {
357 id,
358 url: url.to_owned(),
359 title,
360 state: state.to_owned(),
361 })
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
369 NoteEntry {
370 id: id.to_owned(),
371 url: url.to_owned(),
372 title: title.to_owned(),
373 state: state.to_owned(),
374 }
375 }
376
377 #[test]
378 fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
379 let entries = vec![
380 entry("#12", "Bottom change", "https://example.com/12", "open"),
381 entry("#13", "Top change", "https://example.com/13", "open"),
382 ];
383
384 let note = build_stack_note(&entries, 0, "main");
385 let lines: Vec<&str> = note.lines().collect();
386 assert!(
387 lines[0].starts_with(DATA_PREFIX),
388 "missing data line: {note}"
389 );
390 assert_eq!(
391 lines[1],
392 "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
393 );
394 assert_eq!(
395 lines[2],
396 "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
397 );
398 assert_eq!(lines[3], "- `main`");
399 assert!(note.ends_with(
400 "Stack managed by \
401 <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
402 width=\"12\" height=\"12\" alt=\"\" /> \
403 [git-stk](https://github.com/lararosekelley/git-stk)"
404 ));
405 }
406
407 #[test]
408 fn build_stack_note_styles_merged_and_closed_entries() {
409 let entries = vec![
410 entry("#11", "Landed", "https://example.com/11", "merged"),
411 entry("#12", "Abandoned", "https://example.com/12", "closed"),
412 entry("#13", "Live", "https://example.com/13", "open"),
413 ];
414
415 let note = build_stack_note(&entries, 2, "main");
416 assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
417 assert!(
418 note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
419 );
420 assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
421 }
422
423 #[test]
424 fn build_stack_note_falls_back_to_id_without_title() {
425 let entries = vec![entry("#12", "", "https://example.com/12", "open")];
426 let note = build_stack_note(&entries, 0, "main");
427 assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
428 }
429
430 #[test]
431 fn parse_ledger_round_trips_the_data_line() {
432 let entries = vec![
433 entry("#11", "Landed", "https://example.com/11", "merged"),
434 entry("#13", "Top -> change", "https://example.com/13", "open"),
435 ];
436
437 let note = build_stack_note(&entries, 1, "main");
438 assert_eq!(parse_ledger(¬e), entries);
439 }
440
441 #[test]
442 fn data_line_survives_a_title_containing_a_comment_terminator() {
443 let entries = vec![entry(
444 "#12",
445 "weird --> title",
446 "https://example.com/12",
447 "open",
448 )];
449 let line = data_line(&entries);
450 assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
451 assert_eq!(parse_ledger(&line), entries);
452 }
453
454 #[test]
455 fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
456 let entries = vec![
457 entry("#11", "Landed", "https://example.com/11", "merged"),
458 entry("#12", "", "https://example.com/12", "closed"),
459 entry("#13", "Live", "https://example.com/13", "open"),
460 ];
461
462 let note = build_stack_note(&entries, 2, "main");
463 let without_data: String = note
464 .lines()
465 .filter(|line| !line.trim().starts_with(DATA_PREFIX))
466 .collect::<Vec<_>>()
467 .join("\n");
468
469 let mut recovered = parse_ledger(&without_data);
472 recovered.reverse();
473 assert_eq!(recovered, entries);
474 }
475
476 #[test]
477 fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
478 let section = "<!-- git-stk:data [{\"id\": -->\n\
479 - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
480 - `main`";
481 assert_eq!(
482 parse_ledger(section),
483 vec![entry("#11", "Landed", "https://example.com/11", "merged")]
484 );
485 }
486
487 #[test]
488 fn parse_ledger_reads_the_legacy_unstyled_format() {
489 let section = "- [Top change (#13)](https://example.com/13)\n\
490 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
491 - `main`\n\n---\n\nfooter";
492 assert_eq!(
493 parse_ledger(section),
494 vec![
495 entry("#13", "Top change", "https://example.com/13", "open"),
496 entry("#12", "Bottom change", "https://example.com/12", "open"),
497 ]
498 );
499 }
500
501 #[test]
502 fn note_entry_round_trips_through_review() {
503 let landed = entry("#11", "Landed", "https://example.com/11", "merged");
504 let review = landed.to_review();
505 assert_eq!(review.state, ReviewState::Merged);
506 assert_eq!(NoteEntry::from_review(&review), landed);
507 }
508
509 #[test]
510 fn note_entry_matches_by_id_or_url() {
511 let by_id = entry("#11", "", "", "open");
512 let by_url = entry("", "", "https://example.com/11", "open");
513 assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
514 assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
515 assert!(!by_url.matches(&by_id));
516 }
517}