# Gitea PR Review CLI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Rust CLI that fetches a Gitea PR and outputs either Markdown or JSON, and can also render Markdown from a JSON file via subcommand. **Architecture:** Use a normalized internal document model (`PrReviewDocument`) as the only rendering input. The `fetch` subcommand retrieves raw API DTOs and normalizes them, then renders to Markdown or JSON. The `render-md` subcommand reads JSON into the same model and renders identical Markdown structure. **Tech Stack:** Rust 2024, `clap`, `serde`/`serde_json`, `reqwest` (blocking + rustls), `anyhow`, `thiserror`, `chrono`, `mockito`, `assert_cmd`, `tempfile` --- ## File Structure - Modify: `Cargo.toml` - Modify: `src/main.rs` - Create: `src/lib.rs` - Create: `src/cli.rs` - Create: `src/error.rs` - Create: `src/model.rs` - Create: `src/output.rs` - Create: `src/render/mod.rs` - Create: `src/render/markdown.rs` - Create: `src/render/json.rs` - Create: `src/gitea/mod.rs` - Create: `src/gitea/dto.rs` - Create: `src/gitea/client.rs` - Create: `src/normalize.rs` - Create: `tests/cli_parse_tests.rs` - Create: `tests/render_markdown_tests.rs` - Create: `tests/render_json_tests.rs` - Create: `tests/normalize_tests.rs` - Create: `tests/gitea_client_tests.rs` - Create: `tests/e2e_smoke_tests.rs` - Modify: `README.md` ### Task 1: Bootstrap crate layout and CLI parser **Files:** - Modify: `Cargo.toml` - Modify: `src/main.rs` - Create: `src/lib.rs` - Create: `src/cli.rs` - Create: `tests/cli_parse_tests.rs` - [ ] **Step 1: Write the failing CLI parse test** ```rust // tests/cli_parse_tests.rs use clap::Parser; use gitea_pr_review::cli::{Cli, Commands, OutputFormat}; #[test] fn parse_fetch_default_format() { let cli = Cli::try_parse_from(["gitea-pr-review", "fetch", "12"]).unwrap(); match cli.command { Commands::Fetch(args) => { assert_eq!(args.pr_index, 12); assert_eq!(args.format, OutputFormat::Markdown); assert!(args.out.is_none()); } _ => panic!("expected fetch"), } } #[test] fn parse_render_md_requires_input() { let cli = Cli::try_parse_from([ "gitea-pr-review", "render-md", "--in", "sample.json", ]) .unwrap(); match cli.command { Commands::RenderMd(args) => { assert_eq!(args.input.display().to_string(), "sample.json"); } _ => panic!("expected render-md"), } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --test cli_parse_tests -q` Expected: FAIL with unresolved import/module errors for `cli`. - [ ] **Step 3: Add dependencies and implement CLI types** ```toml # Cargo.toml [dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } [dev-dependencies] assert_cmd = "2.0" mockito = "1.7" tempfile = "3.10" ``` ```rust // src/cli.rs use clap::{Parser, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum OutputFormat { Markdown, Json, } #[derive(Debug, Parser)] #[command(name = "gitea-pr-review")] pub struct Cli { #[command(subcommand)] pub command: Commands, } #[derive(Debug, Subcommand)] pub enum Commands { Fetch(FetchArgs), RenderMd(RenderMdArgs), } #[derive(Debug, clap::Args)] pub struct FetchArgs { pub pr_index: i64, #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)] pub format: OutputFormat, #[arg(long)] pub out: Option, } #[derive(Debug, clap::Args)] pub struct RenderMdArgs { #[arg(long = "in")] pub input: PathBuf, #[arg(long)] pub out: Option, } ``` ```rust // src/lib.rs pub mod cli; pub mod error; pub mod gitea; pub mod model; pub mod normalize; pub mod output; pub mod render; ``` ```rust // src/main.rs use anyhow::Result; fn main() -> Result<()> { gitea_pr_review::run() } ``` - [ ] **Step 4: Add temporary `run()` entry and re-run tests** ```rust // append in src/lib.rs pub fn run() -> anyhow::Result<()> { Ok(()) } ``` Run: `cargo test --test cli_parse_tests -q` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add Cargo.toml src/main.rs src/lib.rs src/cli.rs tests/cli_parse_tests.rs git commit -m "feat: scaffold CLI with fetch and render-md subcommands" ``` ### Task 2: Define domain model and output writer (default stdout) **Files:** - Create: `src/model.rs` - Create: `src/output.rs` - Create: `src/error.rs` - Create: `tests/render_json_tests.rs` - Modify: `src/lib.rs` - [ ] **Step 1: Write failing JSON roundtrip test for model stability** ```rust // tests/render_json_tests.rs use gitea_pr_review::model::{CommentItem, CommentThread, DiffStat, FileStat, PrMeta, PrReviewDocument}; #[test] fn model_json_roundtrip() { let doc = PrReviewDocument { meta: PrMeta { repo: "org/repo".into(), pr_index: 9, title: "feat: x".into(), state: "open".into(), author: "alice".into(), base_branch: "main".into(), head_branch: "feature/x".into(), created_at: "2026-04-08T10:00:00Z".into(), updated_at: "2026-04-08T11:00:00Z".into(), merged_at: None, }, commits: vec![], diff_stat: DiffStat { files_changed: 1, additions: 2, deletions: 1, files: vec![FileStat { path: "src/main.rs".into(), additions: 2, deletions: 1 }], }, reviews: vec![], threads: vec![CommentThread { thread_id: "t1".into(), file_path: Some("src/main.rs".into()), line: Some(10), root_comment: CommentItem { id: 100, user: "reviewer".into(), created_at: "2026-04-08T12:00:00Z".into(), body: "hello".into(), }, replies: vec![], }], }; let encoded = serde_json::to_string(&doc).unwrap(); let decoded: PrReviewDocument = serde_json::from_str(&encoded).unwrap(); assert_eq!(decoded.meta.repo, "org/repo"); assert_eq!(decoded.threads[0].root_comment.body, "hello"); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --test render_json_tests -q` Expected: FAIL with missing `model` module/types. - [ ] **Step 3: Implement domain model and output writer** ```rust // src/model.rs use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrReviewDocument { pub meta: PrMeta, pub commits: Vec, pub diff_stat: DiffStat, pub reviews: Vec, pub threads: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrMeta { pub repo: String, pub pr_index: i64, pub title: String, pub state: String, pub author: String, pub base_branch: String, pub head_branch: String, pub created_at: String, pub updated_at: String, pub merged_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitItem { pub sha: String, pub short_sha: String, pub title: String, pub author: String, pub date: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiffStat { pub files_changed: usize, pub additions: i64, pub deletions: i64, pub files: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileStat { pub path: String, pub additions: i64, pub deletions: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReviewItem { pub id: i64, pub state: String, pub reviewer: String, pub submitted_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommentThread { pub thread_id: String, pub file_path: Option, pub line: Option, pub root_comment: CommentItem, pub replies: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommentItem { pub id: i64, pub user: String, pub created_at: String, pub body: String, } ``` ```rust // src/output.rs use std::io::Write; use std::path::Path; pub fn write_output(out: Option<&Path>, content: &str) -> anyhow::Result<()> { if let Some(path) = out { std::fs::write(path, content)?; } else { let mut stdout = std::io::stdout().lock(); stdout.write_all(content.as_bytes())?; } Ok(()) } ``` ```rust // src/error.rs use thiserror::Error; #[derive(Debug, Error)] pub enum AppError { #[error("missing required environment variable: {0}")] MissingEnv(&'static str), #[error("api error: {0}")] Api(String), #[error("invalid input json: {0}")] InvalidInputJson(String), } ``` - [ ] **Step 4: Wire modules and run tests** ```rust // src/lib.rs pub mod cli; pub mod error; pub mod gitea; pub mod model; pub mod normalize; pub mod output; pub mod render; pub fn run() -> anyhow::Result<()> { Ok(()) } ``` Run: `cargo test --test render_json_tests -q` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/model.rs src/output.rs src/error.rs src/lib.rs tests/render_json_tests.rs git commit -m "feat: add normalized document model and output writer" ``` ### Task 3: Implement Markdown renderer with markdown-body safety fences **Files:** - Create: `src/render/mod.rs` - Create: `src/render/markdown.rs` - Create: `tests/render_markdown_tests.rs` - [ ] **Step 1: Write failing test for markdown structure and fence escalation** ```rust // tests/render_markdown_tests.rs use gitea_pr_review::model::*; use gitea_pr_review::render::markdown::render_markdown; #[test] fn markdown_contains_metadata_and_reviews() { let doc = PrReviewDocument { meta: PrMeta { repo: "org/repo".into(), pr_index: 7, title: "Fix parser".into(), state: "open".into(), author: "alice".into(), base_branch: "main".into(), head_branch: "feat/a".into(), created_at: "2026-04-08T10:00:00Z".into(), updated_at: "2026-04-08T11:00:00Z".into(), merged_at: None, }, commits: vec![CommitItem { sha: "abcdef0".into(), short_sha: "abcdef0".into(), title: "fix: parser".into(), author: "alice".into(), date: "2026-04-08T10:10:00Z".into() }], diff_stat: DiffStat { files_changed: 1, additions: 3, deletions: 1, files: vec![FileStat { path: "src/main.rs".into(), additions: 3, deletions: 1 }] }, reviews: vec![ReviewItem { id: 1, state: "COMMENT".into(), reviewer: "bob".into(), submitted_at: Some("2026-04-08T11:00:00Z".into()) }], threads: vec![CommentThread { thread_id: "t1".into(), file_path: Some("src/main.rs".into()), line: Some(9), root_comment: CommentItem { id: 11, user: "bob".into(), created_at: "2026-04-08T11:01:00Z".into(), body: "```rs\\nlet x = 1;\\n```".into() }, replies: vec![], }], }; let md = render_markdown(&doc); assert!(md.contains("# org/repo `#7` Fix parser")); assert!(md.contains("### Commits")); assert!(md.contains("### Diff Stat")); assert!(md.contains("## Review 1 (COMMENT)")); assert!(md.contains("````md")); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --test render_markdown_tests -q` Expected: FAIL with missing renderer module/function. - [ ] **Step 3: Implement renderer and dynamic fence helper** ```rust // src/render/mod.rs pub mod json; pub mod markdown; ``` ```rust // src/render/markdown.rs use crate::model::PrReviewDocument; fn fence_for(body: &str) -> &'static str { if body.contains("```") { "````" } else { "```" } } pub fn render_markdown(doc: &PrReviewDocument) -> String { let mut out = String::new(); out.push_str(&format!("# {} `#{}` {}\n\n", doc.meta.repo, doc.meta.pr_index, doc.meta.title)); out.push_str("## Metadata\n\n"); out.push_str(&format!("- state: {}\n- author: {}\n- base: {}\n- head: {}\n\n", doc.meta.state, doc.meta.author, doc.meta.base_branch, doc.meta.head_branch)); out.push_str("### Commits\n"); for c in &doc.commits { out.push_str(&format!("- {} {} ({}, {})\n", c.short_sha, c.title, c.author, c.date)); } out.push_str("\n### Diff Stat\n"); out.push_str(&format!("total: {} files, +{}, -{}\n", doc.diff_stat.files_changed, doc.diff_stat.additions, doc.diff_stat.deletions)); for f in &doc.diff_stat.files { out.push_str(&format!("- {}: +{}, -{}\n", f.path, f.additions, f.deletions)); } for (i, review) in doc.reviews.iter().enumerate() { out.push_str(&format!("\n## Review {} ({})\n", i + 1, review.state)); out.push_str(&format!("> {}\n", review.reviewer)); } for (i, thread) in doc.threads.iter().enumerate() { let f = thread.file_path.clone().unwrap_or_else(|| "".into()); let l = thread.line.map(|v| v.to_string()).unwrap_or_else(|| "-".into()); out.push_str(&format!("\n### Comment 1.{}\n{}:{}\n{}:\n", i + 1, f, l, thread.root_comment.user)); let fence = fence_for(&thread.root_comment.body); out.push_str(&format!("{fence}md\n{}\n{fence}\n", thread.root_comment.body)); for (j, r) in thread.replies.iter().enumerate() { out.push_str(&format!("\n### Reply 1.{}.{}\n{}:\n", i + 1, j + 1, r.user)); let rf = fence_for(&r.body); out.push_str(&format!("{rf}md\n{}\n{rf}\n", r.body)); } } out } ``` - [ ] **Step 4: Run renderer tests** Run: `cargo test --test render_markdown_tests -q` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/render/mod.rs src/render/markdown.rs tests/render_markdown_tests.rs git commit -m "feat: render markdown with safe fenced comment blocks" ``` ### Task 4: Implement JSON renderer and `render-md` command flow **Files:** - Create: `src/render/json.rs` - Modify: `src/lib.rs` - Create: `tests/e2e_smoke_tests.rs` - [ ] **Step 1: Write failing end-to-end test for render-md from JSON file** ```rust // tests/e2e_smoke_tests.rs use assert_cmd::Command; use tempfile::NamedTempFile; #[test] fn render_md_reads_json_and_outputs_markdown() { let input = NamedTempFile::new().unwrap(); std::fs::write( input.path(), r#"{"meta":{"repo":"org/repo","pr_index":1,"title":"t","state":"open","author":"a","base_branch":"main","head_branch":"f","created_at":"x","updated_at":"y","merged_at":null},"commits":[],"diff_stat":{"files_changed":0,"additions":0,"deletions":0,"files":[]},"reviews":[],"threads":[]}"#, ) .unwrap(); let mut cmd = Command::cargo_bin("gitea-pr-review").unwrap(); let output = cmd.args(["render-md", "--in", input.path().to_str().unwrap()]).assert().success(); let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap(); assert!(stdout.contains("# org/repo `#1` t")); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --test e2e_smoke_tests -q` Expected: FAIL because command dispatch is not implemented. - [ ] **Step 3: Implement JSON renderer and command dispatch** ```rust // src/render/json.rs use crate::model::PrReviewDocument; pub fn render_json(doc: &PrReviewDocument) -> anyhow::Result { Ok(serde_json::to_string_pretty(doc)?) } pub fn parse_json(input: &str) -> anyhow::Result { Ok(serde_json::from_str(input)?) } ``` ```rust // src/lib.rs use clap::Parser; use std::path::Path; use crate::cli::{Cli, Commands, OutputFormat}; use crate::output::write_output; use crate::render::json::{parse_json, render_json}; use crate::render::markdown::render_markdown; pub fn run() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { Commands::RenderMd(args) => { let raw = std::fs::read_to_string(&args.input)?; let doc = parse_json(&raw)?; let md = render_markdown(&doc); write_output(args.out.as_deref().map(Path::new), &md)?; } Commands::Fetch(_args) => { let _ = OutputFormat::Markdown; // implemented in Task 7 after client+normalize are ready } } Ok(()) } ``` - [ ] **Step 4: Run tests** Run: `cargo test --test e2e_smoke_tests --test render_json_tests -q` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/lib.rs src/render/json.rs tests/e2e_smoke_tests.rs git commit -m "feat: support render-md subcommand from input json" ``` ### Task 5: Implement Gitea DTOs and blocking API client with mock tests **Files:** - Create: `src/gitea/mod.rs` - Create: `src/gitea/dto.rs` - Create: `src/gitea/client.rs` - Create: `tests/gitea_client_tests.rs` - [ ] **Step 1: Write failing mock API test for fetch bundle** ```rust // tests/gitea_client_tests.rs use gitea_pr_review::gitea::client::GiteaClient; use mockito::Server; #[test] fn fetch_bundle_hits_required_endpoints() { let mut server = Server::new(); let _pr = server.mock("GET", "/api/v1/repos/org/repo/pulls/1").with_status(200).with_body(r#"{"number":1,"title":"T","state":"open","user":{"login":"alice"},"base":{"ref":"main"},"head":{"ref":"feat/x"},"created_at":"2026-04-08T10:00:00Z","updated_at":"2026-04-08T11:00:00Z","merged_at":null,"body":"desc"}"#).create(); let _reviews = server.mock("GET", "/api/v1/repos/org/repo/pulls/1/reviews").with_status(200).with_body("[]").create(); let _comments = server.mock("GET", "/api/v1/repos/org/repo/pulls/1/comments").with_status(200).with_body("[]").create(); let _commits = server.mock("GET", "/api/v1/repos/org/repo/pulls/1/commits").with_status(200).with_body("[]").create(); let _files = server.mock("GET", "/api/v1/repos/org/repo/pulls/1/files").with_status(200).with_body("[]").create(); let c = GiteaClient::new(server.url(), "token".into()); let bundle = c.fetch_pr_bundle("org/repo", 1).unwrap(); assert_eq!(bundle.pull.number, 1); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --test gitea_client_tests -q` Expected: FAIL with missing client/DTO definitions. - [ ] **Step 3: Implement DTOs and client** ```rust // src/gitea/mod.rs pub mod client; pub mod dto; ``` ```rust // src/gitea/dto.rs use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct UserDto { pub login: String } #[derive(Debug, Deserialize)] pub struct BranchRefDto { #[serde(rename = "ref")] pub ref_name: String } #[derive(Debug, Deserialize)] pub struct PullDto { pub number: i64, pub title: String, pub state: String, pub body: Option, pub user: UserDto, pub base: BranchRefDto, pub head: BranchRefDto, pub created_at: String, pub updated_at: String, pub merged_at: Option, } #[derive(Debug, Deserialize)] pub struct ReviewDto { pub id: i64, pub state: String, pub user: UserDto, pub submitted_at: Option, } #[derive(Debug, Deserialize)] pub struct CommentDto { pub id: i64, pub body: String, pub created_at: String, pub user: UserDto, pub path: Option, pub line: Option, pub in_reply_to_id: Option, } #[derive(Debug, Deserialize)] pub struct CommitInnerDto { pub message: String, pub author: CommitAuthorDto } #[derive(Debug, Deserialize)] pub struct CommitAuthorDto { pub name: String, pub date: String } #[derive(Debug, Deserialize)] pub struct CommitDto { pub sha: String, pub commit: CommitInnerDto } #[derive(Debug, Deserialize)] pub struct FileDto { pub filename: String, pub additions: i64, pub deletions: i64 } #[derive(Debug)] pub struct PullBundleDto { pub pull: PullDto, pub reviews: Vec, pub comments: Vec, pub commits: Vec, pub files: Vec, } ``` ```rust // src/gitea/client.rs use anyhow::{Context, Result}; use reqwest::blocking::Client; use super::dto::*; pub struct GiteaClient { base_url: String, token: String, http: Client, } impl GiteaClient { pub fn new(base_url: String, token: String) -> Self { Self { base_url, token, http: Client::new() } } fn get_json(&self, path: &str) -> Result { let url = format!("{}/{}", self.base_url.trim_end_matches('/'), path.trim_start_matches('/')); self.http .get(url) .header("Authorization", format!("token {}", self.token)) .send()? .error_for_status() .context("gitea api error")? .json::() .context("decode gitea response failed") } pub fn fetch_pr_bundle(&self, repo: &str, pr_index: i64) -> Result { let prefix = format!("/api/v1/repos/{}/pulls/{}", repo, pr_index); Ok(PullBundleDto { pull: self.get_json(&prefix)?, reviews: self.get_json(&format!("{}/reviews", prefix))?, comments: self.get_json(&format!("{}/comments", prefix))?, commits: self.get_json(&format!("{}/commits", prefix))?, files: self.get_json(&format!("{}/files", prefix))?, }) } } ``` - [ ] **Step 4: Run tests** Run: `cargo test --test gitea_client_tests -q` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/gitea/mod.rs src/gitea/dto.rs src/gitea/client.rs tests/gitea_client_tests.rs git commit -m "feat: add gitea API client and DTO bundle fetch" ``` ### Task 6: Implement normalization (thread grouping, stable ordering, diff stat) **Files:** - Create: `src/normalize.rs` - Create: `tests/normalize_tests.rs` - [ ] **Step 1: Write failing test for reply grouping and sorting** ```rust // tests/normalize_tests.rs use gitea_pr_review::gitea::dto::*; use gitea_pr_review::normalize::normalize_bundle; #[test] fn normalize_groups_replies_under_root_and_sorts_by_time() { let bundle = PullBundleDto { pull: PullDto { number: 1, title: "T".into(), state: "open".into(), body: Some("desc".into()), user: UserDto { login: "alice".into() }, base: BranchRefDto { ref_name: "main".into() }, head: BranchRefDto { ref_name: "feat/x".into() }, created_at: "2026-04-08T10:00:00Z".into(), updated_at: "2026-04-08T11:00:00Z".into(), merged_at: None, }, reviews: vec![], comments: vec![ CommentDto { id: 2, body: "reply".into(), created_at: "2026-04-08T11:03:00Z".into(), user: UserDto { login: "bob".into() }, path: Some("src/main.rs".into()), line: Some(9), in_reply_to_id: Some(1) }, CommentDto { id: 1, body: "root".into(), created_at: "2026-04-08T11:01:00Z".into(), user: UserDto { login: "bob".into() }, path: Some("src/main.rs".into()), line: Some(9), in_reply_to_id: None }, ], commits: vec![], files: vec![], }; let doc = normalize_bundle("org/repo", bundle); assert_eq!(doc.threads.len(), 1); assert_eq!(doc.threads[0].root_comment.id, 1); assert_eq!(doc.threads[0].replies[0].id, 2); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --test normalize_tests -q` Expected: FAIL with missing `normalize_bundle`. - [ ] **Step 3: Implement normalize function** ```rust // src/normalize.rs use std::collections::HashMap; use crate::gitea::dto::PullBundleDto; use crate::model::*; pub fn normalize_bundle(repo: &str, bundle: PullBundleDto) -> PrReviewDocument { let mut by_id = HashMap::new(); for c in &bundle.comments { by_id.insert(c.id, c); } let mut roots = Vec::new(); let mut children: HashMap> = HashMap::new(); for c in &bundle.comments { if let Some(parent) = c.in_reply_to_id { if by_id.contains_key(&parent) { children.entry(parent).or_default().push(c); } else { roots.push(c); } } else { roots.push(c); } } roots.sort_by(|a, b| a.created_at.cmp(&b.created_at)); let threads = roots .into_iter() .map(|root| { let mut replies = children.remove(&root.id).unwrap_or_default(); replies.sort_by(|a, b| a.created_at.cmp(&b.created_at)); CommentThread { thread_id: format!("t-{}", root.id), file_path: root.path.clone(), line: root.line, root_comment: CommentItem { id: root.id, user: root.user.login.clone(), created_at: root.created_at.clone(), body: root.body.clone(), }, replies: replies .into_iter() .map(|r| CommentItem { id: r.id, user: r.user.login.clone(), created_at: r.created_at.clone(), body: r.body.clone(), }) .collect(), } }) .collect::>(); let mut additions = 0_i64; let mut deletions = 0_i64; let mut files = Vec::new(); for f in bundle.files { additions += f.additions; deletions += f.deletions; files.push(FileStat { path: f.filename, additions: f.additions, deletions: f.deletions }); } let commits = bundle .commits .into_iter() .map(|c| { let title = c.commit.message.lines().next().unwrap_or("").to_string(); let short_sha = c.sha.chars().take(7).collect::(); CommitItem { sha: c.sha, short_sha, title, author: c.commit.author.name, date: c.commit.author.date, } }) .collect(); let reviews = bundle .reviews .into_iter() .map(|r| ReviewItem { id: r.id, state: r.state, reviewer: r.user.login, submitted_at: r.submitted_at, }) .collect(); PrReviewDocument { meta: PrMeta { repo: repo.to_string(), pr_index: bundle.pull.number, title: bundle.pull.title, state: bundle.pull.state, author: bundle.pull.user.login, base_branch: bundle.pull.base.ref_name, head_branch: bundle.pull.head.ref_name, created_at: bundle.pull.created_at, updated_at: bundle.pull.updated_at, merged_at: bundle.pull.merged_at, }, commits, diff_stat: DiffStat { files_changed: files.len(), additions, deletions, files, }, reviews, threads, } } ``` - [ ] **Step 4: Run normalization tests** Run: `cargo test --test normalize_tests -q` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/normalize.rs tests/normalize_tests.rs git commit -m "feat: normalize gitea DTO bundle into stable review document" ``` ### Task 7: Complete fetch command pipeline and environment validation **Files:** - Modify: `src/lib.rs` - Modify: `tests/e2e_smoke_tests.rs` - [ ] **Step 1: Write failing test for missing env variable behavior** ```rust // append in tests/e2e_smoke_tests.rs #[test] fn fetch_requires_env() { let mut cmd = Command::cargo_bin("gitea-pr-review").unwrap(); cmd.args(["fetch", "1"]) .env_remove("GITEA_PR_CLI_API_TOKEN") .env_remove("GITEA_PR_CLI_URL") .env_remove("GITEA_PR_CLI_REPO") .assert() .failure(); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --test e2e_smoke_tests -q` Expected: FAIL because `fetch` path is still stubbed. - [ ] **Step 3: Implement fetch execution path in run()** ```rust // src/lib.rs (replace run function body) use std::env; use std::path::Path; use crate::gitea::client::GiteaClient; use crate::normalize::normalize_bundle; pub fn run() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { Commands::RenderMd(args) => { let raw = std::fs::read_to_string(&args.input)?; let doc = parse_json(&raw)?; let md = render_markdown(&doc); write_output(args.out.as_deref().map(Path::new), &md)?; } Commands::Fetch(args) => { let token = env::var("GITEA_PR_CLI_API_TOKEN") .map_err(|_| anyhow::anyhow!("missing required environment variable: GITEA_PR_CLI_API_TOKEN"))?; let url = env::var("GITEA_PR_CLI_URL") .map_err(|_| anyhow::anyhow!("missing required environment variable: GITEA_PR_CLI_URL"))?; let repo = env::var("GITEA_PR_CLI_REPO") .map_err(|_| anyhow::anyhow!("missing required environment variable: GITEA_PR_CLI_REPO"))?; let client = GiteaClient::new(url, token); let bundle = client.fetch_pr_bundle(&repo, args.pr_index)?; let doc = normalize_bundle(&repo, bundle); let rendered = match args.format { OutputFormat::Markdown => render_markdown(&doc), OutputFormat::Json => render_json(&doc)?, }; write_output(args.out.as_deref().map(Path::new), &rendered)?; } } Ok(()) } ``` - [ ] **Step 4: Run smoke tests** Run: `cargo test --test e2e_smoke_tests -q` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/lib.rs tests/e2e_smoke_tests.rs git commit -m "feat: implement fetch pipeline with env validation and stdout default" ``` ### Task 8: Full verification and docs update **Files:** - Modify: `README.md` - [ ] **Step 1: Write README usage updates for new subcommands and flags** ````md # README.md (replace usage block) ```bash GITEA_PR_CLI_API_TOKEN=... GITEA_PR_CLI_URL=https://gitea.com GITEA_PR_CLI_REPO=Origami404/aaa # fetch markdown to stdout (default) gitea-pr-review fetch # fetch json to file gitea-pr-review fetch --format json --out pr.json # render markdown from existing json gitea-pr-review render-md --in pr.json > pr.md ``` ```` - [ ] **Step 2: Run full test suite** Run: `cargo test` Expected: all tests PASS. - [ ] **Step 3: Manual smoke commands** Run: `cargo run -- render-md --in ./fixtures/sample.json` Expected: prints markdown to stdout and exits with code 0. - [ ] **Step 4: Commit** ```bash git add README.md git commit -m "docs: document fetch and render-md command usage" ``` - [ ] **Step 5: Final quality gate** Run: `cargo fmt --check && cargo clippy --all-targets -- -D warnings` Expected: both commands succeed with no warnings. ## Spec Coverage Check - PR metadata / branches / commits / diff stat: covered by Tasks 5, 6, 7. - Full review comment and reply preservation: covered by Tasks 3 and 6. - Markdown and JSON dual output: covered by Tasks 3, 4, 7. - `render-md` subcommand from JSON: covered by Task 4. - `--format markdown|json`, no `both`, no `--out-dir`, stdout default: covered by Task 1 and Task 7. - Error handling for missing env / invalid JSON: covered by Tasks 4 and 7. - Testing strategy (unit + integration + e2e): covered by Tasks 1 through 8. ## Self-Review - Placeholder scan: no `TBD`/`TODO`/"later" placeholders remain. - Internal consistency: subcommand names, field names, and format flags are consistent across all tasks. - Scope check: plan is a single subsystem (CLI pipeline + renderers + client + normalization) and remains within approved spec boundaries.