Skip to main content

plan_issue/commands/
tracking.rs

1//! `plan-issue tracking` subcommand surface.
2//!
3//! Owns the run-state controller commands (`status`, `run init`,
4//! `run update`, `checkpoint`, `close-ready`). The handlers live in
5//! [`crate::execute`] and the data shapes live in [`crate::tracking`].
6
7use std::path::PathBuf;
8
9use clap::{Args, Subcommand};
10use serde::Serialize;
11
12use crate::commands::record::RecordProfile;
13
14#[derive(Debug, Clone, Args, Serialize)]
15pub struct TrackingArgs {
16    #[command(subcommand)]
17    pub command: TrackingCommand,
18}
19
20#[derive(Debug, Clone, Subcommand, Serialize)]
21pub enum TrackingCommand {
22    /// Read active payload evidence + local run state and return the
23    /// reconciled FSM state without provider mutation. Old state payload
24    /// formats require one-off migration/repair.
25    #[command(
26        after_help = "State payload replacement policy: this command targets the active payload contract only. Old state payload formats require one-off migration/repair outside the main CLI; no long-term v2 reader or mixed old/new stream reconciliation is provided."
27    )]
28    Status(Box<TrackingStatusArgs>),
29
30    /// Manage a typed local run state (`run init`, `run update`).
31    Run(Box<TrackingRunArgs>),
32
33    /// Render or post checkpoint lifecycle comments derived from run state.
34    Checkpoint(Box<TrackingCheckpointArgs>),
35
36    /// Non-mutating close-readiness probe over the active payload contract.
37    /// Old state payload formats require one-off migration/repair.
38    #[command(name = "close-ready")]
39    #[command(
40        after_help = "State payload replacement policy: this command targets the active payload contract only. Old state payload formats require one-off migration/repair outside the main CLI; no long-term v2 reader or mixed old/new stream reconciliation is provided."
41    )]
42    CloseReady(Box<TrackingCloseReadyArgs>),
43}
44
45#[derive(Debug, Clone, Args, Serialize)]
46pub struct TrackingRunArgs {
47    #[command(subcommand)]
48    pub command: TrackingRunCommand,
49}
50
51#[derive(Debug, Clone, Subcommand, Serialize)]
52pub enum TrackingRunCommand {
53    /// Create or refresh a run-state.json document under the issue runtime
54    /// root.
55    Init(Box<TrackingRunInitArgs>),
56
57    /// Update a previously-initialized run-state.json without provider
58    /// mutation.
59    Update(Box<TrackingRunUpdateArgs>),
60}
61
62#[derive(Debug, Clone, Args, Serialize)]
63pub struct TrackingRunInitArgs {
64    /// Repository slug in `owner/repo` form.
65    #[arg(long = "provider-repo", value_name = "owner/repo")]
66    pub provider_repo: String,
67
68    /// Issue number.
69    #[arg(long, value_name = "number")]
70    pub issue: u64,
71
72    /// Lifecycle profile for the new run.
73    #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
74    pub profile: RecordProfile,
75
76    /// Plan bundle directory.
77    #[arg(long, value_name = "dir")]
78    pub bundle: Option<PathBuf>,
79
80    /// Canonical execution-state Markdown.
81    #[arg(long = "execution-state-file", value_name = "path")]
82    pub execution_state_file: Option<PathBuf>,
83
84    /// Selected task id.
85    #[arg(long, value_name = "id")]
86    pub task: Option<String>,
87
88    /// Selected sprint number.
89    #[arg(long, value_name = "number")]
90    pub sprint: Option<i32>,
91
92    /// Branch backing the run.
93    #[arg(long, value_name = "name")]
94    pub branch: Option<String>,
95
96    /// Worktree path.
97    #[arg(long, value_name = "path")]
98    pub worktree: Option<PathBuf>,
99
100    /// Linked PR reference (`owner/repo#number`).
101    #[arg(long = "linked-pr", value_name = "ref")]
102    pub linked_pr: Option<String>,
103
104    /// Override the generated `run_id`. Useful for deterministic tests.
105    #[arg(long = "run-id", value_name = "id")]
106    pub run_id: Option<String>,
107
108    /// Override the recorded timestamp (`created_at` / `updated_at`).
109    /// Defaults to the current UTC time; pass an explicit value for
110    /// deterministic tests/fixtures.
111    #[arg(long = "now", value_name = "rfc3339")]
112    pub now: Option<String>,
113
114    /// Write to this run-state path instead of the issue runtime root.
115    #[arg(long = "out", value_name = "path")]
116    pub out: Option<PathBuf>,
117}
118
119#[derive(Debug, Clone, Args, Serialize)]
120pub struct TrackingRunUpdateArgs {
121    /// Run-state path to mutate.
122    #[arg(long = "run-state", value_name = "path")]
123    pub run_state: PathBuf,
124
125    /// New phase. Optional.
126    #[arg(long, value_enum)]
127    pub phase: Option<RunPhaseArg>,
128
129    /// Update the selected task id.
130    #[arg(long = "selected-task", value_name = "id")]
131    pub selected_task: Option<String>,
132
133    /// Update the branch name.
134    #[arg(long, value_name = "name")]
135    pub branch: Option<String>,
136
137    /// Update the linked PR reference.
138    #[arg(long = "linked-pr", value_name = "ref")]
139    pub linked_pr: Option<String>,
140
141    /// Validation overall status update (`pass|partial|fail`).
142    #[arg(long = "validation-overall", value_name = "status")]
143    pub validation_overall: Option<String>,
144
145    /// Validation command row update.
146    #[arg(long = "validation-command", value_name = "command")]
147    pub validation_command: Option<String>,
148
149    /// Validation command status update.
150    #[arg(long = "validation-status", value_name = "status")]
151    pub validation_status: Option<String>,
152
153    /// Validation evidence path.
154    #[arg(long = "validation-evidence", value_name = "path")]
155    pub validation_evidence: Option<String>,
156
157    /// Review decision (`approve|request-changes|comments-only`).
158    #[arg(long = "review-decision", value_name = "decision")]
159    pub review_decision: Option<String>,
160
161    /// Review lens. Repeat to record multiple lenses.
162    #[arg(long = "review-lens", value_name = "lens")]
163    pub review_lens: Vec<String>,
164
165    /// Review outcome comment URL or retained evidence path.
166    #[arg(long = "review-outcome-comment", value_name = "url-or-path")]
167    pub review_outcome_comment: Option<String>,
168
169    /// JSON file containing review finding rows.
170    #[arg(long = "review-findings-file", value_name = "path")]
171    pub review_findings_file: Option<PathBuf>,
172
173    /// Free-form note appended to `notes`.
174    #[arg(long, value_name = "text")]
175    pub note: Option<String>,
176
177    /// Override the recorded `updated_at` timestamp.
178    /// Defaults to the current UTC time; pass an explicit value for
179    /// deterministic tests/fixtures.
180    #[arg(long = "now", value_name = "rfc3339")]
181    pub now: Option<String>,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
185pub enum RunPhaseArg {
186    Initial,
187    Implementing,
188    Validating,
189    Reviewing,
190    Blocked,
191    ReadyForClose,
192    Closed,
193}
194
195impl RunPhaseArg {
196    pub fn as_str(self) -> &'static str {
197        match self {
198            Self::Initial => "initial",
199            Self::Implementing => "implementing",
200            Self::Validating => "validating",
201            Self::Reviewing => "reviewing",
202            Self::Blocked => "blocked",
203            Self::ReadyForClose => "ready_for_close",
204            Self::Closed => "closed",
205        }
206    }
207}
208
209#[derive(Debug, Clone, Args, Serialize)]
210pub struct TrackingCheckpointArgs {
211    /// Repository slug for live mode.
212    #[arg(long = "provider-repo", value_name = "owner/repo")]
213    pub provider_repo: Option<String>,
214
215    /// Issue number for live mode.
216    #[arg(long, value_name = "number")]
217    pub issue: Option<u64>,
218
219    /// Lifecycle profile.
220    #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
221    pub profile: RecordProfile,
222
223    /// Run-state path.
224    #[arg(long = "run-state", value_name = "path")]
225    pub run_state: PathBuf,
226
227    /// Comma-separated lifecycle roles to render (`state,session,validation,review`).
228    #[arg(long, value_name = "roles", default_value = "state")]
229    pub post: String,
230
231    /// Always repair the dashboard after checkpoint posting.
232    #[arg(long = "repair-dashboard", default_value_t = false)]
233    pub repair_dashboard: bool,
234
235    /// Fixture directory for deterministic issue evidence.
236    #[arg(long, value_name = "dir")]
237    pub fixture: Option<PathBuf>,
238
239    /// Body file (deterministic mode).
240    #[arg(long = "body-file", value_name = "path")]
241    pub body_file: Option<PathBuf>,
242
243    /// Comments JSON file (deterministic mode).
244    #[arg(long = "comments-json", value_name = "path")]
245    pub comments_json: Option<PathBuf>,
246
247    /// Opt into live mutation. Without this flag, `tracking checkpoint`
248    /// renders the planned comments but never mutates the provider issue.
249    /// With `--live`, the controller posts one lifecycle comment per role
250    /// listed in `--post` (one comment per role, mirroring `record post`
251    /// semantics), preserving declaration order. On the first per-role
252    /// failure it stops and returns the already-posted URLs alongside a
253    /// `tracking-checkpoint-live-post-failed` blocker so the caller can
254    /// decide whether to retry. Combine with `--repair-dashboard` to
255    /// refresh the issue body after all roles post successfully (skipped
256    /// on partial failure). Combine with `--fixture <dir>` to exercise
257    /// the post path deterministically without provider mutation.
258    #[arg(long = "live", default_value_t = false)]
259    pub live: bool,
260
261    /// Run the visible-completeness lint against rendered bodies.
262    #[arg(long = "expect-visible", default_value_t = true)]
263    pub expect_visible: bool,
264
265    /// Write rendered comment bodies under this directory instead of the
266    /// run-state `rendered/` subtree.
267    #[arg(long = "rendered-out", value_name = "dir")]
268    pub rendered_out: Option<PathBuf>,
269}
270
271#[derive(Debug, Clone, Args, Serialize)]
272pub struct TrackingCloseReadyArgs {
273    /// Repository slug.
274    #[arg(long = "provider-repo", value_name = "owner/repo")]
275    pub provider_repo: Option<String>,
276
277    /// Issue number.
278    #[arg(long, value_name = "number")]
279    pub issue: Option<u64>,
280
281    /// Lifecycle profile.
282    #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
283    pub profile: RecordProfile,
284
285    /// Run-state path.
286    #[arg(long = "run-state", value_name = "path")]
287    pub run_state: Option<PathBuf>,
288
289    /// Linked PR reference. Repeatable.
290    #[arg(long = "linked-pr", value_name = "ref")]
291    pub linked_pr: Vec<String>,
292
293    /// Approval evidence (URL or text).
294    #[arg(long, value_name = "text")]
295    pub approval: Option<String>,
296
297    /// Fixture directory.
298    #[arg(long, value_name = "dir")]
299    pub fixture: Option<PathBuf>,
300
301    /// Body file.
302    #[arg(long = "body-file", value_name = "path")]
303    pub body_file: Option<PathBuf>,
304
305    /// Comments JSON file.
306    #[arg(long = "comments-json", value_name = "path")]
307    pub comments_json: Option<PathBuf>,
308
309    /// Run the visible-completeness lint before reporting ready.
310    #[arg(long = "expect-visible", default_value_t = true)]
311    pub expect_visible: bool,
312}
313
314#[derive(Debug, Clone, Args, Serialize)]
315pub struct TrackingStatusArgs {
316    /// Repository in `owner/repo` form. Required for live mode.
317    #[arg(long, value_name = "owner/repo")]
318    pub provider_repo: Option<String>,
319
320    /// Issue number. Required when reading live provider evidence.
321    #[arg(long, value_name = "number")]
322    pub issue: Option<u64>,
323
324    /// Lifecycle profile filter. Defaults to `tracking`.
325    #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
326    pub profile: RecordProfile,
327
328    /// Provider issue body Markdown for deterministic mode.
329    #[arg(long = "body-file", value_name = "path")]
330    pub body_file: Option<PathBuf>,
331
332    /// JSON containing the issue comments (deterministic mode).
333    #[arg(long = "comments-json", value_name = "path")]
334    pub comments_json: Option<PathBuf>,
335
336    /// Fixture directory containing `body.md` and `comments.json`.
337    #[arg(long, value_name = "dir")]
338    pub fixture: Option<PathBuf>,
339
340    /// Local `run-state.json` path.
341    #[arg(long = "run-state", value_name = "path")]
342    pub run_state: Option<PathBuf>,
343
344    /// Plan bundle directory used to validate execution-state metadata.
345    #[arg(long, value_name = "dir")]
346    pub bundle: Option<PathBuf>,
347
348    /// Also run the visible-completeness lint against the latest comment
349    /// body per role.
350    #[arg(long = "expect-visible", default_value_t = false)]
351    pub expect_visible: bool,
352}
353
354#[cfg(test)]
355mod tests {
356    use super::RunPhaseArg;
357    use pretty_assertions::assert_eq;
358
359    /// `RunPhaseArg::as_str` is the run-state JSON `phase` contract (see
360    /// `execute.rs`). Pin every variant's snake_case wire value so a
361    /// renamed or reordered arm cannot silently change emitted run state.
362    #[test]
363    fn run_phase_arg_as_str_matches_wire_contract() {
364        assert_eq!(RunPhaseArg::Initial.as_str(), "initial");
365        assert_eq!(RunPhaseArg::Implementing.as_str(), "implementing");
366        assert_eq!(RunPhaseArg::Validating.as_str(), "validating");
367        assert_eq!(RunPhaseArg::Reviewing.as_str(), "reviewing");
368        assert_eq!(RunPhaseArg::Blocked.as_str(), "blocked");
369        assert_eq!(RunPhaseArg::ReadyForClose.as_str(), "ready_for_close");
370        assert_eq!(RunPhaseArg::Closed.as_str(), "closed");
371    }
372}