Skip to main content

tix/model/
issue.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// An issue: one markdown file with YAML frontmatter.
5///
6/// Field order and names are frozen by `contracts/file-format.md` FR-012.
7/// `number` is a stored frontmatter key assigned by the storage layer at
8/// create time by scanning the live issue set (issue #52; previously from
9/// `tix.toml` `[state].next_number`, which is no longer persisted); this
10/// module models and validates it but never allocates. `filename` and
11/// `body` are memory-only (`#[serde(skip)]`).
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Issue {
14    /// Human-facing sequential number: a convenience alias, allocated
15    /// per-checkout as `max(number) + 1` and **allowed to collide** across
16    /// branches (an agent's and a human's `tix create` may both produce `#7`).
17    /// It is stable for every surviving issue in one store (C-007) but is not
18    /// an identity — use [`Self::id`] for that.
19    pub number: u32,
20    /// Globally unique identity: 8 Crockford base32 characters (see
21    /// [`crate::id`]), assigned at create and stored in the frontmatter and the
22    /// filename prefix.
23    ///
24    /// Optional so every issue written before the id schema keeps parsing
25    /// unchanged; such an issue simply has no canonical id yet (and its
26    /// filename is not id-prefixed). New issues always carry one. Unlike
27    /// `number`, this value never collides by accident: it is random across
28    /// clones, so two branches creating issue #7 each still create two distinct
29    /// issues.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub id: Option<String>,
32    /// Human title; drives the slug in the filename.
33    #[serde(default)]
34    pub title: String,
35    /// Column (board workflow stage) the issue sits in.
36    #[serde(default)]
37    pub status: Status,
38    /// Free-form labels.
39    #[serde(default)]
40    pub labels: Labels,
41    /// Issue type (free-form, e.g. `bug`, `feature`).
42    ///
43    /// First-class since the type promotion; it used to be kept as a
44    /// `[custom_attributes]` key. `tix lint` accepts a value only from the
45    /// configured `custom_types` (or the built-in defaults). Optional, and
46    /// skipped when absent so a file without it round-trips byte-identically.
47    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
48    pub type_: Option<String>,
49    /// Priority; defaults to `medium`.
50    #[serde(default)]
51    pub priority: Priority,
52    /// Assignee. In memory: bare handle (`alice`) or full `Name <email>`
53    /// identity, verbatim. Files serialize per the rule documented on
54    /// [`de_assignee`]/[`se_assignee`] (legacy `@alice` tolerated on read).
55    #[serde(
56        default,
57        skip_serializing_if = "Option::is_none",
58        deserialize_with = "de_assignee",
59        serialize_with = "se_assignee"
60    )]
61    pub assignee: Option<String>,
62    /// Free-form estimate (e.g. `2`, `S`, `3d`); default `null`.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub estimate: Option<String>,
65    /// Git commit recorded when moved to `done` (FR-026).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub commit: Option<String>,
68    /// Remote GitLab issue id after export/import.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub gitlab_id: Option<String>,
71    /// Remote Linear issue id after export/import.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub linear_id: Option<String>,
74    /// Spec Kitty mission slug (e.g. `v3-archive-review`) this issue belongs to, if any.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub mission: Option<String>,
77    /// Spec Kitty work package id (e.g. `WP01`) this issue links to, if any.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub task: Option<String>,
80    /// Typed relationships to other issues, replacing the retired generic
81    /// `links: Vec<String>` bag (C-001).
82    ///
83    /// Each entry is a [`Relationship`]: a typed, directed edge with a stable
84    /// id and a human-readable display name. The vocabulary is the closed
85    /// four-kind set — `blocks`, `blocked_by`, `duplicates`, `related` — and
86    /// the kind that reaches disk is always the canonical one, so `blocked_by`
87    /// is normalized into `blocks` before it is stored and a pair is never
88    /// stored twice. See [`crate::relationships`] for the direction
89    /// convention, which this field does not restate.
90    ///
91    /// Files without the key parse (`default`). An empty list is **omitted**
92    /// rather than written as `[]`: the typed field is new, so there is no
93    /// legacy expectation to preserve for it, and omitting it keeps an issue
94    /// with no relationships free of a meaningless key.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub relationships: Vec<Relationship>,
97    /// General, cross-repository references to other issues, stored verbatim.
98    ///
99    /// Unlike [`Self::relationships`] (typed, number-keyed, local) this is a
100    /// forward-only, user-authored list of free-form references. An entry is one
101    /// of: a local issue number or selector (`42`, `#42`, a title/filename
102    /// fragment); a relative or absolute path to another issue file (possibly in
103    /// a sibling repository); or a repository URL (optionally with an issue
104    /// reference). The grammar and resolution live in [`crate::related`].
105    ///
106    /// Entries are stored **verbatim** (trimmed) rather than as a re-serialized
107    /// enum, so a path round-trips byte-stable; classification happens at
108    /// read/render time. The list is never mirrored on write: it is user data,
109    /// not a symmetric edge like the retired `links:`.
110    ///
111    /// `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so every file
112    /// without the key parses and round-trips byte-identically, and an empty list
113    /// is omitted rather than written as `[]`.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub related_issues: Vec<String>,
116    /// This issue's **decomposition parent**, as an issue number.
117    ///
118    /// A parent is a decomposition — "this work is part of that" — and is a
119    /// **distinct concept from a typed dependency edge**: `blocks` is a scheduling
120    /// dependency, and conflating the two in one field is prohibited (C-007). It is
121    /// also distinct from the `mission` and `task` Spec Kitty pointer fields, which
122    /// are governance metadata and not a hierarchy (research R2).
123    ///
124    /// **At most one parent** (C-001). The type is a single optional reference, so two
125    /// parents are not expressible; the field is not a list and no code path appends
126    /// to it. Child membership is *derived* by inverting this pointer and is never
127    /// stored on the parent (C-003) — see [`crate::hierarchy`], which owns that
128    /// derivation.
129    ///
130    /// `#[serde(default)]` so every legacy file without the key parses unchanged,
131    /// and skipped when absent so a file that never had a parent serialises
132    /// byte-identically to its pre-mission form.
133    ///
134    /// The value is a canonical issue **id**. Selectors, filenames, titles, and
135    /// numbers are input aliases; they never replace the canonical id in storage.
136    /// Older numeric values parse for the explicit graph migration only.
137    ///
138    /// A reference whose target no longer exists is **kept**, not cleared: it becomes
139    /// the diagnosed `UnresolvedParentState` so it can be repaired.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub parent: Option<crate::id::IssueRef>,
142    /// The recurrence metadata this issue carries (`series:`), when it is part of
143    /// a series — either the **definition** (it owns the rule) or one of its
144    /// **occurrences** (it carries the occurrence key). Mission
145    /// `recurring-issues-01M3C7G7`, data-model §1/§3/§6; see
146    /// [`crate::recurrence::series`] for the typed model and the index.
147    ///
148    /// One table with two roles decided by which fields are set, rather than a
149    /// `role:` flag a writer could disagree with. Optional and skipped when
150    /// absent, so every file that predates the mission parses unchanged.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub series: Option<SeriesRef>,
153    /// The **current** health value (mission
154    /// `project-issue-health-status-01M3C8HX`, WP02 T007).
155    ///
156    /// A projection of the append-only Health events, kept beside them so a surface
157    /// can read the state without walking the History — and *only* the projection:
158    /// the state, how it was obtained, the policy version, and when it was last
159    /// updated. The **rationale lives in the event**, never here, because a
160    /// frontmatter-only note is exactly the untracked value C-004 forbids.
161    ///
162    /// Optional and skipped when absent, so every file that predates the mission
163    /// parses and re-serializes byte-identically. The state is the file's own
164    /// spelling (like [`SeriesRule`]'s fields): validating it is the health layer's
165    /// job, so a file carrying a value this build does not know still loads and is
166    /// **reported** rather than becoming unreadable.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub health: Option<HealthRef>,
169    /// Repo-relative file paths of the governing specification (e.g.
170    /// `openspec/changes/<name>/specs/<cap>/spec.md`). Default `[]`.
171    #[serde(default)]
172    pub files: Vec<String>,
173    /// Planning cycle names this issue belongs to (FR-005). Each entry must
174    /// name a `[cycles.<name>]` config entry. `Option` so legacy files lack
175    /// the key entirely; serialized only when non-empty.
176    #[serde(
177        default,
178        skip_serializing_if = "skip_planning_list",
179        serialize_with = "se_planning_list"
180    )]
181    pub cycles: Option<Vec<String>>,
182    /// Milestone names this issue belongs to (FR-005). Each entry must name a
183    /// milestone file in `milestones_dir`. Serialized only when non-empty.
184    #[serde(
185        default,
186        skip_serializing_if = "skip_planning_list",
187        serialize_with = "se_planning_list"
188    )]
189    pub milestones: Option<Vec<String>>,
190    /// Project ids this issue is associated with (mission
191    /// `first-class-projects-01M3BYBW`, decision `01M3BYD1F3NWB0ZYXT1AMJYYVY`).
192    ///
193    /// Holds project **ids**, never names: an id is allocated once and never
194    /// changes, so a project rename cannot break an association (C-002, SC-005).
195    /// An issue may carry **several** associations (C-003), so the field is
196    /// list-valued; a single-valued `project` field is the rejected alternative
197    /// (data-model §1, contract §6).
198    ///
199    /// The wire shape is the same as [`Self::cycles`]/[`Self::milestones`] — an
200    /// `Option<Vec<String>>`, `#[serde(default)]` on read and serialized only when
201    /// non-empty — so a file that never had an association round-trips
202    /// byte-identically to its pre-mission form (NFR-002). The key is `projects`,
203    /// which is **distinct** from `cycles`, `milestones`, `mission`, and `task`:
204    /// four list/pointer reference fields on one struct is exactly where an
205    /// accidental alias would hide, so no serde alias is declared for any of them.
206    ///
207    /// `(issue, project_id)` is unique. The service returns the resulting list and
208    /// storage writes it verbatim; the shared `se_planning_list` renderer
209    /// additionally collapses a duplicate that predates the field, preserving
210    /// first-seen order.
211    ///
212    /// A reference whose project is no longer in the registry is **kept**, not
213    /// dropped: it becomes the diagnosed missing reference so it can be repaired
214    /// (C-005, contract §5).
215    #[serde(
216        default,
217        skip_serializing_if = "skip_planning_list",
218        serialize_with = "se_planning_list"
219    )]
220    pub projects: Option<Vec<String>>,
221    /// Epic ids this issue is referenced by (mission
222    /// `epics-work-items-01M3BYW4`, decision `01M3BYX33ZKN9BB275T0BNZJXD`).
223    ///
224    /// Holds epic **ids**, never names: an id is allocated once and never changes,
225    /// so renaming an epic cannot break a reference (C-003). An issue may be
226    /// referenced by **several** epics (C-003), so the field is list-valued; a
227    /// single-valued `epic` field is the rejected alternative
228    /// (`contracts/epic-registry.md` §3).
229    ///
230    /// The wire shape is the same as [`Self::projects`]/[`Self::cycles`]/
231    /// [`Self::milestones`] — an `Option<Vec<String>>`, `#[serde(default)]` on read
232    /// and serialized only when non-empty — so a file that never had a reference
233    /// round-trips byte-identically to its pre-mission form (NFR-002). The key is
234    /// `epics`, which is **distinct** from `projects`, `cycles`, `milestones`,
235    /// `mission`, `task`, and `files`: five list-valued reference fields on one
236    /// struct is exactly where an accidental alias would hide, so no serde alias is
237    /// declared for any of them.
238    ///
239    /// This is **not** a task or work-item reference (C-001): there is no task
240    /// record type, and a `task`-labelled issue is an ordinary member. The Spec
241    /// Kitty `task` pointer field above is unrelated and untouched.
242    ///
243    /// `(issue, epic_id)` is unique. The service returns the resulting list and
244    /// storage writes it verbatim; the shared `se_planning_list` renderer
245    /// additionally collapses a duplicate that predates the field, preserving
246    /// first-seen order.
247    ///
248    /// A reference whose epic is no longer in the registry is **kept**, not
249    /// dropped: it becomes the diagnosed missing reference so it can be repaired
250    /// (C-005, `contracts/epic-registry.md` §5).
251    #[serde(
252        default,
253        skip_serializing_if = "skip_planning_list",
254        serialize_with = "se_planning_list"
255    )]
256    pub epics: Option<Vec<String>>,
257    /// Advisory reviewer identity (FR-006), free-form like `assignee`.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub reviewer: Option<String>,
260    /// Planning start date, `YYYY-MM-DD` (FR-006).
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub start_date: Option<String>,
263    /// Planning due date, `YYYY-MM-DD`; must be `>= start_date` (FR-007).
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub due_date: Option<String>,
266    /// When the issue most recently entered the triage inbox, RFC 3339 UTC
267    /// (`2026-09-30T09:12:44Z`), if it ever did.
268    ///
269    /// Mission `triage-inbox-review-flow-01M3C600`, data-model §2. Queue age is
270    /// measured from here and from nothing else: `updated` moves on every edit and
271    /// `created` predates the queue, so either would report a wait the issue did
272    /// not have (research R4). Read it through [`crate::triage::entry_time`],
273    /// which is the one documented fallback for an issue that has none.
274    ///
275    /// Optional and skipped when absent, so a repository that predates triage
276    /// round-trips byte-identically instead of gaining a key on every file (C-001).
277    ///
278    /// Refreshed on re-entry, not merged — a defer restarts the wait (data-model
279    /// §5) — and deliberately **not** stamped on every edit, which would turn it
280    /// into a second `updated`.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub entered_at: Option<String>,
283    /// Where the issue's current entry came from: `new`, `import`, `defer`, or
284    /// any other origin the caller documents (data-model §2, `source`).
285    ///
286    /// Optional for the same reason as [`Self::entered_at`]. It describes *this*
287    /// entry rather than the issue's permanent provenance, so it is rewritten
288    /// whenever the issue re-enters.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub source: Option<String>,
291    /// The issue's **current** time entries (mission
292    /// `per-issue-time-tracking-01M3C9A4`, WP01 T004), under the frontmatter key
293    /// `time_entries`.
294    ///
295    /// Each element is a [`crate::time_tracking::TimeEntry`]: actual effort at
296    /// minute precision, with its own stable id, optional calendar date, author
297    /// and note. This is the current projection only — corrections and deletions
298    /// stay in the append-only History, never here (C-004) — and it is **not**
299    /// the free-form `estimate` field above, which keeps its planning meaning
300    /// (C-005). Nothing about an entry changes status or ownership (C-008).
301    ///
302    /// `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so a file that
303    /// never had an entry parses unchanged and round-trips byte-identically
304    /// instead of gaining a `time_entries: []` key, which is the same rule
305    /// [`Self::relationships`] and [`Self::related_issues`] follow.
306    ///
307    /// An entry that fails [`crate::time_tracking::TimeEntry::validate`] is kept
308    /// in this list rather than dropped, so a malformed entry stays readable and
309    /// reportable instead of blocking the issue or vanishing on the next
310    /// unrelated write (C-005).
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub time_entries: Vec<crate::time_tracking::TimeEntry>,
313    /// Creation date, `YYYY-MM-DD`, the leading component of the filename.
314    #[serde(default = "today")]
315    pub created: String,
316    /// Last-mutation date, `YYYY-MM-DD`.
317    #[serde(default = "today")]
318    pub updated: String,
319    /// Issue file name — path relative to the issues dir. A new-schema name is
320    /// `{date}-{id}-{slug}` (e.g. `alice/type/status/2026-05-10-A1B2C3D4-fix-login.md`),
321    /// so a directory listing is creation order; a filename written before the
322    /// id schema is `{date}-{slug}` (e.g. `2026-05-10-fix-login.md`, or grouped
323    /// per C-010).
324    #[serde(skip)]
325    pub filename: String,
326    /// Raw markdown body; memory-only, preserved verbatim.
327    #[serde(skip)]
328    pub body: String,
329    /// The append-only `## History` section, parsed out of the body on read and
330    /// re-rendered on write; memory-only like [`Issue::body`].
331    ///
332    /// The section is *stripped* from `body` during parsing, so `body` holds
333    /// only the issue's own prose and the two never disagree. Rendering puts
334    /// the section back immediately after the frontmatter, the position
335    /// `data-model.md` §8 pins.
336    #[serde(skip)]
337    pub history: History,
338    /// Whether the source frontmatter still carries a legacy `links:` key.
339    ///
340    /// The key is never read as typed data (C-001) and never migrated; it is
341    /// recorded here so the service can report it as a
342    /// `retired_links_key` validation result. Memory-only, like the rest of the
343    /// file-surface state.
344    #[serde(skip)]
345    pub has_retired_links_key: bool,
346    /// Top-level frontmatter keys this model does not know, as `key -> raw block`.
347    ///
348    /// #1361. Before this, an unknown key was dropped on every write: `Issue`
349    /// deserialises a fixed set of keys and `frontmatter` re-emits exactly those, so
350    /// a key `tix` does not recognise was deleted by the next unrelated `tix edit` —
351    /// silently, because `tix lint` had no unknown-key rule at all.
352    ///
353    /// The value is the **raw source block**, not a parsed value, and that is the
354    /// whole design. Re-serialising a parsed value would re-quote and re-indent it, so
355    /// a key that was written one way would come back another way and every write
356    /// would produce a spurious diff. Splicing the original bytes back in makes the
357    /// round trip byte-stable for content this model has no opinion about.
358    ///
359    /// A `BTreeMap` rather than a `HashMap` so the render order is deterministic:
360    /// `HashMap` iteration order would reshuffle these keys on every write and
361    /// produce a diff in a file that had not changed.
362    ///
363    /// `Eq` is preserved (unlike `serde_yaml::Value`, which is not `Eq`), and the
364    /// blocks are plain `String`s, so `Issue` keeps every one of its derives.
365    ///
366    /// The retired `links:` key is **excluded** — see [`Issue::parse`]. It is
367    /// recognised, reported by its own finding, and dropped (C-001); round-tripping it
368    /// would silently undo the typed-relationship migration.
369    #[serde(skip)]
370    pub extra_frontmatter: BTreeMap<String, String>,
371    /// Whether the issue is archived (issue #1492).
372    ///
373    /// The **filename is the marker**, never a frontmatter field: an archived
374    /// issue's markdown file is dot-prefixed in place
375    /// (`docs/issues/<assignee>/.<file>.md`). This field is therefore derived
376    /// state, not data — it mirrors the leading dot of [`Issue::filename`] and
377    /// is set by [`crate::storage::Store::list_all`], never written to disk.
378    /// `#[serde(skip)]` keeps it out of the frontmatter, and it defaults to
379    /// `false` for every parse.
380    #[serde(skip)]
381    pub archived: bool,
382}
383
384impl Issue {
385    /// Parse a full issue file (`frontmatter` + `body`) from its text.
386    ///
387    /// The first line must be `---`; the closing `---` terminates the
388    /// frontmatter. The body after it is preserved verbatim (the single
389    /// structural separator blank line is consumed).
390    pub fn parse(contents: &str) -> Result<Self> {
391        // #1299: a CRLF file must parse. The delimiter scan tolerates a `\r`
392        // before each `\n` rather than normalizing the whole file — a blanket
393        // CRLF-to-LF rewrite would silently change a user's bytes, so the fix
394        // stays in the scan that decides where the frontmatter ends.
395        let rest = contents
396            .strip_prefix("---\n")
397            .or_else(|| contents.strip_prefix("---\r\n"))
398            .ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
399        let mut current = rest;
400        let mut fm_len = 0usize;
401        loop {
402            let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
403            let line = &current[..line_end];
404            let content = line.strip_suffix('\n').unwrap_or(line);
405            let content = content.strip_suffix('\r').unwrap_or(content);
406            if content == "---" {
407                break;
408            }
409            fm_len += line_end;
410            current = &current[line_end..];
411            if current.is_empty() {
412                return Err(TixError::Parse(
413                    "frontmatter is not terminated by a closing `---`".into(),
414                ));
415            }
416        }
417        let frontmatter = &rest[..fm_len];
418        let body_raw = &rest[fm_len..];
419        // #1299: the closing delimiter is CRLF too when the file is CRLF.
420        let mut body = body_raw
421            .strip_prefix("---\n")
422            .or_else(|| body_raw.strip_prefix("---\r\n"))
423            .unwrap_or(body_raw);
424        if let Some(after) = body
425            .strip_prefix("\r\n")
426            .or_else(|| body.strip_prefix('\n'))
427        {
428            body = after;
429        }
430        let mut issue: Issue =
431            serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
432        // A legacy `links:` key is recorded, never read. The typed model does
433        // not migrate it and does not reinterpret it as typed edges; the
434        // service reports it as a `retired_links_key` validation result (C-001).
435        // `relationships` stays as serde left it — empty.
436        issue.has_retired_links_key = frontmatter_has_retired_links_key(frontmatter);
437        // Unknown top-level keys are captured so the next write can put them back
438        // (#1361). `links` is excluded on purpose — see [`capture_extra_frontmatter`].
439        issue.extra_frontmatter = capture_extra_frontmatter(frontmatter);
440        let (history, remaining_body) = History::parse_front(body);
441        issue.history = history;
442        issue.body = remaining_body;
443        Ok(issue)
444    }
445
446    /// Serialize just the frontmatter YAML (no delimiters), with `None`
447    /// fields omitted except `created`/`updated` which are always present.
448    pub fn frontmatter(&self) -> String {
449        let fm =
450            serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
451        let mut fm = fm.trim_end_matches('\n').to_string();
452        // Unknown keys are appended after the known ones, in `BTreeMap` order, so
453        // the render is deterministic. A `HashMap` would reshuffle them on every
454        // write and produce a diff in a file that had not changed (#1361).
455        //
456        // They go last rather than back in their original position because the
457        // positions of the *known* keys move as optional fields come and go;
458        // re-inserting at the original index would be a guess. `tix` has no
459        // opinion about these keys, so where they sit carries no meaning.
460        for (key, block) in &self.extra_frontmatter {
461            fm.push('\n');
462            fm.push_str(block);
463            let _ = key;
464        }
465        fm.push('\n');
466        fm
467    }
468
469    /// Render the full issue file: `---`, frontmatter, closing `---`,
470    /// blank separator and the raw body.
471    pub fn to_file_contents(&self) -> String {
472        // Frontmatter, then the History section (omitted when empty), then the
473        // issue's own body — the order `data-model.md` §8 pins.
474        let history = self.history.render();
475        let mut out = format!("---\n{}---\n\n", self.frontmatter());
476        out.push_str(&history);
477        out.push_str(&self.body);
478        out
479    }
480
481    /// A borrowed [`IssueView`] of this issue, for the relationship service.
482    ///
483    /// Keeps the service off the full `Issue` surface: it needs only the
484    /// number, title, status, relationships, and whether the legacy `links:`
485    /// key is still present.
486    pub fn issue_view(&self) -> IssueView<'_> {
487        IssueView::new(
488            self.number,
489            &self.title,
490            self.status.display(),
491            &self.relationships,
492        )
493        .with_identity(self.id.as_deref(), &self.filename)
494        .with_retired_links_key(self.has_retired_links_key)
495        .with_parent(self.parent)
496    }
497
498    /// The filename stem: `{created}-{id}-{slug}` when the issue has an id, or
499    /// the legacy `{created}-{slug}` when it does not.
500    ///
501    /// The date leads so a lexicographic directory listing is creation order;
502    /// the id follows so the name is globally unique across branches (#1587). A
503    /// legacy issue keeps the date-leading name it was written with, because
504    /// renaming it would be a migration this feature deliberately does not do.
505    pub fn filename_stem(&self) -> String {
506        match &self.id {
507            Some(id) => format!("{}-{id}-{}", self.created, slugify(&self.title)),
508            None => format!("{}-{}", self.created, slugify(&self.title)),
509        }
510    }
511
512    /// Record a mutation by stamping today's date into `updated`.
513    pub fn bump_updated(&mut self) {
514        self.updated = today();
515    }
516
517    /// Set `start_date`/`due_date` together after atomic validation (FR-007):
518    /// both must parse as calendar dates and `due_date >= start_date` when
519    /// both are present (delegates to [`validate_issue_dates`]).
520    pub fn set_dates(
521        &mut self,
522        start_date: Option<String>,
523        due_date: Option<String>,
524    ) -> Result<()> {
525        validate_issue_dates(start_date.as_deref(), due_date.as_deref())?;
526        self.start_date = start_date;
527        self.due_date = due_date;
528        Ok(())
529    }
530
531    /// The byte offset of the first top-level `## Comments` line in `body`,
532    /// if any (C-001, `contracts/comments-format.md` rule 6).
533    ///
534    /// A line counts as the boundary only when it is *not* inside a fenced
535    /// code block, so a literal `## Comments` in prose (or a code block) is
536    /// never mistaken for the real section head.
537    fn comments_boundary(body: &str) -> Option<usize> {
538        let mut in_fence = false;
539        let mut offset = 0usize;
540        for raw in body.split_inclusive('\n') {
541            let line = raw.strip_suffix('\n').unwrap_or(raw);
542            if line.starts_with("```") || line.starts_with("~~~") {
543                in_fence = !in_fence;
544            } else if !in_fence && line == "## Comments" {
545                return Some(offset);
546            }
547            offset += raw.len();
548        }
549        None
550    }
551
552    /// The description: everything above the first top-level `## Comments`
553    /// heading, trimmed of trailing whitespace. With no section, the whole
554    /// body is the description.
555    pub fn description(&self) -> &str {
556        match Self::comments_boundary(&self.body) {
557            Some(idx) => self.body[..idx].trim_end(),
558            None => self.body.trim_end(),
559        }
560    }
561
562    /// The comments parsed from the `## Comments` section, in file order
563    /// (NFR-005). With no section — or a section with no `###` heads —
564    /// returns an empty vec.
565    pub fn comments(&self) -> Vec<Comment> {
566        let Some(boundary) = Self::comments_boundary(&self.body) else {
567            return Vec::new();
568        };
569        let section = &self.body[boundary..];
570        let mut comments = Vec::new();
571        let mut offset = 0usize;
572        for raw in section.split_inclusive('\n') {
573            let line = raw.strip_suffix('\n').unwrap_or(raw);
574            if let Some(after) = line.strip_prefix("### ") {
575                let (author, date) = parse_comment_head(after);
576                let start = offset + raw.len();
577                let mut text = String::new();
578                for next in section[start..].split_inclusive('\n') {
579                    if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
580                        break;
581                    }
582                    text.push_str(next);
583                }
584                comments.push(Comment {
585                    author,
586                    date,
587                    text: text.trim_end_matches('\n').to_string(),
588                });
589            }
590            offset += raw.len();
591        }
592        comments
593    }
594
595    /// Append a comment to the `## Comments` section.
596    ///
597    /// Whitespace-only text is discarded and the body is left untouched
598    /// (FR-004 case 4). Otherwise the section is created if absent and the
599    /// comment block is appended after the last existing comment. The
600    /// description and any existing comments are never rewritten.
601    pub fn add_comment(&mut self, author: &str, text: &str) {
602        if text.trim().is_empty() {
603            return;
604        }
605        if Self::comments_boundary(&self.body).is_none() {
606            if !self.body.trim().is_empty() {
607                if !self.body.ends_with('\n') {
608                    self.body.push('\n');
609                }
610                self.body.push('\n');
611            }
612            self.body.push_str("## Comments\n");
613        }
614        if !self.body.is_empty() && !self.body.ends_with('\n') {
615            self.body.push('\n');
616        }
617        if !self.body.ends_with("\n\n") {
618            self.body.push('\n');
619        }
620        self.body
621            .push_str(&format!("### {author} ({})\n\n{text}\n", today()));
622        self.bump_updated();
623    }
624}
625
626impl crate::hierarchy::HierarchyNode for Issue {
627    fn number(&self) -> u32 {
628        self.number
629    }
630    fn title(&self) -> &str {
631        &self.title
632    }
633    fn status(&self) -> &str {
634        self.status.display()
635    }
636    fn canonical_id(&self) -> Option<&str> {
637        self.id.as_deref()
638    }
639    fn filename(&self) -> Option<&str> {
640        Some(&self.filename)
641    }
642    fn parent_ref(&self) -> Option<&crate::id::IssueRef> {
643        self.parent.as_ref()
644    }
645    fn parent_number(&self) -> Option<u32> {
646        self.parent
647            .as_ref()
648            .and_then(crate::id::IssueRef::as_number)
649    }
650}
651
652impl crate::id::IdentityNode for Issue {
653    fn canonical_id(&self) -> Option<&str> {
654        self.id.as_deref()
655    }
656
657    fn issue_number(&self) -> u32 {
658        self.number
659    }
660
661    fn issue_filename(&self) -> &str {
662        &self.filename
663    }
664
665    fn issue_title(&self) -> &str {
666        &self.title
667    }
668}
669
670impl crate::projects::ProjectNode for Issue {
671    fn number(&self) -> u32 {
672        self.number
673    }
674    fn title(&self) -> &str {
675        &self.title
676    }
677    fn status(&self) -> &str {
678        self.status.display()
679    }
680    fn project_ids(&self) -> &[String] {
681        self.projects.as_deref().unwrap_or(&[])
682    }
683}
684
685impl crate::epics::EpicNode for Issue {
686    fn number(&self) -> u32 {
687        self.number
688    }
689    fn title(&self) -> &str {
690        &self.title
691    }
692    fn status(&self) -> &str {
693        self.status.display()
694    }
695    fn epic_ids(&self) -> &[String] {
696        self.epics.as_deref().unwrap_or(&[])
697    }
698}