Skip to main content

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}