rigger 0.24.0

One seat for all your projects and tasks: a local record of what is done, what is next and when it ships - read by you and your coding assistant
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
//! What git says, against what the record claims.
//!
//! Until now "what is done" depended on somebody writing it down. A stage
//! could be marked closed in a plan whose tag was never pushed, and a tag
//! could exist for a version the plan still listed as open - and nothing
//! noticed, because the plan was the only place anyone looked.
//!
//! So git is read directly: a tag matching `v*` means that version shipped,
//! on the date of the commit it points at, and commits since the newest tag
//! are the project's activity. Where the plan and git disagree, the record
//! follows git for the fact it can prove - a tag - and reports the rest
//! rather than correcting it silently (ADR 0005).
//!
//! Reading happens in-process through gix; `git` is never spawned. A shelled
//! command would cost a process per project, depend on git being installed
//! and on its output format, and inherit whatever the caller's environment
//! did to it.

use anyhow::{Context, Result};
use serde::Serialize;

use crate::db::{Change, Db, Project, version_order};
use crate::delivery::{Delivery, Unread};

/// What one project's repository says, and what changed in the record.
#[derive(Debug, Default, Serialize)]
pub struct Report {
    pub project: String,
    /// Versions the record now knows shipped, because a tag proves it.
    pub shipped: Vec<Shipped>,
    /// Tags whose version the plan never mentioned.
    pub unplanned: Vec<String>,
    /// Versions the plan closed without a tag to show for it.
    pub untagged: Vec<String>,
    pub commits_since_tag: u32,
    pub last_commit_at: Option<String>,
    /// Changes read from commit messages that the record did not have.
    pub changes_recorded: u32,
    /// Said rather than failed: a project can be recorded before its
    /// repository exists, and a hub can be imported from a directory that
    /// was never a checkout.
    pub warnings: Vec<String>,
    /// What GitHub says about the newest shipped version, when it was asked.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub delivery: Option<DeliveryNote>,
    /// Cards whose names this repository's branches or commits carry.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub cards: Vec<crate::db::CardRead>,
}

/// How far the newest shipped version got past its tag, as GitHub told it.
#[derive(Debug, Serialize)]
pub struct DeliveryNote {
    pub version: String,
    pub state: Delivery,
    /// Whether this run is what learnt it.
    pub newly: bool,
}

#[derive(Debug, Serialize)]
pub struct Shipped {
    pub version: String,
    pub date: String,
    /// Whether this run is what closed it.
    pub newly: bool,
}

impl Report {
    pub fn changed(&self) -> bool {
        self.shipped.iter().any(|s| s.newly)
            || !self.unplanned.is_empty()
            || self.changes_recorded > 0
            || self.delivery.as_ref().is_some_and(|d| d.newly)
            || self.cards.iter().any(|c| c.linked_now || c.new_commits > 0)
    }

    /// Whether GitHub said something the owner has to hear: a version the
    /// calendar counts as shipped that nobody can install.
    pub fn short_delivery(&self) -> Option<&DeliveryNote> {
        self.delivery.as_ref().filter(|d| d.state.is_short())
    }
}

/// Asks GitHub how far the newest shipped version got, and records it.
///
/// Only the newest: the question is whether the release that counts now
/// reached anyone, and asking about every tag of every project would spend
/// a sync on history nobody is going to act on. A version the engine
/// reported, or one already seen published, is not asked about again -
/// the first because the engine outranks a look, the second because
/// nothing un-publishes a crate.
///
/// `Ok(None)` when there was nothing to ask: no GitHub remote, nothing
/// shipped, or a repository that makes no releases at all.
///
/// GitHub is asked about the tag, spelt as the tag is: `tags` are the ones
/// the repository has. A plan writes `v1.13` where the tag says `v1.13.0`,
/// the record matches the two by value, and GitHub does not - asked about
/// the plan's spelling, it answered "no release" for a version released the
/// day before. Found on the first run over the real line.
pub fn read_delivery(db: &Db, project: &Project, tags: &[&str]) -> Result<Result<Option<DeliveryNote>, Unread>> {
    let Some(repo) = project.remote.as_deref().and_then(crate::delivery::github_repo) else {
        return Ok(Ok(None));
    };
    let Some(latest) = db.latest_delivery(project.id)? else {
        return Ok(Ok(None));
    };
    if latest.by.as_deref() == Some("engine") || latest.delivery == Some(Delivery::Published) {
        return Ok(Ok(None));
    }
    let wanted = version_order(&latest.version);
    let tag = tags.iter().find(|t| version_order(t) == wanted).copied().unwrap_or(&latest.version);
    let state = match crate::delivery::read(&repo, tag) {
        Ok(Some(state)) => state,
        Ok(None) => return Ok(Ok(None)),
        Err(unread) => return Ok(Err(unread)),
    };
    let change = db.set_delivery(latest.id, state, &[], "github")?;
    Ok(Ok(Some(DeliveryNote {
        version: latest.version,
        state,
        newly: change != Change::Unchanged,
    })))
}

/// A tag that names a version, and the day its commit was made.
struct Tag {
    version: String,
    date: String,
    /// The commit's whole timestamp. The day is what a changelog speaks in,
    /// but this line ships several versions on one day, so the moment is
    /// what tells their work apart.
    moment: String,
}

pub fn sync(db: &Db, project: &Project) -> Result<Report> {
    let mut report = Report {
        project: project.name.clone(),
        ..Report::default()
    };

    let repo = match gix::open(&project.path) {
        Ok(repo) => repo,
        Err(err) => {
            report
                .warnings
                .push(format!("{} is not a git repository rigger can read ({err})", project.path));
            return Ok(report);
        }
    };

    // Taken before anything is written: once a tag has been recorded, its
    // version is in the record, and "did the plan know about this?" can no
    // longer be asked.
    let known_before = db.version_names(project.id)?;

    let tags = read_tags(&repo)?;
    for tag in &tags {
        // A tag is proof the version shipped; the plan does not get a vote.
        let change = db.mark_shipped(project.id, &tag.version, &tag.date, &tag.moment)?;
        match change {
            Change::Unchanged => report.shipped.push(Shipped {
                version: tag.version.clone(),
                date: tag.date.clone(),
                newly: false,
            }),
            Change::Updated => report.shipped.push(Shipped {
                version: tag.version.clone(),
                date: tag.date.clone(),
                newly: true,
            }),
            // No such version in the plan: the tag is a fact all the same,
            // so it is recorded. Whether that is worth reporting depends on
            // where it falls - see `unplanned` below.
            Change::Added => report.shipped.push(Shipped {
                version: tag.version.clone(),
                date: tag.date.clone(),
                newly: true,
            }),
        }
    }

    // A release the plan never mentioned is only news if the plan was
    // supposed to know about it. Every hub starts somewhere - kasl's plan
    // begins at v1.4 and its repository carries twenty-one releases from
    // 2024 - and calling all of those unplanned buries the one that matters
    // under the history of the project. So the floor is the oldest version
    // the plan does know, and only tags above it are reported.
    let floor = known_before.iter().map(|n| version_order(n)).min();
    for tag in &tags {
        if !known_before.iter().any(|k| version_order(k) == version_order(&tag.version)) && floor.is_none_or(|floor| version_order(&tag.version) > floor) {
            report.unplanned.push(tag.version.clone());
        }
    }

    // The other direction: closed in the plan, with no tag to show for it.
    // Left exactly as the owner wrote it - the record cannot prove a
    // negative, a tag may simply not have been fetched - but said out loud.
    let tagged: Vec<&str> = tags.iter().map(|t| t.version.as_str()).collect();
    for claimed in db.shipped_versions(project.id)? {
        if !tagged.iter().any(|t| version_order(t) == version_order(&claimed)) {
            report.untagged.push(claimed);
        }
    }

    let newest = tags.iter().max_by_key(|t| version_order(&t.version));
    let history = read_history(&repo, newest.map(|t| t.version.as_str()))?;
    report.commits_since_tag = history.commits_since_tag;
    report.last_commit_at = history.last_commit_at.clone();
    db.record_activity(project.id, history.commits_since_tag, history.last_commit_at.as_deref())?;

    // Changes are recorded oldest first, so that the record reads forwards
    // even though history is walked backwards.
    for change in history.changes.iter().rev() {
        // The timestamp is the commit's own: an event about a change that
        // landed in August must not date from the sync that read it, and a
        // change made after a tag must not sort before it.
        if db.record_commit_event(project.id, &change.hash, &change.body, &change.moment)? == Change::Added {
            report.changes_recorded += 1;
        }
    }

    // A record with no cards - a line of products - pays nothing for this.
    let cards = db.cards(None)?;
    if !cards.is_empty() {
        let seen = crate::activity::read(&repo, &cards)?;
        report.cards = db.record_card_activity(project.id, &seen)?;
    }

    Ok(report)
}

/// Tags that name a version, each with the day of the commit it points at.
///
/// The commit's date rather than the tag's own: a lightweight tag - which is
/// what `git tag v0.5.0` makes, and what this line uses - has no date of its
/// own at all, so the commit is the only date both kinds share.
fn read_tags(repo: &gix::Repository) -> Result<Vec<Tag>> {
    let mut tags = Vec::new();
    let refs = repo.references().context("cannot read the repository's references")?;
    for tag in refs.tags().context("cannot list tags")? {
        let mut tag = match tag {
            Ok(tag) => tag,
            // One unreadable ref must not cost the whole sync.
            Err(_) => continue,
        };
        let name = tag.name().shorten().to_string();
        if !names_a_version(&name) {
            continue;
        }
        // Peeling follows an annotated tag through to its commit; a
        // lightweight tag already is one.
        let Ok(id) = tag.peel_to_id() else { continue };
        let Ok(object) = repo.find_object(id) else { continue };
        let Ok(commit) = object.try_into_commit() else { continue };
        let Ok(time) = commit.time() else { continue };
        tags.push(Tag {
            version: name,
            date: day(time.seconds),
            moment: moment(time.seconds),
        });
    }
    Ok(tags)
}

/// Whether a tag names a version: `v` and then a digit.
///
/// Deliberately narrow. A repository carries tags that are not releases -
/// `latest`, `nightly`, a name someone tagged a branch point with - and
/// treating one of those as a version would put a phantom release in the
/// record and on the calendar.
fn names_a_version(name: &str) -> bool {
    name.strip_prefix('v').is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
}

/// One pass over history: what has landed since the newest release, and the
/// changes worth recording along the way.
///
/// One walk rather than two. The counting stops at the newest tag - the
/// answer is "work since the release", not the length of history - but the
/// reading goes further back, so that a project synced for the first time
/// arrives with its recent changes rather than with only what happened since
/// Friday.
struct History {
    commits_since_tag: u32,
    last_commit_at: Option<String>,
    changes: Vec<CommitChange>,
}

/// A commit that changed the product, ready to be recorded.
struct CommitChange {
    hash: String,
    body: String,
    /// The commit's whole timestamp. The day alone would place every change
    /// of a day at midnight, before any tag made that day - and `why` bounds
    /// a version's work by the moments its tags were made.
    moment: String,
}

/// How far back changes are read. Deep enough that a first sync brings a
/// project's recent history, shallow enough that syncing the whole line
/// stays a thing you run at the start of a session. Older changes are not
/// lost - they are in git, which is where a question about 2024 belongs.
const READ_DEPTH: u32 = 300;

fn read_history(repo: &gix::Repository, newest_tag: Option<&str>) -> Result<History> {
    let Ok(head) = repo.head_commit() else {
        // An empty repository: no commits, and nothing to say about them.
        return Ok(History {
            commits_since_tag: 0,
            last_commit_at: None,
            changes: Vec::new(),
        });
    };
    let last_commit_at = head.time().ok().map(|t| day(t.seconds));

    let boundary = newest_tag.and_then(|name| {
        let mut reference = repo.find_reference(&format!("refs/tags/{name}")).ok()?;
        reference.peel_to_id().ok().map(|id| id.detach())
    });

    let mut commits_since_tag = 0u32;
    let mut past_the_tag = false;
    let mut changes = Vec::new();
    let mut seen = 0u32;

    let walk = head.ancestors().all().context("cannot walk the history")?;
    for step in walk {
        let Ok(info) = step else { break };
        if Some(info.id) == boundary {
            past_the_tag = true;
        }
        if !past_the_tag {
            commits_since_tag += 1;
        }

        if let Some(change) = read_change(repo, info.id) {
            changes.push(change);
        }

        seen += 1;
        if seen >= READ_DEPTH {
            break;
        }
    }
    Ok(History {
        commits_since_tag,
        last_commit_at,
        changes,
    })
}

/// A commit, if its message says it changed the product.
fn read_change(repo: &gix::Repository, id: gix::ObjectId) -> Option<CommitChange> {
    let commit = repo.find_object(id).ok()?.try_into_commit().ok()?;
    let message = commit.message_raw().ok()?.to_string();
    let parsed = crate::commit::parse(&message)?;
    if !parsed.is_a_change() {
        return None;
    }
    Some(CommitChange {
        hash: id.to_hex().to_string(),
        body: parsed.body(),
        moment: commit.time().ok().map(|t| moment(t.seconds)).unwrap_or_default(),
    })
}

/// A UNIX timestamp whole, in UTC - the moment, not the day.
fn moment(seconds: i64) -> String {
    jiff::Timestamp::from_second(seconds).map(|t| t.to_string()).unwrap_or_default()
}

/// A UNIX timestamp as the day it fell on, in UTC.
fn day(seconds: i64) -> String {
    jiff::Timestamp::from_second(seconds)
        .map(|t| t.to_string().split('T').next().unwrap_or_default().to_string())
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn only_tags_that_name_a_version_are_releases() {
        assert!(names_a_version("v0.5.0"));
        assert!(names_a_version("v1.9"));
        // A repository carries tags that are not releases; treating one as a
        // version would put a phantom release on the calendar.
        assert!(!names_a_version("latest"));
        assert!(!names_a_version("nightly"));
        assert!(!names_a_version("v-final"));
        assert!(!names_a_version("release-1.0"));
    }

    #[test]
    fn a_timestamp_becomes_the_day_it_fell_on_in_utc() {
        // UTC, not the machine's zone: the same tag must date the same way
        // on every machine that reads it, and a release read here and abroad
        // cannot land on two different days of the calendar.
        assert_eq!(day(1788508019), "2026-09-04"); // 07:46 UTC
        assert_eq!(day(0), "1970-01-01");
        // Either side of midnight UTC, which is where a local zone would
        // silently shift the answer by a day.
        assert_eq!(day(1788479999), "2026-09-03"); // 23:59:59 UTC
        assert_eq!(day(1788480000), "2026-09-04"); // 00:00:00 UTC
    }
}