diff --git a/docs/superpowers/plans/2026-04-08-gitea-pr-review-implementation.md b/docs/superpowers/plans/2026-04-08-gitea-pr-review-implementation.md new file mode 100644 index 0000000..e6e91e9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-gitea-pr-review-implementation.md @@ -0,0 +1,1080 @@ +# 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.