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
#[allow(clippy::wildcard_imports)]
use super::*;
impl Store {
/// Reference an epic from an issue.
///
/// The service resolves the epic (by id, or by name as a convenience),
/// enforces `(issue, epic_id)` uniqueness, and returns the resulting list
/// plus the History event; this method writes them through the existing
/// [`Store::update`] path. A refused reference writes nothing and appends
/// nothing, and an already-present reference is an idempotent no-op that does
/// the same.
pub fn reference_epic(
&self,
issue_selector: &str,
epic_reference: &str,
) -> Result<EpicReferenceOutcome> {
let issue = self.resolve_issue(issue_selector)?;
let snapshot = self.list_all()?;
let change =
crate::epics::reference_epic(&self.config, &snapshot, issue.number, epic_reference);
self.commit_epic_change(&snapshot, change)
}
/// Remove an issue's reference to an epic id.
///
/// `epic_id` is compared **verbatim** against the stored list, as the service
/// documents: a name is not resolved here, so removing a stale id that is no
/// longer a valid epic shape still works โ the repair path C-005 promises.
/// Dereferencing an absent reference is an idempotent no-op.
pub fn dereference_epic(
&self,
issue_selector: &str,
epic_id: &str,
) -> Result<EpicReferenceOutcome> {
let issue = self.resolve_issue(issue_selector)?;
let snapshot = self.list_all()?;
let change = crate::epics::dereference_epic(&snapshot, issue.number, epic_id);
self.commit_epic_change(&snapshot, change)
}
/// Reference an epic from each selector independently, writing every accepted
/// change.
///
/// One rejection must not abort the rest (`contracts/epic-service.md` ยง5): the
/// returned vector has one entry per selector, in input order. Each accepted
/// change is written to its own file and appends exactly one History event; a
/// refusal writes nothing. An unresolvable selector becomes that entry's own
/// error rather than failing the run.
///
/// ## Two selectors for one issue
///
/// Selectors are resolved and applied one at a time against a **working copy**
/// of the issue set, advanced after every accepted write โ the same reasoning
/// as [`Store::bulk_associate`]. Applying each selector to the one immutable
/// read would let a second selector naming the same issue miss the first
/// write, so the service's already-present branch would not fire and a second
/// `epic_referenced` entry would be appended for a reference that is already
/// there. With the working copy the second commit *is* seen, so idempotence
/// stays the service's decision rather than a membership rule re-implemented
/// here.
pub fn bulk_reference(
&self,
epic_reference: &str,
selectors: &[String],
) -> Result<Vec<BulkReferenceOutcome>> {
let mut working = self.list_all()?;
let mut outcomes = Vec::with_capacity(selectors.len());
for selector in selectors {
let outcome = match self.resolve_issue(selector) {
Err(error) => Err(error),
Ok(issue) => {
let change = crate::epics::reference_epic(
&self.config,
&working,
issue.number,
epic_reference,
);
match self.commit_epic_change(&working, change) {
Ok(applied) => {
if let Some(written) = &applied.written {
if let Some(slot) = working
.iter_mut()
.find(|candidate| candidate.number == issue.number)
{
*slot = written.clone();
}
}
Ok(applied)
}
Err(error) => Err(error),
}
}
};
outcomes.push(BulkReferenceOutcome {
selector: selector.clone(),
outcome,
});
}
Ok(outcomes)
}
/// Apply a service decision: write only an accepted change and append its
/// event.
///
/// The single write path for reference/dereference/bulk, so they cannot drift
/// in whether they record what they did. `snapshot` must be the issue set the
/// service validated against, exactly as [`Store::commit_project_change`]
/// requires: the written list is then the one the service derived rather than
/// a second, possibly stale, read.
pub(in crate::storage) fn commit_epic_change(
&self,
snapshot: &[Issue],
change: std::result::Result<crate::epics::EpicChange, crate::epics::EpicError>,
) -> Result<EpicReferenceOutcome> {
let change = change.map_err(TixError::from)?;
let Some(event) = change.event else {
// A no-op: nothing is written and no event is appended. The current
// list is still reported, so the caller does not have to re-read.
return Ok(EpicReferenceOutcome {
written: None,
epic_ids: change.epic_ids,
event: None,
changed: false,
});
};
let mut issue = snapshot
.iter()
.find(|issue| issue.number == change.issue.number())
.cloned()
.ok_or_else(|| {
TixError::NotFound(format!(
"issue #{} vanished between validating and writing it",
change.issue.number()
))
})?;
// The service's list, verbatim: storage does not re-derive uniqueness or
// membership. A rule implemented in two places is a defect, so the only
// copy of it lives in `crate::epics`.
issue.epics = Some(change.epic_ids.clone());
// Exactly one event per accepted change, appended to the issue's existing
// History โ never a second log.
let kind = HistoryEventKind::from_epic(event.kind);
issue
.history
.append_now(&actor_identity(), kind, &event.detail);
issue.bump_updated();
issue.filename = self.update(&issue.filename, &issue)?;
Ok(EpicReferenceOutcome {
written: Some(issue),
epic_ids: change.epic_ids,
event: Some(event),
changed: true,
})
}
}