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) -> Result<()> {
79 let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
81 return Ok(());
82 };
83
84 let mut live = Vec::new();
85 for (branch, _) in branch_parents {
86 match review_provider.review_for_branch_including_closed(branch)? {
90 Some(review) if review.branch == *branch => live.push(review),
91 _ => {
92 if !dry_run {
95 println!("skipped stack notes: no review found for {branch}");
96 }
97 return Ok(());
98 }
99 }
100 }
101
102 if dry_run {
103 for review in &live {
104 println!("would update stack note in {}", review.id);
105 }
106 return Ok(());
107 }
108
109 let mut bodies = Vec::new();
113 for review in &live {
114 bodies.push(review_provider.review_body(review)?);
115 }
116
117 let live_entries: Vec<NoteEntry> = live.iter().map(NoteEntry::from_review).collect();
118 let mut historical: Vec<NoteEntry> = Vec::new();
119 for body in &bodies {
120 let Some(section) = extract_section(body, STACK_SECTION) else {
121 continue;
122 };
123 for entry in parse_ledger(section) {
124 let known = live_entries.iter().chain(historical.iter());
125 if !known
126 .into_iter()
127 .any(|entry_known| entry_known.matches(&entry))
128 {
129 historical.push(entry);
130 }
131 }
132 }
133
134 let mut entries = historical.clone();
137 entries.extend(live_entries);
138
139 for (offset, review) in live.iter().enumerate() {
140 let note = build_stack_note(&entries, historical.len() + offset, &trunk);
141 let updated = body_with_section(&bodies[offset], STACK_SECTION, ¬e);
142 if updated == bodies[offset] {
143 continue;
144 }
145
146 review_provider.update_review_body(review, &updated)?;
147 println!("updated stack note in {}", review.id);
148 }
149
150 for (index, entry) in historical.iter().enumerate() {
154 if entry.id.is_empty() {
155 continue;
156 }
157 let review = entry.to_review();
158 let Ok(body) = review_provider.review_body(&review) else {
159 println!("skipped stack note in {}: could not read body", review.id);
160 continue;
161 };
162
163 let note = build_stack_note(&entries, index, &trunk);
164 let updated = body_with_section(&body, STACK_SECTION, ¬e);
165 if updated == body {
166 continue;
167 }
168
169 if review_provider
170 .update_review_body(&review, &updated)
171 .is_err()
172 {
173 println!("skipped stack note in {}: could not update body", review.id);
174 continue;
175 }
176 println!("updated stack note in {}", review.id);
177 }
178
179 Ok(())
180}
181
182fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
187 let mut lines = vec![data_line(entries)];
188 for (index, entry) in entries.iter().enumerate().rev() {
189 lines.push(render_entry(entry, index == current));
190 }
191 lines.push(format!("- `{trunk}`"));
192
193 format!(
194 "{}\n\n---\n\nStack managed by \
195 <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
196 [git-stk]({TOOL_URL})",
197 lines.join("\n")
198 )
199}
200
201fn render_entry(entry: &NoteEntry, current: bool) -> String {
204 let label = crate::providers::label(&entry.title, &entry.id);
205 let link = format!("[{label}]({})", entry.url);
206
207 let mut line = match entry.state.as_str() {
208 "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
209 "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
210 _ => format!("- \u{1F7E2} {link}"),
211 };
212 if current {
213 line.push_str(" \u{1F448}");
214 }
215 line
216}
217
218fn data_line(entries: &[NoteEntry]) -> String {
221 let data = Value::Array(
222 entries
223 .iter()
224 .map(|entry| {
225 json!({
226 "id": entry.id,
227 "url": entry.url,
228 "title": entry.title,
229 "state": entry.state,
230 })
231 })
232 .collect(),
233 );
234
235 let encoded = data.to_string().replace('>', "\\u003e");
238 format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
239}
240
241fn parse_ledger(section: &str) -> Vec<NoteEntry> {
245 for line in section.lines() {
246 if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
247 && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
248 && let Some(entries) = parse_data_json(encoded.trim())
249 {
250 return entries;
251 }
252 }
253
254 section.lines().filter_map(parse_entry_line).collect()
255}
256
257fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
258 let value: Value = serde_json::from_str(encoded).ok()?;
259 let mut entries = Vec::new();
260 for item in value.as_array()? {
261 entries.push(NoteEntry {
262 id: item.get("id")?.as_str()?.to_owned(),
263 url: item.get("url")?.as_str()?.to_owned(),
264 title: item
265 .get("title")
266 .and_then(Value::as_str)
267 .unwrap_or_default()
268 .to_owned(),
269 state: item
270 .get("state")
271 .and_then(Value::as_str)
272 .unwrap_or("open")
273 .to_owned(),
274 });
275 }
276 Some(entries)
277}
278
279fn parse_entry_line(line: &str) -> Option<NoteEntry> {
283 let rest = line.trim().strip_prefix("- ")?;
284 if rest.starts_with('`') {
285 return None;
286 }
287
288 let open = rest.find('[')?;
289 let split = rest[open..].find("](")? + open;
290 let close = rest[split + 2..].find(')')? + split + 2;
291 let label = &rest[open + 1..split];
292 let url = &rest[split + 2..close];
293 let tail = &rest[close + 1..];
294
295 let state = if tail.contains("(merged)") {
296 "merged"
297 } else if tail.contains("(closed)") {
298 "closed"
299 } else {
300 "open"
301 };
302
303 let (title, id) = match rest[open + 1..split].rfind(" (") {
305 Some(position) if label.ends_with(')') => {
306 let id = &label[position + 2..label.len() - 1];
307 if id.starts_with('#') || id.starts_with('!') {
308 (label[..position].to_owned(), id.to_owned())
309 } else {
310 (label.to_owned(), String::new())
311 }
312 }
313 _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
314 _ => (label.to_owned(), String::new()),
315 };
316
317 Some(NoteEntry {
318 id,
319 url: url.to_owned(),
320 title,
321 state: state.to_owned(),
322 })
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
330 NoteEntry {
331 id: id.to_owned(),
332 url: url.to_owned(),
333 title: title.to_owned(),
334 state: state.to_owned(),
335 }
336 }
337
338 #[test]
339 fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
340 let entries = vec![
341 entry("#12", "Bottom change", "https://example.com/12", "open"),
342 entry("#13", "Top change", "https://example.com/13", "open"),
343 ];
344
345 let note = build_stack_note(&entries, 0, "main");
346 let lines: Vec<&str> = note.lines().collect();
347 assert!(
348 lines[0].starts_with(DATA_PREFIX),
349 "missing data line: {note}"
350 );
351 assert_eq!(
352 lines[1],
353 "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
354 );
355 assert_eq!(
356 lines[2],
357 "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
358 );
359 assert_eq!(lines[3], "- `main`");
360 assert!(note.ends_with(
361 "Stack managed by \
362 <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
363 width=\"12\" height=\"12\" alt=\"\" /> \
364 [git-stk](https://github.com/lararosekelley/git-stk)"
365 ));
366 }
367
368 #[test]
369 fn build_stack_note_styles_merged_and_closed_entries() {
370 let entries = vec![
371 entry("#11", "Landed", "https://example.com/11", "merged"),
372 entry("#12", "Abandoned", "https://example.com/12", "closed"),
373 entry("#13", "Live", "https://example.com/13", "open"),
374 ];
375
376 let note = build_stack_note(&entries, 2, "main");
377 assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
378 assert!(
379 note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
380 );
381 assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
382 }
383
384 #[test]
385 fn build_stack_note_falls_back_to_id_without_title() {
386 let entries = vec![entry("#12", "", "https://example.com/12", "open")];
387 let note = build_stack_note(&entries, 0, "main");
388 assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
389 }
390
391 #[test]
392 fn parse_ledger_round_trips_the_data_line() {
393 let entries = vec![
394 entry("#11", "Landed", "https://example.com/11", "merged"),
395 entry("#13", "Top -> change", "https://example.com/13", "open"),
396 ];
397
398 let note = build_stack_note(&entries, 1, "main");
399 assert_eq!(parse_ledger(¬e), entries);
400 }
401
402 #[test]
403 fn data_line_survives_a_title_containing_a_comment_terminator() {
404 let entries = vec![entry(
405 "#12",
406 "weird --> title",
407 "https://example.com/12",
408 "open",
409 )];
410 let line = data_line(&entries);
411 assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
412 assert_eq!(parse_ledger(&line), entries);
413 }
414
415 #[test]
416 fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
417 let entries = vec![
418 entry("#11", "Landed", "https://example.com/11", "merged"),
419 entry("#12", "", "https://example.com/12", "closed"),
420 entry("#13", "Live", "https://example.com/13", "open"),
421 ];
422
423 let note = build_stack_note(&entries, 2, "main");
424 let without_data: String = note
425 .lines()
426 .filter(|line| !line.trim().starts_with(DATA_PREFIX))
427 .collect::<Vec<_>>()
428 .join("\n");
429
430 let mut recovered = parse_ledger(&without_data);
433 recovered.reverse();
434 assert_eq!(recovered, entries);
435 }
436
437 #[test]
438 fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
439 let section = "<!-- git-stk:data [{\"id\": -->\n\
440 - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
441 - `main`";
442 assert_eq!(
443 parse_ledger(section),
444 vec![entry("#11", "Landed", "https://example.com/11", "merged")]
445 );
446 }
447
448 #[test]
449 fn parse_ledger_reads_the_legacy_unstyled_format() {
450 let section = "- [Top change (#13)](https://example.com/13)\n\
451 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
452 - `main`\n\n---\n\nfooter";
453 assert_eq!(
454 parse_ledger(section),
455 vec![
456 entry("#13", "Top change", "https://example.com/13", "open"),
457 entry("#12", "Bottom change", "https://example.com/12", "open"),
458 ]
459 );
460 }
461
462 #[test]
463 fn note_entry_round_trips_through_review() {
464 let landed = entry("#11", "Landed", "https://example.com/11", "merged");
465 let review = landed.to_review();
466 assert_eq!(review.state, ReviewState::Merged);
467 assert_eq!(NoteEntry::from_review(&review), landed);
468 }
469
470 #[test]
471 fn note_entry_matches_by_id_or_url() {
472 let by_id = entry("#11", "", "", "open");
473 let by_url = entry("", "", "https://example.com/11", "open");
474 assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
475 assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
476 assert!(!by_url.matches(&by_id));
477 }
478}