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

1081 lines
31 KiB
Markdown

# 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<PathBuf>,
}
#[derive(Debug, clap::Args)]
pub struct RenderMdArgs {
#[arg(long = "in")]
pub input: PathBuf,
#[arg(long)]
pub out: Option<PathBuf>,
}
```
```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<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,
}
```
```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(|| "<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**
```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<String> {
Ok(serde_json::to_string_pretty(doc)?)
}
pub fn parse_json(input: &str) -> anyhow::Result<PrReviewDocument> {
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<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>,
}
```
```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<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**
```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<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**
```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 <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**
```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.