1081 lines
31 KiB
Markdown
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.
|