thirdshift 0.7.2

Turn a GitHub issue into a ready-for-review pull request, unattended
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
//! An Architect run up to its plan: an Architecture review of the Base
//! branch, the one its command named or else the one checked out in the
//! Launch directory, from the Launch directory with no Issue URL, then the
//! checks on the Architect plan it published and the label change that marks
//! it ready and labels it `architect-plan`, for the command to stop at or to
//! dispatch. A review that found no Strong candidate published no plan, and
//! the Architect run ends on the issue it named instead: the idea issue it
//! filed for its top recommendation, or the open issue that already covers
//! it. Only one Architect run or Pickup run per repository runs at a time on
//! a machine: an Architect run started while another of either is still
//! running is skipped, before any review. So is one that finds an Architect
//! plan still open on the repository: it never retries or dispatches an
//! Architect plan that is already there.

use std::fmt;
use std::path::{Path, PathBuf};

use anyhow::{Result, anyhow, bail};
use chrono::{DateTime, Utc};

use crate::command_log;
use crate::failed_run::FailedRun;
use crate::github;
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::labels::{Label, Labels, NEEDS_TRIAGE, READY_FOR_AGENT};
use crate::launch::{self, AlreadyRunning, Launch, Start};
use crate::plugin::Plugin;
use crate::progress;
use crate::prompt;
use crate::run;
use crate::session::{Logs, Sessions};
use crate::worktree::ReviewWorktree;

/// The Architecture review session's kind, in its progress lines and log
/// name.
const REVIEW: &str = "architecture-review";

/// The label thirdshift marks an Architect plan with, which a later Architect
/// run finds an open one by.
const ARCHITECT_PLAN: Label = Label::new(
    "architect-plan",
    "An Architect plan: the Spec or Ticket an Architecture review published",
);

/// How an Architect run ended, short of a failure and before any dispatch.
/// Its `Display` is the line that says how it ended.
#[derive(Debug)]
pub enum Outcome {
    /// Skipped, before any review, having done nothing.
    Skipped(Skipped),
    /// Its Architecture review ended on an issue.
    Reviewed(Reviewed),
}

impl Outcome {
    /// How the Architect run ended, as its Run notification's subject says
    /// it when nothing was dispatched.
    pub fn name(&self) -> &'static str {
        match self {
            Self::Skipped(_) => "skipped",
            Self::Reviewed(reviewed) => reviewed.review(),
        }
    }

    /// The URLs of the issues the Architect run ended on, as its stdout
    /// carries them when nothing was dispatched: the issue its Architecture
    /// review ended on, or each open Architect plan it was skipped for. None
    /// when it was skipped as another, or a Pickup run, is still running.
    pub fn urls(&self) -> Vec<&str> {
        match self {
            Self::Skipped(Skipped::AlreadyRunning(_)) => Vec::new(),
            Self::Skipped(Skipped::OpenPlans(plans)) => {
                plans.iter().map(|(plan, _)| plan.url.as_str()).collect()
            }
            Self::Reviewed(reviewed) => vec![reviewed.url()],
        }
    }
}

impl fmt::Display for Outcome {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Skipped(skipped) => skipped.fmt(f),
            Self::Reviewed(reviewed) => reviewed.fmt(f),
        }
    }
}

/// Why an Architect run was skipped. Its `Display` is the reason, as the
/// skipped run's progress line and its Run notification give it.
#[derive(Debug)]
pub enum Skipped {
    /// Another Architect run on this repository, or a Pickup run, is still
    /// running on this machine, the Spec run or Run it dispatched included.
    AlreadyRunning(AlreadyRunning),
    /// These Architect plans, each with its title, are still open on this
    /// repository: at least one.
    OpenPlans(Vec<(IssueUrl, String)>),
}

impl fmt::Display for Skipped {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::AlreadyRunning(running) => running.fmt(f),
            Self::OpenPlans(plans) => {
                let still_open: Vec<String> = plans
                    .iter()
                    .map(|(plan, title)| {
                        format!(
                            "Architect plan #{} \"{title}\" is still open: pick it up with thirdshift {}",
                            plan.number, plan.url
                        )
                    })
                    .collect();
                f.write_str(&still_open.join("; "))
            }
        }
    }
}

/// How an Architecture review ended, short of a failure, with the issue it
/// ended on. Its `Display` is the line that says how it ended, naming that
/// issue.
#[derive(Debug)]
pub enum Reviewed {
    /// The plan the review published is marked ready, with the Architect
    /// run's Base branch, which the run the plan is dispatched as takes.
    PlanReady { plan: IssueUrl, base: String },
    /// The review found no Strong candidate, and filed its top
    /// recommendation as this issue.
    IdeaFiled(IssueUrl),
    /// The review found no Strong candidate, and filed nothing: this open
    /// issue already covers its top recommendation.
    AlreadyFiled(IssueUrl),
}

impl Reviewed {
    /// How the Architecture review ended, as the Architect run's Run
    /// notification says it.
    pub fn review(&self) -> &'static str {
        match self {
            Self::PlanReady { .. } => "plan published",
            Self::IdeaFiled(_) => "idea filed",
            Self::AlreadyFiled(_) => "idea already filed",
        }
    }

    /// The URL of the issue the Architecture review ended on.
    pub fn url(&self) -> &str {
        match self {
            Self::PlanReady { plan: issue, .. }
            | Self::IdeaFiled(issue)
            | Self::AlreadyFiled(issue) => &issue.url,
        }
    }
}

impl fmt::Display for Reviewed {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let url = self.url();
        match self {
            Self::PlanReady { .. } => write!(f, "plan {url} is ready for an agent"),
            Self::IdeaFiled(_) => write!(
                f,
                "no Strong candidate: the Architecture review filed the idea {url}"
            ),
            Self::AlreadyFiled(_) => write!(
                f,
                "no Strong candidate: {url} already covers the Architecture review's top recommendation, so it filed nothing"
            ),
        }
    }
}

/// Run an Architecture review of the Base branch, pointed at `focus` if
/// given, and mark the plan it publishes ready. The Base branch is `base`,
/// the branch the command named, whatever the Launch directory has checked
/// out, or without one the branch checked out there. A review that reports
/// an idea issue it filed, or the open issue that already covers its top
/// recommendation, instead of a plan, changes no label. With `launch_pull`,
/// the Launch directory's checkout of the Base branch, if that is the branch
/// checked out, is first brought up to date with origin. The review's
/// worktree and the plugin directory are gone when this returns. A failure
/// after the plan is published leaves its labels as the review left them.
///
/// Once the preflight checks pass, and before anything else, the Architect
/// run is skipped, with nothing done, if another on the same repository, or a
/// Pickup run, is still running on this machine: see [`launch::start`]. It is
/// then skipped, likewise, if the repository has an open Architect plan: only
/// then, so that the plan of an Architect run still running is never taken
/// for an unfinished one. Past both, it keeps its Command log under
/// `logs_dir`, where its Session logs go too.
pub fn run(
    focus: Option<&str>,
    base: Option<&str>,
    logs_dir: &Path,
    launch_pull: bool,
) -> Result<Outcome, FailedRun> {
    let started = Utc::now();
    let Launch {
        git: launch,
        origin,
        repo,
        checked_out,
        base,
    } = match launch::start(base)? {
        Start::Clear(launch) => launch,
        Start::AlreadyRunning(running) => {
            return Ok(Outcome::Skipped(Skipped::AlreadyRunning(running)));
        }
    };
    let open_plans = github::open_issues_labelled(&repo.slug(), ARCHITECT_PLAN)?;
    if !open_plans.is_empty() {
        let open_plans = open_plans.into_iter().map(|plan| (plan.issue, plan.title));
        return Ok(Outcome::Skipped(Skipped::OpenPlans(open_plans.collect())));
    }
    command_log::keep(command_log::of_architect_run(logs_dir, &repo));
    if launch_pull {
        run::pull_base_branch(&launch, checked_out.as_deref(), &base);
    }

    if interrupt::requested() {
        return Err(anyhow!("interrupted").into());
    }
    let worktree = ReviewWorktree::create(&launch, &repo.name, &base)?;
    let logs = Logs::of_architect_run(&repo, logs_dir);
    let mut log = logs.path(REVIEW);
    review(worktree, &base, focus, &origin, started, &logs, &mut log)
        .map(Outcome::Reviewed)
        .map_err(|error| FailedRun {
            log: log.exists().then_some(log),
            ..FailedRun::from(error)
        })
}

/// The Architecture review session in `worktree`, of the Base branch `base`,
/// then [`conclude`] on its final message. The worktree is removed once the
/// review is concluded. `log` is left at the most recent session's log.
fn review(
    worktree: ReviewWorktree,
    base: &str,
    focus: Option<&str>,
    origin: &str,
    started: DateTime<Utc>,
    logs: &Logs,
    log: &mut PathBuf,
) -> Result<Reviewed> {
    let plugin = Plugin::write()?;
    match focus {
        Some(focus) => progress::step(format_args!(
            "starting the Architecture review of {base}, focused on: {focus}"
        )),
        None => progress::step(format_args!("starting the Architecture review of {base}")),
    }
    Sessions::within(logs, worktree.path(), plugin.path(), |sessions| {
        let prompt = prompt::architecture_review(base, focus);
        let final_message = sessions.run_to_final_message(REVIEW, &prompt, log)?;
        conclude(final_message.as_deref(), origin, started, base)
    })
}

/// What an Architecture review reported in the last line of its final
/// message.
#[derive(Debug, PartialEq, Eq)]
enum Report {
    /// The plan it published: a Spec, or a single Ticket.
    Plan(IssueUrl),
    /// The issue it filed for a top recommendation that is not Strong.
    Idea(IssueUrl),
    /// The open issue that already covers that recommendation.
    AlreadyFiled(IssueUrl),
}

impl Report {
    /// Read the last line of `final_message` that isn't blank. `None` unless
    /// it is one of the lines the Architecture review prompt asks for, with
    /// an Issue URL and nothing else after it.
    fn read(final_message: &str) -> Option<Self> {
        let line = final_message
            .lines()
            .map(str::trim)
            .rfind(|line| !line.is_empty())?;
        let naming = |start: &str, kind: fn(IssueUrl) -> Report| {
            let url = line.strip_prefix(start)?;
            IssueUrl::parse(url).ok().map(kind)
        };
        naming(prompt::PLAN_LINE, Report::Plan)
            .or_else(|| naming(prompt::IDEA_LINE, Report::Idea))
            .or_else(|| naming(prompt::ALREADY_FILED_LINE, Report::AlreadyFiled))
    }
}

/// End the Architecture review, of the Base branch `base`, on the issue the
/// last line of its `final_message` names: a plan is marked ready, and an
/// idea issue, or the issue that already covers the top recommendation, is
/// left as it is. Fails if the session had no final message, if its last line
/// is not one the prompt asks for, or if the plan can't be marked ready.
fn conclude(
    final_message: Option<&str>,
    origin: &str,
    started: DateTime<Utc>,
    base: &str,
) -> Result<Reviewed> {
    match final_message.and_then(Report::read) {
        Some(Report::Plan(plan)) => {
            mark_plan_ready(&plan, origin, started)?;
            let base = base.to_string();
            Ok(Reviewed::PlanReady { plan, base })
        }
        Some(Report::Idea(idea)) => Ok(Reviewed::IdeaFiled(idea)),
        Some(Report::AlreadyFiled(issue)) => Ok(Reviewed::AlreadyFiled(issue)),
        None => bail!("the Architecture review ended without the final line its prompt asks for"),
    }
}

/// Mark `plan` ready: check it, then swap its `needs-triage` for
/// `ready-for-agent` and label it `architect-plan`, in one request, having
/// added that label to the repository if it lacks it. Fails, changing no
/// label, if the plan fails its checks.
fn mark_plan_ready(plan: &IssueUrl, origin: &str, started: DateTime<Utc>) -> Result<()> {
    progress::step(format_args!(
        "the Architecture review published the plan {}",
        plan.url
    ));
    let kept = labels_to_keep(plan, origin, started)?;
    if interrupt::requested() {
        bail!("interrupted");
    }
    progress::step(format_args!(
        "marking the plan ready: swapping {NEEDS_TRIAGE} for {READY_FOR_AGENT} and adding {ARCHITECT_PLAN} on #{}",
        plan.number
    ));
    github::ensure_labels(&plan.repo_slug(), &[ARCHITECT_PLAN])?;
    github::set_labels(plan, &kept.swapped(&[], &[READY_FOR_AGENT, ARCHITECT_PLAN]))
}

/// The labels `plan` keeps once it is marked ready: all it has but
/// `needs-triage`. Fails unless the plan passes its checks: it is in the
/// repository at `origin`, open, created since the Architect run `started`,
/// and has no other label that makes an Unready Ticket.
fn labels_to_keep(plan: &IssueUrl, origin: &str, started: DateTime<Utc>) -> Result<Labels> {
    if !plan.matches_origin(origin) {
        bail!(
            "the plan {} is not in the repository at origin {origin}",
            plan.url
        );
    }
    let issue = github::issue(plan)?;
    if !issue.is_open {
        bail!("the plan {} is closed", plan.url);
    }
    // GitHub's times are to the second.
    if issue.created.timestamp() < started.timestamp() {
        bail!(
            "the plan {} was created before this Architect run started",
            plan.url
        );
    }
    let kept = issue.labels.swapped(&[NEEDS_TRIAGE], &[]);
    if let Some(unready) = kept.unready() {
        bail!("the plan {} is labelled {unready}", plan.url);
    }
    Ok(kept)
}

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

    const URL: &str = "https://github.com/acme/widgets/issues/8";

    fn issue() -> IssueUrl {
        IssueUrl::parse(URL).unwrap()
    }

    #[test]
    fn the_report_is_the_last_line_whichever_of_the_three_it_is() {
        for (line, report) in [
            ("Architecture review plan: ", Report::Plan(issue())),
            ("Architecture review idea: ", Report::Idea(issue())),
            (
                "Architecture review already filed: ",
                Report::AlreadyFiled(issue()),
            ),
        ] {
            let message = format!("Reviewed the codebase.\n\n{line}{URL}\n\n");
            assert_eq!(Report::read(&message), Some(report), "{line}");
        }
    }

    #[test]
    fn a_final_message_without_the_line_as_asked_for_reports_nothing() {
        for message in [
            "",
            "Published the plan.",
            "Architecture review plan: https://github.com/acme/widgets/pull/8",
            "Architecture review plan: #8",
            "Architecture review plan: https://github.com/acme/widgets/issues/8 (a Spec)",
            "The plan: https://github.com/acme/widgets/issues/8",
            "Architecture review plan: https://github.com/acme/widgets/issues/8\nThat's all.",
        ] {
            assert_eq!(Report::read(message), None, "{message:?}");
        }
    }
}