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 = ¤t[..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 = ¤t[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}