Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 76 additions & 20 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use serde_json::json;

use crate::{
config::{RepoReviewConfig, ReviewTarget, translate_coderabbit, translate_kodo},
review::{Finding, ReviewEnvelope},
review::{Finding, ReviewEnvelope, markdown_body, severity_mark, status_line},
text::limit_text,
};

Expand Down Expand Up @@ -128,7 +128,7 @@ impl GithubClient {
path: f.path.clone(),
line: f.line,
side: "RIGHT",
body: format!("**{}** · {}", f.severity.as_str().to_uppercase(), f.body),
body: render_inline_comment(f),
})
.collect();
let payload = PullReviewRequest {
Expand Down Expand Up @@ -309,21 +309,11 @@ impl CheckOutput {
}

pub fn from_envelope(envelope: &ReviewEnvelope) -> Self {
let mut errors = 0;
let mut warnings = 0;
for finding in &envelope.findings {
match finding.severity {
crate::config::Severity::Error => errors += 1,
crate::config::Severity::Warn => warnings += 1,
crate::config::Severity::Info => {}
}
}
let title = if errors > 0 {
format!("{errors} error{}", if errors == 1 { "" } else { "s" })
} else if warnings > 0 {
format!("{warnings} warning{}", if warnings == 1 { "" } else { "s" })
} else {
let status = status_line(envelope, envelope.findings.len(), "");
let title = if status.is_empty() {
"No merge-relevant findings".to_string()
} else {
status
};
let text = if envelope.findings.is_empty() {
None
Expand All @@ -339,23 +329,31 @@ impl CheckOutput {
"See inline review comments.".to_string()
}
} else {
envelope.summary.clone()
markdown_body(envelope.summary.trim())
},
text,
}
}
}

fn render_inline_comment(finding: &Finding) -> String {
format!(
"{} {}",
severity_mark(finding.severity),
markdown_body(finding.body.trim())
)
}

fn render_findings(findings: &[Finding]) -> String {
findings
.iter()
.map(|f| {
format!(
"**{}** `{}`:{}\n\n{}",
f.severity.as_str().to_uppercase(),
"{} `{}`:{}\n\n{}",
severity_mark(f.severity),
f.path,
f.line,
f.body
markdown_body(f.body.trim())
)
})
.collect::<Vec<_>>()
Expand All @@ -379,3 +377,61 @@ pub fn check_conclusion(envelope: &ReviewEnvelope) -> &'static str {
"success"
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::Severity,
review::{FindingKind, TokenUsage},
};

fn envelope(findings: Vec<Finding>) -> ReviewEnvelope {
ReviewEnvelope {
summary: String::new(),
findings,
usage: TokenUsage::default(),
model_used: "test".to_string(),
}
}

#[test]
fn inline_comment_uses_icon_and_code_formatting() {
let finding = Finding {
path: ".github/workflows/trigger-deploy.yml".to_string(),
line: 53,
severity: Severity::Error,
kind: Some(FindingKind::Risk),
body: "TRIGGER_PROJECT_ID and TRIGGER_SECRET_KEY are written to GITHUB_ENV."
.to_string(),
};

assert_eq!(
render_inline_comment(&finding),
"❌ `TRIGGER_PROJECT_ID` and `TRIGGER_SECRET_KEY` are written to `GITHUB_ENV`."
);
}

#[test]
fn check_output_uses_compact_status_title() {
let output = CheckOutput::from_envelope(&envelope(vec![
Finding {
path: "src/lib.rs".to_string(),
line: 1,
severity: Severity::Info,
kind: None,
body: "needs review".to_string(),
},
Finding {
path: "src/lib.rs".to_string(),
line: 2,
severity: Severity::Warn,
kind: None,
body: "risk".to_string(),
},
]));

assert_eq!(output.title, "status: ℹ️⚠️");
assert_eq!(output.summary, "See inline review comments.");
}
}
159 changes: 131 additions & 28 deletions src/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ use serde_json::Value;

use crate::config::{RepoReviewConfig, Severity};

const STATUS_ICON_BASE_URL: &str = "https://postil.dev/status";

const BASE_SYSTEM_PROMPT: &str = r#"You are Postil, a low-noise review gate for agent-speed development. You receive a unified diff for a pull request and produce structured findings as JSON.

Product doctrine:
Expand All @@ -27,7 +25,8 @@ Rules:
- Use info only for merge-relevant human escalation, durable guardrail suggestions, or material uncertainty.
- If the diff has no merge-relevant findings, return an empty summary string and an empty findings array.
- Return at most 25 findings.
- Keep each finding body under 1200 characters.
- Keep each finding body to 1-2 short sentences. State the merge risk or intent mismatch first, then the fix. Do not teach basic concepts.
- Wrap code identifiers, env vars, file names, commands, config keys, and bot handles in backticks.

Reply with ONLY a single JSON object, no prose, no markdown fence:
{
Expand Down Expand Up @@ -207,41 +206,93 @@ pub fn apply_config(
Ok(envelope)
}

pub fn status_line(envelope: &ReviewEnvelope, _inline_comments: usize, label: &str) -> String {
let mut errors = 0;
let mut warnings = 0;
let mut infos = 0;
pub fn status_line(envelope: &ReviewEnvelope, _inline_comments: usize, _label: &str) -> String {
let mut status = String::new();
for finding in &envelope.findings {
match finding.severity {
Severity::Error => errors += 1,
Severity::Warn => warnings += 1,
Severity::Info => infos += 1,
}
status.push_str(severity_mark(finding.severity));
}
let mut status = String::new();
for _ in 0..errors {
status.push_str(&status_icon("error"));
if status.is_empty() {
String::new()
} else {
format!("status: {status}")
}
for _ in 0..warnings {
status.push_str(&status_icon("warn"));
}

pub fn severity_mark(severity: Severity) -> &'static str {
match severity {
Severity::Error => "❌",
Severity::Warn => "⚠️",
Severity::Info => "ℹ️",
}
for _ in 0..infos {
status.push_str(&status_icon("info"));
}

pub fn markdown_body(body: &str) -> String {
let mut formatted = String::with_capacity(body.len());
let mut token = String::new();
let mut in_code = false;

for ch in body.chars() {
if ch == '`' {
flush_token(&mut formatted, &mut token, in_code);
in_code = !in_code;
formatted.push(ch);
} else if is_token_char(ch) {
token.push(ch);
} else {
flush_token(&mut formatted, &mut token, in_code);
formatted.push(ch);
}
}
if status.is_empty() {
status.push_str(&status_icon(if label == "clean" { "pass" } else { "warn" }));
flush_token(&mut formatted, &mut token, in_code);
formatted
}

fn flush_token(formatted: &mut String, token: &mut String, in_code: bool) {
if token.is_empty() {
return;
}
let punctuation_len = token
.chars()
.rev()
.take_while(|ch| matches!(ch, '.' | ',' | ';' | ':' | ')' | ']'))
.map(char::len_utf8)
.sum::<usize>();
let core_len = token.len() - punctuation_len;
let (core, punctuation) = token.split_at(core_len);
if !in_code && should_code_format(core) {
formatted.push('`');
formatted.push_str(core);
formatted.push('`');
formatted.push_str(punctuation);
} else {
formatted.push_str(token);
}
format!("status: {status}")
token.clear();
}

fn status_icon(kind: &str) -> String {
format!("![{kind}]({STATUS_ICON_BASE_URL}/{kind}.svg)")
fn is_token_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '/' | '.' | '@')
}

fn should_code_format(token: &str) -> bool {
let uppercase_count = token.chars().filter(|ch| ch.is_ascii_uppercase()).count();
token.starts_with('@')
|| token.contains('/')
|| token.ends_with(".yml")
|| token.ends_with(".yaml")
|| token.ends_with(".json")
|| token.ends_with(".toml")
|| token.chars().any(|ch| ch == '_')
|| uppercase_count >= 2
}

pub fn append_status(body: &str, status: &str) -> String {
let trimmed = body.trim();
let status = status.trim();
if trimmed.is_empty() {
status.to_string()
} else if status.is_empty() {
trimmed.to_string()
} else {
format!("{trimmed}\n\n{status}")
}
Expand Down Expand Up @@ -357,6 +408,8 @@ mod tests {
assert!(prompt.contains("accountable humans"));
assert!(prompt.contains("self-dismissing findings"));
assert!(prompt.contains("empty summary string"));
assert!(prompt.contains("1-2 short sentences"));
assert!(prompt.contains("Wrap code identifiers"));
}

#[test]
Expand All @@ -367,10 +420,7 @@ mod tests {
usage: TokenUsage::default(),
model_used: "m".into(),
};
assert_eq!(
review_body(&clean, 0, "clean"),
"status: ![pass](https://postil.dev/status/pass.svg)"
);
assert_eq!(review_body(&clean, 0, "clean"), "");

let dirty = ReviewEnvelope {
summary: String::new(),
Expand All @@ -386,4 +436,57 @@ mod tests {
};
assert!(review_body(&dirty, 1, "needs-attention").contains("merge-relevant"));
}

#[test]
fn status_line_repeats_severity_marks_without_counters() {
let envelope = ReviewEnvelope {
summary: String::new(),
findings: vec![
Finding {
path: "src/lib.rs".into(),
line: 1,
severity: Severity::Info,
kind: None,
body: "context".into(),
},
Finding {
path: "src/lib.rs".into(),
line: 2,
severity: Severity::Warn,
kind: None,
body: "risk".into(),
},
Finding {
path: "src/lib.rs".into(),
line: 3,
severity: Severity::Error,
kind: None,
body: "bug".into(),
},
],
usage: TokenUsage::default(),
model_used: "m".into(),
};

assert_eq!(
status_line(&envelope, 3, "needs-attention"),
"status: ℹ️⚠️❌"
);
}

#[test]
fn markdown_body_formats_code_like_tokens_once() {
assert_eq!(
markdown_body("TRIGGER_PROJECT_ID is written to GITHUB_ENV by @postil-dev."),
"`TRIGGER_PROJECT_ID` is written to `GITHUB_ENV` by `@postil-dev`."
);
assert_eq!(
markdown_body("Already `GITHUB_ENV` stays as code."),
"Already `GITHUB_ENV` stays as code."
);
assert_eq!(
markdown_body("The fallback uses safe env handling."),
"The fallback uses safe env handling."
);
}
}