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