Skip to main content

cliban_sync/linear/
render.rs

1//! Pure text: what a Linear issue looks like as a cliban description, and what
2//! cliban progress looks like in Linear.
3//!
4//! Everything here is `&str` in, `String` out. No network, no database — which
5//! is what makes the merge rules testable, and the merge rules are the part
6//! that can quietly destroy someone's work.
7
8use cliban_core::schema::ActivityLogEntry;
9use cliban_core::{sections, time};
10
11use super::model;
12
13/// Anchors in the cliban description contract.
14pub const SPEC: &str = "Spec";
15pub const PLAN: &str = "Plan";
16
17/// Markers delimiting the region of a Linear description that cliban owns.
18/// HTML comments, so they are invisible in Linear's rendered view — a human
19/// reading the issue sees the content, not the plumbing.
20pub const FENCE_BEGIN_PREFIX: &str = "<!-- cliban:begin";
21pub const FENCE_END: &str = "<!-- cliban:end -->";
22
23/// The `## Spec` body for an imported issue: Linear's description verbatim,
24/// with a provenance line so anyone reading the cliban issue can get back to
25/// the source.
26pub fn spec_body(issue: &model::Issue) -> String {
27    let desc = issue.description_text();
28    let provenance = format!("_Imported from [{}]({})._", issue.identifier, issue.url);
29    if desc.is_empty() {
30        provenance
31    } else {
32        format!("{desc}\n\n{provenance}")
33    }
34}
35
36/// The full description for a newly imported issue: the spec, plus an empty
37/// `## Plan` so `cliban issue tick` has a section to work against immediately
38/// rather than failing with "no ## Plan section".
39pub fn initial_description(issue: &model::Issue) -> String {
40    format!(
41        "## Spec\n\n{}\n\n## Plan\n\n_No plan yet. Add tasks as `### Task N: title` \
42         with `- [ ] **Step M: ...**` steps._\n",
43        spec_body(issue)
44    )
45}
46
47/// Re-import: refresh the Linear-owned `## Spec` and leave every other section
48/// byte-identical. This is the load-bearing guarantee of the whole bridge — an
49/// agent's half-ticked `## Plan` must survive a refresh.
50pub fn refresh_description(existing: &str, issue: &model::Issue) -> String {
51    sections::replace_section(existing, SPEC, &spec_body(issue))
52}
53
54/// Splice `inner` into the cliban-owned fenced region of a Linear description,
55/// leaving all surrounding prose untouched. Appends the region when absent.
56///
57/// A begin marker with no matching end is treated as running to the end of the
58/// description. That recovers from a human deleting the end marker without
59/// duplicating the block, which is the failure mode that would actually happen.
60pub fn apply_fence(existing: &str, cliban_key: &str, inner: &str) -> String {
61    let block = format!(
62        "{FENCE_BEGIN_PREFIX} {cliban_key} -->\n{}\n{FENCE_END}",
63        inner.trim_end()
64    );
65
66    match fence_range(existing) {
67        Some((start, end)) => {
68            let mut out = String::with_capacity(existing.len() + block.len());
69            out.push_str(&existing[..start]);
70            out.push_str(&block);
71            out.push_str(&existing[end..]);
72            out
73        }
74        None => {
75            let base = existing.trim_end();
76            if base.is_empty() {
77                format!("{block}\n")
78            } else {
79                format!("{base}\n\n{block}\n")
80            }
81        }
82    }
83}
84
85/// Byte range of the existing fenced block, markers included.
86fn fence_range(desc: &str) -> Option<(usize, usize)> {
87    let start = find_line_starting_with(desc, FENCE_BEGIN_PREFIX)?;
88    let after_begin = &desc[start..];
89    match find_line_starting_with(after_begin, FENCE_END) {
90        Some(rel) => {
91            let end_line_start = start + rel;
92            let end = end_line_start + line_len_at(desc, end_line_start);
93            Some((start, end))
94        }
95        // Begin without end: cliban owns everything from the marker onward.
96        None => Some((start, desc.len())),
97    }
98}
99
100/// Byte offset of the first line whose trimmed start matches `prefix`.
101fn find_line_starting_with(s: &str, prefix: &str) -> Option<usize> {
102    let mut offset = 0usize;
103    for line in s.split_inclusive('\n') {
104        if line.trim_start().starts_with(prefix) {
105            return Some(offset);
106        }
107        offset += line.len();
108    }
109    None
110}
111
112/// Length of the line beginning at `at`, trailing newline included.
113fn line_len_at(s: &str, at: usize) -> usize {
114    s[at..]
115        .split_inclusive('\n')
116        .next()
117        .map(str::len)
118        .unwrap_or(0)
119}
120
121/// One task's tick count.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct TaskProgress {
124    pub number: i32,
125    pub title: String,
126    pub done: usize,
127    pub total: usize,
128}
129
130/// Count ticked steps per task in a `## Plan` body.
131///
132/// Read-only, and deliberately mirrors the CLI `descmd` step rule rather than
133/// inventing its own: a step is a GFM checkbox at **column zero**, so indented
134/// child bullets are prose, not steps. Getting this wrong would only misreport
135/// a comment, never corrupt a description — which is why counting lives here
136/// while mutation stays in `descmd`.
137pub fn plan_progress(plan_body: &str) -> Vec<TaskProgress> {
138    let mut out: Vec<TaskProgress> = Vec::new();
139    for line in plan_body.lines() {
140        if let Some(rest) = line.strip_prefix("### Task ") {
141            let (number, title) = split_task_heading(rest);
142            if let Some(number) = number {
143                out.push(TaskProgress {
144                    number,
145                    title,
146                    done: 0,
147                    total: 0,
148                });
149            }
150            continue;
151        }
152        let checked = if line.starts_with("- [x] ") || line.starts_with("- [X] ") {
153            Some(true)
154        } else if line.starts_with("- [ ] ") {
155            Some(false)
156        } else {
157            None
158        };
159        if let (Some(checked), Some(task)) = (checked, out.last_mut()) {
160            task.total += 1;
161            if checked {
162                task.done += 1;
163            }
164        }
165    }
166    out
167}
168
169/// `1: short title` → `(Some(1), "short title")`.
170fn split_task_heading(rest: &str) -> (Option<i32>, String) {
171    match rest.split_once(':') {
172        Some((num, title)) => (num.trim().parse().ok(), title.trim().to_string()),
173        None => (None, String::new()),
174    }
175}
176
177/// The comment `push` posts on the Linear issue: where the plan stands and
178/// what happened since the last sync.
179///
180/// Additive by construction — a comment can never destroy anything a human
181/// wrote, which is why it is on by default while the description rewrite is
182/// opt-in.
183pub fn progress_comment(
184    cliban_key: &str,
185    status: &str,
186    plan_body: Option<&str>,
187    activity: &[ActivityLogEntry],
188) -> String {
189    let mut out = format!("**cliban `{cliban_key}`** — status: `{status}`\n");
190
191    if let Some(plan) = plan_body {
192        let tasks = plan_progress(plan);
193        let done: usize = tasks.iter().map(|t| t.done).sum();
194        let total: usize = tasks.iter().map(|t| t.total).sum();
195        if total > 0 {
196            out.push_str(&format!("\n**Plan: {done}/{total} steps**\n\n"));
197            for task in &tasks {
198                let check = if task.total > 0 && task.done == task.total {
199                    "x"
200                } else {
201                    " "
202                };
203                out.push_str(&format!(
204                    "- [{check}] Task {}: {} — {}/{}\n",
205                    task.number, task.title, task.done, task.total
206                ));
207            }
208        }
209    }
210
211    if !activity.is_empty() {
212        out.push_str("\n**Activity**\n\n");
213        for entry in activity {
214            out.push_str(&format!(
215                "- `{}` {} — {}\n",
216                time::format_usec(entry.ts),
217                entry.kind,
218                entry.message.trim()
219            ));
220        }
221    }
222
223    out
224}
225
226/// How many `issue log` findings the living digest keeps. Roughly "what
227/// happened lately", not a transcript — the full log lives on the board.
228const DIGEST_FINDINGS: usize = 3;
229
230/// The body of the *living* progress comment: one comment per linked issue,
231/// rewritten in place on every push, so it always describes now.
232///
233/// Unlike [`progress_comment`] — which appends what changed since the last
234/// sync — this is a snapshot: plan progress, the last few `issue log`
235/// findings, the latest test status a finding carried, and a footer telling
236/// a human why the comment keeps changing under them.
237pub fn progress_digest(
238    cliban_key: &str,
239    status: &str,
240    plan_body: Option<&str>,
241    activity: &[ActivityLogEntry],
242) -> String {
243    let mut out = format!("**cliban `{cliban_key}`** — status: `{status}`\n");
244
245    if let Some(plan) = plan_body {
246        let tasks = plan_progress(plan);
247        let done: usize = tasks.iter().map(|t| t.done).sum();
248        let total: usize = tasks.iter().map(|t| t.total).sum();
249        if total > 0 {
250            out.push_str(&format!("\n**Plan: {done}/{total} steps**\n\n"));
251            for task in &tasks {
252                let check = if task.total > 0 && task.done == task.total {
253                    "x"
254                } else {
255                    " "
256                };
257                out.push_str(&format!(
258                    "- [{check}] Task {}: {} — {}/{}\n",
259                    task.number, task.title, task.done, task.total
260                ));
261            }
262        }
263    }
264
265    // Findings are what an agent chose to say (`issue log`), not what the tool
266    // recorded about itself — status moves and ticks are already visible as
267    // plan progress and the state itself.
268    let findings: Vec<&ActivityLogEntry> = activity.iter().filter(|e| e.kind == "log").collect();
269
270    if let Some(status_line) = findings.iter().rev().find_map(|e| test_status(&e.message)) {
271        out.push_str(&format!("\n**Tests:** {status_line}\n"));
272    }
273
274    if !findings.is_empty() {
275        out.push_str("\n**Latest findings**\n\n");
276        let skip = findings.len().saturating_sub(DIGEST_FINDINGS);
277        for entry in &findings[skip..] {
278            out.push_str(&format!(
279                "- `{}` — {}\n",
280                time::format_usec(entry.ts),
281                entry.message.trim()
282            ));
283        }
284    }
285
286    out.push_str(
287        "\n---\n_This comment is maintained by cliban and updated in place on each push._\n",
288    );
289    out
290}
291
292/// Pull a test status like `447 passed / 0 failed` out of a log message, if it
293/// carries one. A count is a number immediately before the word `passed` or
294/// `failed`; punctuation around tokens is ignored. Heuristic on purpose — a
295/// miss only omits a digest line, it never corrupts anything.
296fn test_status(message: &str) -> Option<String> {
297    let mut passed: Option<u64> = None;
298    let mut failed: Option<u64> = None;
299    let tokens: Vec<&str> = message
300        .split_whitespace()
301        .map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
302        .collect();
303    for pair in tokens.windows(2) {
304        let Ok(n) = pair[0].parse::<u64>() else {
305            continue;
306        };
307        match pair[1].to_ascii_lowercase().as_str() {
308            "passed" => passed = Some(n),
309            "failed" => failed = Some(n),
310            _ => {}
311        }
312    }
313    let mut parts = Vec::new();
314    if let Some(n) = passed {
315        parts.push(format!("{n} passed"));
316    }
317    if let Some(n) = failed {
318        parts.push(format!("{n} failed"));
319    }
320    if parts.is_empty() {
321        None
322    } else {
323        Some(parts.join(" / "))
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn issue(desc: Option<&str>) -> model::Issue {
332        serde_json::from_value(serde_json::json!({
333            "id": "uuid-1",
334            "identifier": "ENG-412",
335            "title": "Fix the thing",
336            "description": desc,
337            "url": "https://linear.app/acme/issue/ENG-412",
338            "updatedAt": "2026-07-29T12:00:00.000Z",
339            "priority": 2,
340            "dueDate": null,
341            "state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
342            "team": {"id": "t1", "key": "ENG", "name": "Engineering"},
343            "labels": {"nodes": []}
344        }))
345        .unwrap()
346    }
347
348    #[test]
349    fn spec_body_carries_the_description_and_a_link_home() {
350        let body = spec_body(&issue(Some("The bug is real.")));
351        assert!(body.starts_with("The bug is real."));
352        assert!(body.contains("[ENG-412](https://linear.app/acme/issue/ENG-412)"));
353    }
354
355    #[test]
356    fn spec_body_of_an_empty_issue_is_just_provenance() {
357        let body = spec_body(&issue(None));
358        assert!(body.starts_with("_Imported from"));
359        assert!(!body.starts_with("\n"));
360    }
361
362    #[test]
363    fn initial_description_has_both_contract_sections() {
364        let desc = initial_description(&issue(Some("spec text")));
365        assert!(sections::find_section(&desc, SPEC).2);
366        assert!(sections::find_section(&desc, PLAN).2, "tick needs ## Plan");
367    }
368
369    #[test]
370    fn refresh_replaces_spec_and_preserves_a_half_ticked_plan() {
371        let existing = "## Spec\n\nold spec\n\n## Plan\n\n### Task 1: wire it\n\n\
372                        - [x] **Step 1: done**\n- [ ] **Step 2: todo**\n\n\
373                        ## Activity Log\n\n2026-07-01T10:00Z  note  started\n";
374        let out = refresh_description(existing, &issue(Some("NEW spec")));
375
376        assert!(out.contains("NEW spec"));
377        assert!(!out.contains("old spec"));
378        // The two things a refresh must never touch.
379        assert!(out.contains("- [x] **Step 1: done**"));
380        assert!(out.contains("- [ ] **Step 2: todo**"));
381        assert!(out.contains("2026-07-01T10:00Z  note  started"));
382    }
383
384    #[test]
385    fn refresh_is_idempotent() {
386        let existing = "## Spec\n\nold\n\n## Plan\n\n### Task 1: x\n\n- [ ] **Step 1: a**\n";
387        let once = refresh_description(existing, &issue(Some("new")));
388        let twice = refresh_description(&once, &issue(Some("new")));
389        assert_eq!(once, twice);
390    }
391
392    #[test]
393    fn fence_appends_when_absent_and_leaves_prose_alone() {
394        let out = apply_fence("Human wrote this.\n", "PROJ-42", "plan mirror");
395        assert!(out.starts_with("Human wrote this."));
396        assert!(out.contains("<!-- cliban:begin PROJ-42 -->"));
397        assert!(out.contains("plan mirror"));
398        assert!(out.contains(FENCE_END));
399    }
400
401    #[test]
402    fn fence_replaces_only_its_own_region() {
403        let existing = "Above the fence.\n\n\
404                        <!-- cliban:begin PROJ-42 -->\nOLD MIRROR\n<!-- cliban:end -->\n\n\
405                        Below the fence.\n";
406        let out = apply_fence(existing, "PROJ-42", "NEW MIRROR");
407        assert!(out.contains("Above the fence."));
408        assert!(out.contains("Below the fence."));
409        assert!(out.contains("NEW MIRROR"));
410        assert!(!out.contains("OLD MIRROR"));
411    }
412
413    #[test]
414    fn fence_is_idempotent_and_does_not_multiply() {
415        let mut desc = "Human prose.\n".to_string();
416        for _ in 0..3 {
417            desc = apply_fence(&desc, "PROJ-42", "mirror");
418        }
419        assert_eq!(desc.matches(FENCE_BEGIN_PREFIX).count(), 1);
420        assert_eq!(desc.matches(FENCE_END).count(), 1);
421        assert_eq!(desc.matches("Human prose.").count(), 1);
422    }
423
424    #[test]
425    fn fence_recovers_from_a_deleted_end_marker_without_duplicating() {
426        let existing = "Prose.\n\n<!-- cliban:begin PROJ-42 -->\nstale mirror\n";
427        let out = apply_fence(existing, "PROJ-42", "fresh");
428        assert_eq!(out.matches(FENCE_BEGIN_PREFIX).count(), 1);
429        assert!(out.contains("fresh"));
430        assert!(!out.contains("stale mirror"));
431        assert!(out.contains("Prose."));
432    }
433
434    #[test]
435    fn plan_progress_counts_only_column_zero_checkboxes() {
436        let plan = "\n### Task 1: setup\n\n\
437                    - [x] **Step 1: a**\n\
438                    - [x] **Step 2: b**\n\
439                    \x20 - [ ] an indented child bullet, not a step\n\n\
440                    ### Task 2: build\n\n\
441                    - [ ] **Step 1: c**\n";
442        let tasks = plan_progress(plan);
443        assert_eq!(tasks.len(), 2);
444        assert_eq!(
445            tasks[0],
446            TaskProgress {
447                number: 1,
448                title: "setup".into(),
449                done: 2,
450                total: 2
451            }
452        );
453        assert_eq!(
454            tasks[1],
455            TaskProgress {
456                number: 2,
457                title: "build".into(),
458                done: 0,
459                total: 1
460            }
461        );
462    }
463
464    #[test]
465    fn plan_progress_of_an_empty_plan_is_empty_not_a_panic() {
466        assert!(plan_progress("").is_empty());
467        assert!(plan_progress("\n_No plan yet._\n").is_empty());
468    }
469
470    #[test]
471    fn plan_progress_ignores_steps_before_any_task_heading() {
472        // Malformed, but it must not panic on `out.last_mut()` being None.
473        assert!(plan_progress("- [x] **orphan step**\n").is_empty());
474    }
475
476    #[test]
477    fn progress_comment_reports_totals_and_marks_finished_tasks() {
478        let plan = "\n### Task 1: setup\n\n- [x] **Step 1: a**\n\n\
479                    ### Task 2: build\n\n- [ ] **Step 1: b**\n";
480        let out = progress_comment("PROJ-42", "in-progress", Some(plan), &[]);
481        assert!(out.contains("**cliban `PROJ-42`**"));
482        assert!(out.contains("Plan: 1/2 steps"));
483        assert!(out.contains("- [x] Task 1: setup — 1/1"));
484        assert!(out.contains("- [ ] Task 2: build — 0/1"));
485    }
486
487    #[test]
488    fn progress_comment_without_a_plan_still_reports_status() {
489        let out = progress_comment("PROJ-42", "done", None, &[]);
490        assert!(out.contains("status: `done`"));
491        assert!(!out.contains("Plan:"));
492    }
493
494    // ---- the living digest ----
495
496    fn entry(ts: &str, kind: &str, message: &str) -> ActivityLogEntry {
497        ActivityLogEntry {
498            id: 0,
499            issue_id: 1,
500            ts: time::parse_ts(ts).unwrap(),
501            kind: kind.into(),
502            message: message.into(),
503            extra: "{}".into(),
504            inserted_at: time::parse_ts(ts).unwrap(),
505            updated_at: time::parse_ts(ts).unwrap(),
506        }
507    }
508
509    #[test]
510    fn digest_reports_plan_progress_and_the_footer() {
511        let plan = "\n### Task 1: setup\n\n- [x] **Step 1: a**\n- [ ] **Step 2: b**\n";
512        let out = progress_digest("PROJ-42", "in-progress", Some(plan), &[]);
513        assert!(out.contains("**cliban `PROJ-42`**"));
514        assert!(out.contains("Plan: 1/2 steps"));
515        assert!(
516            out.contains("maintained by cliban"),
517            "the footer tells a human why the comment keeps changing:\n{out}"
518        );
519    }
520
521    #[test]
522    fn digest_keeps_only_the_last_three_log_findings_and_skips_bookkeeping() {
523        let activity = vec![
524            entry("2026-08-01T10:00:00Z", "log", "finding one"),
525            entry("2026-08-01T10:01:00Z", "status", "backlog → in-progress"),
526            entry("2026-08-01T10:02:00Z", "log", "finding two"),
527            entry("2026-08-01T10:03:00Z", "tick", "ticked Task 1 Step 1"),
528            entry("2026-08-01T10:04:00Z", "log", "finding three"),
529            entry("2026-08-01T10:05:00Z", "log", "finding four"),
530        ];
531        let out = progress_digest("PROJ-42", "in-progress", None, &activity);
532        assert!(
533            !out.contains("finding one"),
534            "oldest finding must age out:\n{out}"
535        );
536        assert!(out.contains("finding two"));
537        assert!(out.contains("finding three"));
538        assert!(out.contains("finding four"));
539        assert!(
540            !out.contains("backlog → in-progress") && !out.contains("ticked Task 1"),
541            "bookkeeping entries are not findings:\n{out}"
542        );
543    }
544
545    #[test]
546    fn digest_surfaces_the_latest_test_status_a_log_entry_carries() {
547        let activity = vec![
548            entry("2026-08-01T10:00:00Z", "log", "suite red: 3 failed"),
549            entry(
550                "2026-08-01T11:00:00Z",
551                "log",
552                "green again: 447 passed / 0 failed",
553            ),
554        ];
555        let out = progress_digest("PROJ-42", "in-progress", None, &activity);
556        assert!(
557            out.contains("**Tests:** 447 passed / 0 failed"),
558            "the newest test status should be pulled out on its own line:\n{out}"
559        );
560    }
561
562    #[test]
563    fn digest_without_test_talk_has_no_tests_line() {
564        let activity = vec![entry("2026-08-01T10:00:00Z", "log", "wired the client")];
565        let out = progress_digest("PROJ-42", "in-progress", None, &activity);
566        assert!(!out.contains("**Tests:**"), "{out}");
567    }
568
569    #[test]
570    fn digest_of_a_bare_issue_is_still_valid_markdown_with_status_and_footer() {
571        let out = progress_digest("PROJ-42", "backlog", None, &[]);
572        assert!(out.contains("status: `backlog`"));
573        assert!(out.contains("maintained by cliban"));
574        assert!(!out.contains("Plan:"));
575        assert!(!out.contains("**Latest findings**"));
576    }
577}