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    /// Free-form note appended to `notes`.
162    #[arg(long, value_name = "text")]
163    pub note: Option<String>,
164
165    /// Override the recorded `updated_at` timestamp.
166    /// Defaults to the current UTC time; pass an explicit value for
167    /// deterministic tests/fixtures.
168    #[arg(long = "now", value_name = "rfc3339")]
169    pub now: Option<String>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
173pub enum RunPhaseArg {
174    Initial,
175    Implementing,
176    Validating,
177    Reviewing,
178    Blocked,
179    ReadyForClose,
180    Closed,
181}
182
183impl RunPhaseArg {
184    pub fn as_str(self) -> &'static str {
185        match self {
186            Self::Initial => "initial",
187            Self::Implementing => "implementing",
188            Self::Validating => "validating",
189            Self::Reviewing => "reviewing",
190            Self::Blocked => "blocked",
191            Self::ReadyForClose => "ready_for_close",
192            Self::Closed => "closed",
193        }
194    }
195}
196
197#[derive(Debug, Clone, Args, Serialize)]
198pub struct TrackingCheckpointArgs {
199    /// Repository slug for live mode.
200    #[arg(long = "provider-repo", value_name = "owner/repo")]
201    pub provider_repo: Option<String>,
202
203    /// Issue number for live mode.
204    #[arg(long, value_name = "number")]
205    pub issue: Option<u64>,
206
207    /// Lifecycle profile.
208    #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
209    pub profile: RecordProfile,
210
211    /// Run-state path.
212    #[arg(long = "run-state", value_name = "path")]
213    pub run_state: PathBuf,
214
215    /// Comma-separated lifecycle roles to render (`state,session,validation,review`).
216    #[arg(long, value_name = "roles", default_value = "state")]
217    pub post: String,
218
219    /// Always repair the dashboard after checkpoint posting.
220    #[arg(long = "repair-dashboard", default_value_t = false)]
221    pub repair_dashboard: bool,
222
223    /// Fixture directory for deterministic issue evidence.
224    #[arg(long, value_name = "dir")]
225    pub fixture: Option<PathBuf>,
226
227    /// Body file (deterministic mode).
228    #[arg(long = "body-file", value_name = "path")]
229    pub body_file: Option<PathBuf>,
230
231    /// Comments JSON file (deterministic mode).
232    #[arg(long = "comments-json", value_name = "path")]
233    pub comments_json: Option<PathBuf>,
234
235    /// Opt into live mutation. Without this flag, `tracking checkpoint`
236    /// renders the planned comments but never mutates the provider issue.
237    /// With `--live`, the controller posts one lifecycle comment per role
238    /// listed in `--post` (one comment per role, mirroring `record post`
239    /// semantics), preserving declaration order. On the first per-role
240    /// failure it stops and returns the already-posted URLs alongside a
241    /// `tracking-checkpoint-live-post-failed` blocker so the caller can
242    /// decide whether to retry. Combine with `--repair-dashboard` to
243    /// refresh the issue body after all roles post successfully (skipped
244    /// on partial failure). Combine with `--fixture <dir>` to exercise
245    /// the post path deterministically without provider mutation.
246    #[arg(long = "live", default_value_t = false)]
247    pub live: bool,
248
249    /// Run the visible-completeness lint against rendered bodies.
250    #[arg(long = "expect-visible", default_value_t = true)]
251    pub expect_visible: bool,
252
253    /// Write rendered comment bodies under this directory instead of the
254    /// run-state `rendered/` subtree.
255    #[arg(long = "rendered-out", value_name = "dir")]
256    pub rendered_out: Option<PathBuf>,
257}
258
259#[derive(Debug, Clone, Args, Serialize)]
260pub struct TrackingCloseReadyArgs {
261    /// Repository slug.
262    #[arg(long = "provider-repo", value_name = "owner/repo")]
263    pub provider_repo: Option<String>,
264
265    /// Issue number.
266    #[arg(long, value_name = "number")]
267    pub issue: Option<u64>,
268
269    /// Lifecycle profile.
270    #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
271    pub profile: RecordProfile,
272
273    /// Run-state path.
274    #[arg(long = "run-state", value_name = "path")]
275    pub run_state: Option<PathBuf>,
276
277    /// Linked PR reference. Repeatable.
278    #[arg(long = "linked-pr", value_name = "ref")]
279    pub linked_pr: Vec<String>,
280
281    /// Approval evidence (URL or text).
282    #[arg(long, value_name = "text")]
283    pub approval: Option<String>,
284
285    /// Fixture directory.
286    #[arg(long, value_name = "dir")]
287    pub fixture: Option<PathBuf>,
288
289    /// Body file.
290    #[arg(long = "body-file", value_name = "path")]
291    pub body_file: Option<PathBuf>,
292
293    /// Comments JSON file.
294    #[arg(long = "comments-json", value_name = "path")]
295    pub comments_json: Option<PathBuf>,
296
297    /// Run the visible-completeness lint before reporting ready.
298    #[arg(long = "expect-visible", default_value_t = true)]
299    pub expect_visible: bool,
300}
301
302#[derive(Debug, Clone, Args, Serialize)]
303pub struct TrackingStatusArgs {
304    /// Repository in `owner/repo` form. Required for live mode.
305    #[arg(long, value_name = "owner/repo")]
306    pub provider_repo: Option<String>,
307
308    /// Issue number. Required when reading live provider evidence.
309    #[arg(long, value_name = "number")]
310    pub issue: Option<u64>,
311
312    /// Lifecycle profile filter. Defaults to `tracking`.
313    #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
314    pub profile: RecordProfile,
315
316    /// Provider issue body Markdown for deterministic mode.
317    #[arg(long = "body-file", value_name = "path")]
318    pub body_file: Option<PathBuf>,
319
320    /// JSON containing the issue comments (deterministic mode).
321    #[arg(long = "comments-json", value_name = "path")]
322    pub comments_json: Option<PathBuf>,
323
324    /// Fixture directory containing `body.md` and `comments.json`.
325    #[arg(long, value_name = "dir")]
326    pub fixture: Option<PathBuf>,
327
328    /// Local `run-state.json` path.
329    #[arg(long = "run-state", value_name = "path")]
330    pub run_state: Option<PathBuf>,
331
332    /// Plan bundle directory used to validate execution-state metadata.
333    #[arg(long, value_name = "dir")]
334    pub bundle: Option<PathBuf>,
335
336    /// Also run the visible-completeness lint against the latest comment
337    /// body per role.
338    #[arg(long = "expect-visible", default_value_t = false)]
339    pub expect_visible: bool,
340}
341
342#[cfg(test)]
343mod tests {
344    use super::RunPhaseArg;
345    use pretty_assertions::assert_eq;
346
347    /// `RunPhaseArg::as_str` is the run-state JSON `phase` contract (see
348    /// `execute.rs`). Pin every variant's snake_case wire value so a
349    /// renamed or reordered arm cannot silently change emitted run state.
350    #[test]
351    fn run_phase_arg_as_str_matches_wire_contract() {
352        assert_eq!(RunPhaseArg::Initial.as_str(), "initial");
353        assert_eq!(RunPhaseArg::Implementing.as_str(), "implementing");
354        assert_eq!(RunPhaseArg::Validating.as_str(), "validating");
355        assert_eq!(RunPhaseArg::Reviewing.as_str(), "reviewing");
356        assert_eq!(RunPhaseArg::Blocked.as_str(), "blocked");
357        assert_eq!(RunPhaseArg::ReadyForClose.as_str(), "ready_for_close");
358        assert_eq!(RunPhaseArg::Closed.as_str(), "closed");
359    }
360}