use crate::config::{Config, ReviewLabelsConfig, ReviewStrategy};
use crate::git::{self, RunOpts};
use anyhow::{Context, Result};
use colored::Colorize;
use glob::Pattern;
use serde_json::Value;
use std::process::Command;
fn short_hash(hash: &str) -> &str {
&hash[..7.min(hash.len())]
}
pub fn should_auto_trigger_review(
config: &Config,
commit_hash: &str,
opts: RunOpts,
) -> Result<bool> {
if !config.review.enabled || config.review.rules.is_empty() {
return Ok(false);
}
let touched_files = git::get_changed_files(commit_hash, opts)?;
for rule in &config.review.rules {
if let Ok(pattern) = Pattern::new(&rule.pattern) {
if touched_files.iter().any(|f| pattern.matches(f)) {
if opts.verbose {
println!(
"{} Auto-trigger: files match rule pattern '{}'",
"[REVIEW]".magenta(),
rule.pattern
);
}
return Ok(true);
}
}
}
Ok(false)
}
pub fn trigger_review(
config: &Config,
reviewers_override: Option<&[String]>,
commit_hash: &str,
message: &str,
author: &str,
opts: RunOpts,
) -> Result<()> {
if !config.review.enabled {
if opts.verbose {
println!("{}", "Review system is disabled in config.".dimmed());
}
return Ok(());
}
let touched_files = git::get_changed_files(commit_hash, opts)?;
let mut applicable_reviewers: Vec<String> = Vec::new();
let mut is_targeted = false;
for rule in &config.review.rules {
if let Ok(pattern) = Pattern::new(&rule.pattern) {
let matched = touched_files.iter().any(|f| pattern.matches(f));
if matched {
if opts.verbose {
println!(
"{} File match for rule: {}",
"[RULE]".magenta(),
rule.pattern.dimmed()
);
}
is_targeted = true;
if let Some(rule_reviewers) = &rule.reviewers {
applicable_reviewers.extend(rule_reviewers.clone());
}
}
}
}
let mut final_reviewers = if let Some(ovr) = reviewers_override {
ovr.to_vec()
} else if !applicable_reviewers.is_empty() {
applicable_reviewers
} else {
config.review.default_reviewers.clone()
};
final_reviewers.sort();
final_reviewers.dedup();
println!("{}", "--- Triggering Non-blocking Review ---".blue());
if is_targeted {
println!("{} Review triggered by targeted file rules.", ">>".yellow());
}
let short = short_hash(commit_hash);
println!(
"{} {} ({})",
"Review requested for:".green(),
message.bold(),
short.dimmed()
);
println!(" Author: {}", author);
if !final_reviewers.is_empty() {
println!(" Reviewers: {}", final_reviewers.join(", "));
}
if opts.dry_run {
println!("{}", "[DRY RUN] Would create review request".yellow());
return Ok(());
}
match &config.review.strategy {
ReviewStrategy::GithubIssue => {
create_github_issue(
&config.review.labels,
&final_reviewers,
commit_hash,
message,
author,
opts,
)?;
}
ReviewStrategy::GithubWorkflow => {
trigger_github_workflow(config, commit_hash, message, author, &final_reviewers, opts)?;
}
ReviewStrategy::LogOnly => {
println!(
"{}",
"Review logged (no external system integration)".dimmed()
);
}
}
Ok(())
}
fn trigger_github_workflow(
config: &Config,
commit_hash: &str,
message: &str,
author: &str,
reviewers: &[String],
opts: RunOpts,
) -> Result<()> {
if !is_gh_cli_available() {
println!(
"{}",
"Warning: GitHub CLI (gh) not found. Install it to trigger workflows.".yellow()
);
println!(
"{}",
"Install: https://cli.github.com/ or 'brew install gh'".dimmed()
);
return Ok(());
}
let workflow_name = config
.review
.workflow
.as_deref()
.unwrap_or("nbr-review.yml");
let short = short_hash(commit_hash);
if opts.verbose {
println!(
"{} Triggering workflow '{}' for commit {}",
"[INFO]".cyan(),
workflow_name,
short
);
}
let reviewers_json = reviewers.join(",");
let output = Command::new("gh")
.args([
"workflow",
"run",
workflow_name,
"-f",
&format!("commit_sha={}", commit_hash),
"-f",
&format!("commit_message={}", message),
"-f",
&format!("author={}", author),
"-f",
&format!("reviewers={}", reviewers_json),
])
.output()
.context("Failed to trigger GitHub workflow")?;
if output.status.success() {
println!(
"{}",
format!(
"Workflow '{}' triggered for commit {}",
workflow_name, short
)
.green()
);
println!(
"{}",
" Server-side review management is now active.".dimmed()
);
println!(
"{}",
" Check GitHub Actions for issue creation and status updates.".dimmed()
);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("could not find any workflows") {
println!(
"{}",
format!(
"Warning: Workflow '{}' not found in repository.",
workflow_name
)
.yellow()
);
println!(
"{}",
" Create the workflow file at .github/workflows/ to enable server-side reviews."
.dimmed()
);
println!(
"{}",
" Falling back to client-side issue creation...".dimmed()
);
create_github_issue(
&config.review.labels,
reviewers,
commit_hash,
message,
author,
opts,
)?;
} else {
println!(
"{}",
format!("Warning: Failed to trigger workflow: {}", stderr.trim()).yellow()
);
}
}
Ok(())
}
fn create_github_issue(
labels: &ReviewLabelsConfig,
reviewers: &[String],
commit_hash: &str,
message: &str,
author: &str,
opts: RunOpts,
) -> Result<()> {
let short = short_hash(commit_hash);
if !is_gh_cli_available() {
println!(
"{}",
"Warning: GitHub CLI (gh) not found. Install it to enable GitHub issue creation."
.yellow()
);
println!(
"{}",
"Install: https://cli.github.com/ or 'brew install gh'".dimmed()
);
return Ok(());
}
ensure_review_labels_exist(labels, opts);
let repo_url = git::get_remote_url(opts).unwrap_or_default();
let commit_url = if repo_url.is_empty() {
format!("`{}`", commit_hash)
} else {
format!("[`{}`]({}/commit/{})", short, repo_url, commit_hash)
};
let title = format!("[Review] {} ({})", message, short);
let body = format!(
"## Non-blocking Review Request\n\n\
**Commit:** {}\n\
**Author:** {}\n\
**Message:** {}\n\n\
---\n\n\
> In Trunk-Based Development, this code is already in the trunk.\n\
> Your goal is **Course Correction** and **Knowledge Sharing**, not gatekeeping.\n\n\
### What to Look For\n\n\
| Focus | Question |\n\
|-------|----------|\n\
| **Design & Intent** | Does the implementation align with our architectural patterns? |\n\
| **Logic & Edge Cases** | Are there logical flaws or unhappy paths that tests might miss? |\n\
| **Readability** | Are names descriptive? (Code as Documentation) |\n\
| **Simplification** | Can this be done with less code or lower complexity? |\n\n\
### How to Comment\n\n\
- **Questions > Commands**: _\"Could we use the existing helper here?\"_ instead of _\"Change this.\"_\n\
- **Praise**: If you see something clever or clean, say so! NBR boosts team morale.\n\
- **Nitpicking**: Label minor style issues as `(nit)` so the author knows they're optional.\n\n\
### Concerns\n\n\
_No concerns raised yet._\n\n\
---\n\n\
To approve via CLI:\n\
```\n\
tbdflow review --approve {}\n\
```\n\n\
To raise a concern:\n\
```\n\
tbdflow review --concern {} -m \"Your concern here\"\n\
```",
commit_url, author, message, short, short
);
let mut args = vec!["issue", "create", "--title", &title, "--body", &body];
if label_exists(&labels.pending) {
args.push("--label");
args.push(&labels.pending);
}
let assignees: Vec<&str> = reviewers.iter().map(String::as_str).collect();
let assignees_str = assignees.join(",");
if !assignees.is_empty() {
args.push("--assignee");
args.push(&assignees_str);
}
if opts.verbose {
println!("{} gh {}", "[RUNNING]".cyan(), args.join(" "));
}
let output = Command::new("gh")
.args(&args)
.output()
.context("Failed to execute 'gh' CLI")?;
if output.status.success() {
let issue_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("{} {}", "Review issue created:".green(), issue_url);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
println!(
"{}",
format!("Warning: Failed to create GitHub issue: {}", stderr).yellow()
);
}
Ok(())
}
fn label_exists(label_name: &str) -> bool {
Command::new("gh")
.args(["label", "list", "--search", label_name, "--json", "name"])
.output()
.map(|o| {
o.status.success()
&& String::from_utf8_lossy(&o.stdout)
.contains(&format!("\"name\":\"{}\"", label_name))
})
.unwrap_or(false)
}
fn ensure_label_exists(label_name: &str, description: &str, color: &str, opts: RunOpts) {
if label_exists(label_name) {
return;
}
if opts.verbose {
println!("{} Creating '{}' label...", "[INFO]".cyan(), label_name);
}
let result = Command::new("gh")
.args([
"label",
"create",
label_name,
"--description",
description,
"--color",
color,
])
.output();
match result {
Ok(output) if output.status.success() => {
if opts.verbose {
println!("{} Created '{}' label", "[INFO]".cyan(), label_name);
}
}
_ => {
}
}
}
fn ensure_review_labels_exist(labels: &ReviewLabelsConfig, opts: RunOpts) {
ensure_label_exists(
&labels.pending,
"Review pending - awaiting attention",
"FBCA04", opts,
);
ensure_label_exists(
&labels.concern,
"Review concern raised - needs attention",
"D93F0B", opts,
);
ensure_label_exists(
&labels.accepted,
"Review accepted/approved",
"0E8A16", opts,
);
ensure_label_exists(
&labels.dismissed,
"Review dismissed - won't fix",
"6A737D", opts,
);
}
fn is_gh_cli_available() -> bool {
Command::new("gh")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn handle_review_trigger(
config: &Config,
reviewers_override: Option<Vec<String>>,
commit_sha: Option<&str>,
opts: RunOpts,
) -> Result<()> {
if !config.review.enabled {
println!(
"{}",
"Review system is not enabled. Add the following to your .tbdflow.yml:".yellow()
);
println!("\n review:");
println!(" enabled: true");
println!(" strategy: github-issue");
println!(" default_reviewers:");
println!(" - teammate-username\n");
return Ok(());
}
let commit_hash = match commit_sha {
Some(sha) if !sha.is_empty() => {
let full = git::resolve_commit_hash(sha, opts)?;
if opts.verbose {
println!(
"{} Triggering review for commit {}",
"[REVIEW]".magenta(),
short_hash(&full)
);
}
full
}
_ => git::get_head_commit_hash(opts)?,
};
let message = git::get_commit_message(&commit_hash, opts)?;
let author = git::get_user_name(opts)?;
trigger_review(
config,
reviewers_override.as_deref(),
&commit_hash,
&message,
&author,
opts,
)
}
pub fn handle_review_digest(config: &Config, since: &str, opts: RunOpts) -> Result<()> {
println!(
"{}",
format!("--- Trunk Evolution Digest (Since {}) ---", since).blue()
);
let log = git::get_log_since(since, opts)?;
if log.is_empty() {
println!(
"{}",
"No new commits found in the specified time range.".yellow()
);
return Ok(());
}
println!("\n{}", "COMMITS FOR REVIEW".cyan().bold());
println!("{}", "─".repeat(50).cyan());
for line in log.lines() {
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(3, '|').collect();
if parts.len() >= 2 {
let hash = short_hash(parts[0]);
let author = parts.get(1).unwrap_or(&"unknown");
let message = parts.get(2).unwrap_or(&"");
println!(
" {} {} {}",
hash.yellow(),
format!("({})", author).dimmed(),
message
);
}
}
println!("{}", "─".repeat(50).cyan());
if !config.review.default_reviewers.is_empty() {
println!(
"\n{}",
format!(
"Default reviewers: {}",
config.review.default_reviewers.join(", ")
)
.dimmed()
);
}
println!("\n{}", "Next steps:".bold());
println!(" • Review commits above and discuss with the team");
println!(" • Run 'tbdflow review --approve <hash>' to mark as reviewed");
println!(" • Run 'tbdflow review --trigger' to create review issues\n");
Ok(())
}
pub fn handle_review_approve(config: &Config, commit_hash: &str, opts: RunOpts) -> Result<()> {
let short = short_hash(commit_hash);
println!("{}", format!("--- Approving Commit {} ---", short).blue());
if opts.dry_run {
println!("{}", "[DRY RUN] Would mark commit as approved".yellow());
return Ok(());
}
match &config.review.strategy {
ReviewStrategy::GithubIssue => {
close_github_review_issue(&config.review.labels, short, opts)?;
}
ReviewStrategy::GithubWorkflow => {
close_github_review_issue(&config.review.labels, short, opts)?;
println!(
"{}",
" Server-side workflow will update commit status.".dimmed()
);
}
ReviewStrategy::LogOnly => {
println!("{}", format!("Commit {} marked as approved", short).green());
}
}
Ok(())
}
pub fn handle_review_concern(
config: &Config,
commit_hash: &str,
message: &str,
opts: RunOpts,
) -> Result<()> {
let short = short_hash(commit_hash);
println!(
"{}",
format!("--- Raising Concern on Commit {} ---", short).blue()
);
if opts.dry_run {
println!("{}", "[DRY RUN] Would raise concern on commit".yellow());
return Ok(());
}
match &config.review.strategy {
ReviewStrategy::GithubIssue | ReviewStrategy::GithubWorkflow => {
raise_github_concern(config, commit_hash, message, opts)?;
}
ReviewStrategy::LogOnly => {
println!("{}", format!("CONCERN on {}: {}", short, message).yellow());
}
}
Ok(())
}
pub fn handle_review_dismiss(
config: &Config,
commit_hash: &str,
message: &str,
opts: RunOpts,
) -> Result<()> {
let short = short_hash(commit_hash);
println!(
"{}",
format!("--- Dismissing Review for Commit {} ---", short).blue()
);
if opts.dry_run {
println!("{}", "[DRY RUN] Would dismiss review".yellow());
return Ok(());
}
match &config.review.strategy {
ReviewStrategy::GithubIssue | ReviewStrategy::GithubWorkflow => {
dismiss_github_review_issue(&config.review.labels, short, message, opts)?;
}
ReviewStrategy::LogOnly => {
println!(
"{}",
format!("Review for {} dismissed: {}", short, message).dimmed()
);
}
}
Ok(())
}
fn raise_github_concern(
config: &Config,
commit_hash: &str,
message: &str,
opts: RunOpts,
) -> Result<()> {
let short = short_hash(commit_hash);
let labels = &config.review.labels;
if !is_gh_cli_available() {
println!(
"{}",
"Warning: GitHub CLI (gh) not found. Cannot raise concern.".yellow()
);
return Ok(());
}
let search_query = format!("[Review] in:title {} in:title is:open", short);
if opts.verbose {
println!("{} Searching for review issue...", "[INFO]".cyan());
}
let output = Command::new("gh")
.args([
"issue",
"list",
"--search",
&search_query,
"--json",
"number,body",
"--limit",
"1",
])
.output()
.context("Failed to search for GitHub issues")?;
if !output.status.success() {
println!(
"{}",
format!("Warning: Could not find review issue for {}", short).yellow()
);
return Ok(());
}
let json_output = String::from_utf8_lossy(&output.stdout);
if let Some(issue_num) = extract_issue_number(&json_output) {
let issue_num_str = issue_num.to_string();
if opts.verbose {
println!(
"{} Updating labels on issue #{}",
"[INFO]".cyan(),
issue_num
);
}
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--remove-label",
&labels.pending,
])
.output();
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--add-label",
&labels.concern,
])
.output();
let comment = format!("**Concern Raised**\n\n{}", message);
let _ = Command::new("gh")
.args(["issue", "comment", &issue_num_str, "--body", &comment])
.output();
append_concern_checklist_item(&issue_num_str, message, opts)?;
set_commit_status(config, commit_hash, message, opts)?;
println!(
"{}",
format!(
"Concern raised on issue #{} for commit {} (label: {})",
issue_num, short, labels.concern
)
.yellow()
);
} else {
println!(
"{}",
format!("Warning: No open review issue found for commit {}", short).yellow()
);
println!(" Run 'tbdflow review --trigger' first to create the review issue.");
}
Ok(())
}
fn append_concern_checklist_item(
issue_num: &str,
concern_message: &str,
opts: RunOpts,
) -> Result<()> {
let output = Command::new("gh")
.args(["issue", "view", issue_num, "--json", "body"])
.output()
.context("Failed to get issue body")?;
if !output.status.success() {
return Ok(());
}
let json_output = String::from_utf8_lossy(&output.stdout);
let current_body = extract_body_from_json(&json_output).unwrap_or_default();
let new_body = if current_body.contains("_No concerns raised yet._") {
current_body.replace(
"_No concerns raised yet._",
&format!("- [ ] {}", concern_message),
)
} else if current_body.contains("### Concerns") {
let concerns_marker = "### Concerns\n\n";
if let Some(pos) = current_body.find(concerns_marker) {
let insert_pos = pos + concerns_marker.len();
let (before, after) = current_body.split_at(insert_pos);
format!("{}- [ ] {}\n{}", before, concern_message, after)
} else {
current_body
}
} else {
current_body
};
if opts.verbose {
println!(
"{} Updating issue body with concern checklist item",
"[INFO]".cyan()
);
}
let _ = Command::new("gh")
.args(["issue", "edit", issue_num, "--body", &new_body])
.output();
Ok(())
}
fn extract_body_from_json(json: &str) -> Option<String> {
let parsed: Value = serde_json::from_str(json).ok()?;
parsed["body"].as_str().map(|s| s.to_string())
}
fn set_commit_status(
config: &Config,
commit_hash: &str,
message: &str,
opts: RunOpts,
) -> Result<()> {
if !is_gh_cli_available() {
return Ok(());
}
let (state, description) = if config.review.concern_blocks_status {
("failure", format!("Audit Concern: {}", message))
} else {
(
"pending",
format!("Awaiting fix-forward for concern: {}", message),
)
};
let repo_info = Command::new("gh")
.args(["repo", "view", "--json", "owner,name"])
.output();
let repo = match repo_info {
Ok(output) if output.status.success() => {
let json = String::from_utf8_lossy(&output.stdout);
extract_repo_from_json(&json)
}
_ => return Ok(()),
};
let Some((owner, name)) = repo else {
return Ok(());
};
if opts.verbose {
println!(
"{} Setting commit status to '{}' for {}",
"[INFO]".cyan(),
state,
short_hash(commit_hash)
);
}
let api_path = format!("repos/{}/{}/statuses/{}", owner, name, commit_hash);
let _ = Command::new("gh")
.args([
"api",
&api_path,
"-f",
&format!("state={}", state),
"-f",
"context=peer-review",
"-f",
&format!("description={}", description),
])
.output();
Ok(())
}
fn extract_repo_from_json(json: &str) -> Option<(String, String)> {
let parsed: Value = serde_json::from_str(json).ok()?;
let owner = parsed["owner"]["login"].as_str()?.to_string();
let name = parsed["name"].as_str()?.to_string();
Some((owner, name))
}
fn dismiss_github_review_issue(
labels: &ReviewLabelsConfig,
short_hash: &str,
message: &str,
opts: RunOpts,
) -> Result<()> {
if !is_gh_cli_available() {
println!(
"{}",
"Warning: GitHub CLI (gh) not found. Cannot dismiss review.".yellow()
);
return Ok(());
}
let search_query = format!("[Review] in:title {} in:title is:open", short_hash);
if opts.verbose {
println!("{} Searching for review issue...", "[INFO]".cyan());
}
let output = Command::new("gh")
.args([
"issue",
"list",
"--search",
&search_query,
"--json",
"number",
"--limit",
"1",
])
.output()
.context("Failed to search for GitHub issues")?;
if output.status.success() {
let json_output = String::from_utf8_lossy(&output.stdout);
if let Some(issue_num) = extract_issue_number(&json_output) {
let issue_num_str = issue_num.to_string();
if opts.verbose {
println!(
"{} Updating labels on issue #{}",
"[INFO]".cyan(),
issue_num
);
}
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--remove-label",
&labels.pending,
])
.output();
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--remove-label",
&labels.concern,
])
.output();
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--add-label",
&labels.dismissed,
])
.output();
let comment = format!(
"**Dismissed** via `tbdflow review --dismiss`\n\nReason: {}",
message
);
let close_output = Command::new("gh")
.args(["issue", "close", &issue_num_str, "--comment", &comment])
.output()
.context("Failed to close GitHub issue")?;
if close_output.status.success() {
println!(
"{}",
format!(
"Review for commit {} dismissed and issue #{} closed (label: {})",
short_hash, issue_num, labels.dismissed
)
.dimmed()
);
} else {
println!(
"{}",
"Review dismissed (issue close failed)".to_string().yellow()
);
}
} else {
println!(
"{}",
format!(
"Review for {} dismissed (no open review issue found)",
short_hash
)
.dimmed()
);
}
} else {
println!(
"{}",
format!("Review for {} dismissed", short_hash).dimmed()
);
}
Ok(())
}
fn close_github_review_issue(
labels: &ReviewLabelsConfig,
short_hash: &str,
opts: RunOpts,
) -> Result<()> {
if !is_gh_cli_available() {
println!(
"{}",
"Warning: GitHub CLI (gh) not found. Marking as approved locally only.".yellow()
);
println!("{}", format!("Commit {} approved", short_hash).green());
return Ok(());
}
let search_query = format!("[Review] in:title {} in:title is:open", short_hash);
if opts.verbose {
println!("{} Searching for review issue...", "[INFO]".cyan());
}
let output = Command::new("gh")
.args([
"issue",
"list",
"--search",
&search_query,
"--json",
"number",
"--limit",
"1",
])
.output()
.context("Failed to search for GitHub issues")?;
if output.status.success() {
let json_output = String::from_utf8_lossy(&output.stdout);
if let Some(issue_num) = extract_issue_number(&json_output) {
let issue_num_str = issue_num.to_string();
if opts.verbose {
println!(
"{} Updating labels on issue #{}",
"[INFO]".cyan(),
issue_num
);
}
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--remove-label",
&labels.pending,
])
.output();
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--remove-label",
&labels.concern,
])
.output();
let _ = Command::new("gh")
.args([
"issue",
"edit",
&issue_num_str,
"--add-label",
&labels.accepted,
])
.output();
if opts.verbose {
println!("{} Closing issue #{}", "[INFO]".cyan(), issue_num);
}
let close_output = Command::new("gh")
.args([
"issue",
"close",
&issue_num_str,
"--comment",
"Approved via `tbdflow review --approve`",
])
.output()
.context("Failed to close GitHub issue")?;
if close_output.status.success() {
println!(
"{}",
format!(
"Commit {} approved and review issue #{} closed (label: {})",
short_hash, issue_num, labels.accepted
)
.green()
);
} else {
println!(
"{}",
format!("Commit {} approved (issue close failed)", short_hash).yellow()
);
}
} else {
println!(
"{}",
format!(
"Commit {} approved (no open review issue found)",
short_hash
)
.green()
);
}
} else {
println!("{}", format!("Commit {} approved", short_hash).green());
}
Ok(())
}
fn extract_issue_number(json: &str) -> Option<i64> {
let parsed: Value = serde_json::from_str(json).ok()?;
parsed.as_array()?.first()?["number"].as_i64()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_hash_returns_first_seven_chars() {
assert_eq!(short_hash("abc1234567890"), "abc1234");
}
#[test]
fn short_hash_handles_exact_seven_chars() {
assert_eq!(short_hash("abc1234"), "abc1234");
}
#[test]
fn short_hash_handles_short_input() {
assert_eq!(short_hash("abc"), "abc");
}
#[test]
fn short_hash_handles_empty_input() {
assert_eq!(short_hash(""), "");
}
#[test]
fn extract_issue_number_parses_valid_json() {
let json = r#"[{"number":123}]"#;
assert_eq!(extract_issue_number(json), Some(123));
}
#[test]
fn extract_issue_number_parses_larger_number() {
let json = r#"[{"number":98765}]"#;
assert_eq!(extract_issue_number(json), Some(98765));
}
#[test]
fn extract_issue_number_returns_none_for_empty_array() {
let json = r#"[]"#;
assert_eq!(extract_issue_number(json), None);
}
#[test]
fn extract_issue_number_returns_none_for_invalid_json() {
let json = r#"not json"#;
assert_eq!(extract_issue_number(json), None);
}
#[test]
fn extract_issue_number_handles_whitespace() {
let json = r#"[{"number": 42}]"#;
assert_eq!(extract_issue_number(json), Some(42));
}
}