gwm/milestones.rs
1//! GitHub milestone declarative management (issue #82).
2//!
3//! `[[milestones]]` in `.gwm.toml` declares the desired milestone set;
4//! this module resolves declared entries into concrete `MilestoneSpec`
5//! values (normalising dates, defaulting state to open), computes the
6//! diff against the upstream remote, and exposes the structs that the
7//! CLI (`gwm milestones list / push`) renders.
8//!
9//! The `gh`-backed I/O lives in `github.rs`; this module is
10//! intentionally I/O-free so unit tests don't need a network or a `gh`
11//! binary. Mirrors the shape of `labels.rs` so the two subcommands
12//! read symmetrically.
13
14use crate::config::MilestoneConfig;
15use crate::error::{GwmError, Result};
16use chrono::{DateTime, NaiveDate, Utc};
17use std::collections::{HashMap, HashSet};
18
19/// A fully-resolved milestone declared by the user: same shape as
20/// `MilestoneConfig` but with `state` materialised to the enum and
21/// `due_on` normalised to RFC3339 (`YYYY-MM-DD` → end-of-day UTC).
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct MilestoneSpec {
24 pub title: String,
25 pub description: Option<String>,
26 /// RFC3339 normalised form (e.g. `2026-07-15T23:59:59Z`). `None`
27 /// when the user omitted `due_on` — push code skips the field so the
28 /// remote value is left untouched.
29 pub due_on: Option<String>,
30 pub state: MilestoneState,
31}
32
33/// One milestone as returned by `gh api repos/:owner/:repo/milestones`.
34/// `number` is the GitHub-issued identifier required by the PATCH
35/// endpoint; the diff carries it through so the push step doesn't need
36/// a second fetch.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct RemoteMilestone {
39 pub number: u64,
40 pub title: String,
41 pub description: Option<String>,
42 pub due_on: Option<String>,
43 pub state: MilestoneState,
44}
45
46/// `open` (default) or `closed`. GitHub's REST contract only knows
47/// these two values; we model them as an enum so a typo can't sneak
48/// through the diff as a "fourth state".
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum MilestoneState {
51 Open,
52 Closed,
53}
54
55impl MilestoneState {
56 /// Canonical lowercase form sent on the wire and rendered in the
57 /// `gwm milestones list` output.
58 pub fn as_str(&self) -> &'static str {
59 match self {
60 Self::Open => "open",
61 Self::Closed => "closed",
62 }
63 }
64}
65
66/// Kind of mutation a `MilestoneUpdate` carries. The variant exists
67/// to leave room for future `delete` entries when `--prune` evolves
68/// without reshuffling the `MilestoneDiff` shape.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum MilestoneAction {
71 Create,
72 Update,
73}
74
75/// One row in `MilestoneDiff::to_update`. Carries the previous remote
76/// values (when they differed) so `gwm milestones list` can render
77/// `~ v0.7.0 (due 2026-07-01 → 2026-07-15)`.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct MilestoneUpdate {
80 pub action: MilestoneAction,
81 pub spec: MilestoneSpec,
82 /// Remote milestone number — required by the gh PATCH endpoint
83 /// (`gh api -X PATCH repos/:owner/:repo/milestones/{number}`).
84 pub number: u64,
85 pub previous_due_on: Option<String>,
86 pub previous_description: Option<String>,
87 pub previous_state: Option<MilestoneState>,
88}
89
90/// Result of diffing the declared milestone set against the remote.
91/// Each bucket is rendered separately by `gwm milestones list` and
92/// consumed by `gwm milestones push`.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct MilestoneDiff {
95 pub to_create: Vec<MilestoneSpec>,
96 pub to_update: Vec<MilestoneUpdate>,
97 pub matching: Vec<MilestoneSpec>,
98 pub extra_on_remote: Vec<RemoteMilestone>,
99}
100
101impl MilestoneDiff {
102 /// `(create, update, match, extra_on_remote)` — the four numbers
103 /// surfaced in the one-line push summary.
104 pub fn counts(&self) -> (usize, usize, usize, usize) {
105 (
106 self.to_create.len(),
107 self.to_update.len(),
108 self.matching.len(),
109 self.extra_on_remote.len(),
110 )
111 }
112}
113
114// --- Date / state helpers -----------------------------------------------
115
116/// Accept either `YYYY-MM-DD` (materialised as 23:59:59 UTC of that
117/// day) or a full RFC3339 timestamp (canonicalised to UTC `…Z`). The
118/// short form is the issue spec's "common-sense" semantic for a due
119/// date — "due Friday" should not close at midnight UTC and surprise
120/// the user. Long forms with an offset are converted to the
121/// equivalent UTC instant before serialising, so the diff doesn't
122/// flip-flop against GitHub's canonical `…Z` representation (Copilot
123/// review on PR #92).
124pub fn normalize_due_on(s: &str) -> Result<String> {
125 let trimmed = s.trim();
126 if trimmed.is_empty() {
127 return Err(GwmError::Config("invalid due_on: empty string".into()));
128 }
129 // Short form: exactly `YYYY-MM-DD` (10 chars). chrono's strict
130 // parser catches `2026-02-30` and friends.
131 if trimmed.len() == 10 {
132 let date = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
133 .map_err(|e| GwmError::Config(format!("invalid due_on '{}': {}", trimmed, e)))?;
134 return Ok(format!("{}T23:59:59Z", date.format("%Y-%m-%d")));
135 }
136 // Long form: full RFC3339. Convert any offset to UTC and emit `…Z`.
137 // GitHub serialises `due_on` as `…Z`; without canonicalisation a
138 // user-supplied `+00:00` (or `+02:00`) would surface as a perpetual
139 // mismatch in `diff_milestones` and `gwm milestones push` would
140 // issue no-op updates on every run.
141 let dt = DateTime::parse_from_rfc3339(trimmed)
142 .map_err(|e| GwmError::Config(format!("invalid due_on '{}': {}", trimmed, e)))?;
143 Ok(dt.with_timezone(&Utc).format("%Y-%m-%dT%H:%M:%SZ").to_string())
144}
145
146/// Strict lowercase parse — `open` or `closed`. GitHub stores the
147/// value lowercase, so accepting `Open` / `OPEN` would mask a typo on
148/// the user side without any upside.
149pub fn parse_state(s: &str) -> Result<MilestoneState> {
150 match s {
151 "open" => Ok(MilestoneState::Open),
152 "closed" => Ok(MilestoneState::Closed),
153 other => Err(GwmError::Config(format!(
154 "invalid milestone state '{}': expected 'open' or 'closed'",
155 other
156 ))),
157 }
158}
159
160// --- Resolve MilestoneConfig → MilestoneSpec ----------------------------
161
162/// Materialise a list of declared `[[milestones]]` entries into
163/// concrete `MilestoneSpec` values. Defaults `state` to `Open`,
164/// normalises `due_on` to RFC3339, and collapses an empty
165/// `description` to `None` (same contract as labels).
166pub fn resolve_milestones(declared: &[MilestoneConfig]) -> Result<Vec<MilestoneSpec>> {
167 declared
168 .iter()
169 .map(|m| {
170 let state = match &m.state {
171 Some(s) => {
172 parse_state(s).map_err(|e| GwmError::Config(format!("milestone '{}' has invalid state: {}", m.title, e)))?
173 }
174 None => MilestoneState::Open,
175 };
176 let due_on = match &m.due_on {
177 Some(s) => Some(
178 normalize_due_on(s)
179 .map_err(|e| GwmError::Config(format!("milestone '{}' has invalid due_on: {}", m.title, e)))?,
180 ),
181 None => None,
182 };
183 Ok(MilestoneSpec {
184 title: m.title.clone(),
185 description: m.description.clone().filter(|s| !s.is_empty()),
186 due_on,
187 state,
188 })
189 })
190 .collect()
191}
192
193// --- Diff declared vs remote --------------------------------------------
194
195/// Compute the diff between the user's declared milestone set and the
196/// milestones currently on the upstream remote. Pure function — no
197/// I/O, no observable side effects.
198///
199/// Comparison rules:
200/// - **Title** is the unique key. Matching is byte-exact, since
201/// GitHub treats `v0.7.0` and `V0.7.0` as different milestones.
202/// - **`due_on`** is compared after normalising both sides through
203/// `normalize_due_on` — defence in depth on top of
204/// [`crate::github::parse_milestones_json`], which already returns
205/// RFC3339, so a manually-constructed `RemoteMilestone` (e.g. from a
206/// fixture) with the short form doesn't slip through as a spurious
207/// update.
208/// - **`description`**: `None` and `Some("")` are equivalent (GitHub
209/// stores them interchangeably).
210/// - **`state`**: byte-exact `Open` / `Closed` comparison.
211pub fn diff_milestones(declared: &[MilestoneSpec], remote: &[RemoteMilestone]) -> MilestoneDiff {
212 let remote_by_title: HashMap<&str, &RemoteMilestone> = remote.iter().map(|r| (r.title.as_str(), r)).collect();
213 let declared_titles: HashSet<&str> = declared.iter().map(|s| s.title.as_str()).collect();
214
215 let mut to_create = Vec::new();
216 let mut to_update = Vec::new();
217 let mut matching = Vec::new();
218
219 for spec in declared {
220 match remote_by_title.get(spec.title.as_str()) {
221 None => to_create.push(spec.clone()),
222 Some(r) => {
223 let due_match = norm_due(&spec.due_on) == norm_due(&r.due_on);
224 let desc_match = norm_desc(&spec.description) == norm_desc(&r.description);
225 let state_match = spec.state == r.state;
226 if due_match && desc_match && state_match {
227 matching.push(spec.clone());
228 } else {
229 to_update.push(MilestoneUpdate {
230 action: MilestoneAction::Update,
231 spec: spec.clone(),
232 number: r.number,
233 previous_due_on: if due_match { None } else { r.due_on.clone() },
234 previous_description: if desc_match { None } else { r.description.clone() },
235 previous_state: if state_match { None } else { Some(r.state) },
236 });
237 }
238 }
239 }
240 }
241
242 let extra_on_remote: Vec<RemoteMilestone> = remote
243 .iter()
244 .filter(|r| !declared_titles.contains(r.title.as_str()))
245 .cloned()
246 .collect();
247
248 MilestoneDiff {
249 to_create,
250 to_update,
251 matching,
252 extra_on_remote,
253 }
254}
255
256fn norm_desc(d: &Option<String>) -> Option<String> {
257 d.as_ref().filter(|s| !s.is_empty()).cloned()
258}
259
260/// Best-effort normalisation for comparison purposes. If `s` is the
261/// short form (10 chars), expand it; if it parses as RFC3339, accept
262/// it verbatim; if neither parses, fall back to the raw string so a
263/// fixture with a non-canonical value still compares deterministically
264/// against itself.
265fn norm_due(d: &Option<String>) -> Option<String> {
266 d.as_ref().map(|s| normalize_due_on(s).unwrap_or_else(|_| s.clone()))
267}