Files
gitea-pr-review/docs/superpowers/plans/2026-04-08-gitea-pr-review-implementation.md
T

31 KiB

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

// 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
# 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"
// 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<PathBuf>,
}

#[derive(Debug, clap::Args)]
pub struct RenderMdArgs {
    #[arg(long = "in")]
    pub input: PathBuf,
    #[arg(long)]
    pub out: Option<PathBuf>,
}
// src/lib.rs
pub mod cli;
pub mod error;
pub mod gitea;
pub mod model;
pub mod normalize;
pub mod output;
pub mod render;
// src/main.rs
use anyhow::Result;

fn main() -> Result<()> {
    gitea_pr_review::run()
}
  • Step 4: Add temporary run() entry and re-run tests
// append in src/lib.rs
pub fn run() -> anyhow::Result<()> {
    Ok(())
}

Run: cargo test --test cli_parse_tests -q
Expected: PASS.

  • Step 5: Commit
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

// 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
// src/model.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrReviewDocument {
    pub meta: PrMeta,
    pub commits: Vec<CommitItem>,
    pub diff_stat: DiffStat,
    pub reviews: Vec<ReviewItem>,
    pub threads: Vec<CommentThread>,
}

#[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<String>,
}

#[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<FileStat>,
}

#[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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentThread {
    pub thread_id: String,
    pub file_path: Option<String>,
    pub line: Option<i64>,
    pub root_comment: CommentItem,
    pub replies: Vec<CommentItem>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentItem {
    pub id: i64,
    pub user: String,
    pub created_at: String,
    pub body: String,
}
// 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(())
}
// 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
// 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
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

// 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
// src/render/mod.rs
pub mod json;
pub mod markdown;
// 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(|| "<no-file>".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
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

// 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
// src/render/json.rs
use crate::model::PrReviewDocument;

pub fn render_json(doc: &PrReviewDocument) -> anyhow::Result<String> {
    Ok(serde_json::to_string_pretty(doc)?)
}

pub fn parse_json(input: &str) -> anyhow::Result<PrReviewDocument> {
    Ok(serde_json::from_str(input)?)
}
// 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
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

// 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
// src/gitea/mod.rs
pub mod client;
pub mod dto;
// 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<String>,
    pub user: UserDto,
    pub base: BranchRefDto,
    pub head: BranchRefDto,
    pub created_at: String,
    pub updated_at: String,
    pub merged_at: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct ReviewDto {
    pub id: i64,
    pub state: String,
    pub user: UserDto,
    pub submitted_at: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct CommentDto {
    pub id: i64,
    pub body: String,
    pub created_at: String,
    pub user: UserDto,
    pub path: Option<String>,
    pub line: Option<i64>,
    pub in_reply_to_id: Option<i64>,
}

#[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<ReviewDto>,
    pub comments: Vec<CommentDto>,
    pub commits: Vec<CommitDto>,
    pub files: Vec<FileDto>,
}
// 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<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
        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::<T>()
            .context("decode gitea response failed")
    }

    pub fn fetch_pr_bundle(&self, repo: &str, pr_index: i64) -> Result<PullBundleDto> {
        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
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

// 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
// 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<i64, Vec<&crate::gitea::dto::CommentDto>> = 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::<Vec<_>>();

    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::<String>();
            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
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

// 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()
// 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
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

# 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 <pr-index>

# fetch json to file
gitea-pr-review fetch <pr-index> --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
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.