From 14c3cd29d4aa34723f330e12a5be59e0181d9aec Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 13 Jun 2026 08:48:55 +0000 Subject: [PATCH 1/3] forge: add fetch_thread/post_comment to the Forge trait Move GitHub's respond helpers (fetch_issue/post_issue_comment) onto the Forge trait as fetch_thread and post_comment so non-GitHub forges can implement the interactive-bot path. A ThreadKind discriminates issue vs pull for forges that key them on different endpoints; GitHub ignores it (the issues API covers both) and its behavior is unchanged. --- src/forge/github.rs | 59 +++++++++++++++++++++++---------------------- src/forge/mod.rs | 20 +++++++++++++++ 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/forge/github.rs b/src/forge/github.rs index 7c7c1f3..8a569a9 100644 --- a/src/forge/github.rs +++ b/src/forge/github.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, anyhow}; use serde::Deserialize; use serde_json::json; -use super::{CheckState, Forge, PrMeta, check_summary, check_title}; +use super::{CheckState, Forge, PrMeta, ThreadKind, check_summary, check_title}; use crate::envelope::{Envelope, Finding, Severity}; pub struct GitHub { @@ -58,34 +58,6 @@ impl GitHub { let snippet: String = body.chars().take(300).collect(); Err(anyhow!("GitHub {what} failed: {status}: {snippet}")) } - - /// Title and body of an issue or PR (the issues API covers both). - pub async fn fetch_issue(&self, number: u64) -> Result<(String, String)> { - let resp = self - .request(reqwest::Method::GET, self.url(&format!("/issues/{number}"))) - .send() - .await - .context("fetching issue")?; - let v: serde_json::Value = Self::check_ok(resp, "issue fetch").await?.json().await?; - let title = v["title"].as_str().unwrap_or_default().to_string(); - let body = v["body"].as_str().unwrap_or_default().to_string(); - Ok((title, body)) - } - - /// Post a top-level comment on an issue or PR (the bot's reply to a mention). - pub async fn post_issue_comment(&self, number: u64, body: &str) -> Result<()> { - let resp = self - .request( - reqwest::Method::POST, - self.url(&format!("/issues/{number}/comments")), - ) - .json(&json!({ "body": body })) - .send() - .await - .context("posting comment")?; - Self::check_ok(resp, "comment post").await?; - Ok(()) - } } #[derive(Deserialize)] @@ -304,4 +276,33 @@ impl Forge for GitHub { } Ok(()) } + + /// Title and body of an issue or PR (the issues API covers both, so `kind` + /// is not needed here). + async fn fetch_thread(&self, number: u64, _kind: ThreadKind) -> Result<(String, String)> { + let resp = self + .request(reqwest::Method::GET, self.url(&format!("/issues/{number}"))) + .send() + .await + .context("fetching issue")?; + let v: serde_json::Value = Self::check_ok(resp, "issue fetch").await?.json().await?; + let title = v["title"].as_str().unwrap_or_default().to_string(); + let body = v["body"].as_str().unwrap_or_default().to_string(); + Ok((title, body)) + } + + /// Post a top-level comment on an issue or PR (the bot's reply to a mention). + async fn post_comment(&self, number: u64, _kind: ThreadKind, body: &str) -> Result<()> { + let resp = self + .request( + reqwest::Method::POST, + self.url(&format!("/issues/{number}/comments")), + ) + .json(&json!({ "body": body })) + .send() + .await + .context("posting comment")?; + Self::check_ok(resp, "comment post").await?; + Ok(()) + } } diff --git a/src/forge/mod.rs b/src/forge/mod.rs index baf1b8b..aabbe73 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -54,6 +54,17 @@ pub enum CheckState { Neutral, } +/// What a `respond` thread number points at. GitHub's issues API covers both, +/// so it ignores this; GitLab/Bitbucket/Azure key issues and PRs on different +/// endpoints, so they branch on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThreadKind { + /// A pull request / merge request. + Pull, + /// An issue / work item on the forge's issue tracker. + Issue, +} + #[allow(async_fn_in_trait)] pub trait Forge { /// True when the forge renders inline HTML `` in markdown comments @@ -81,6 +92,15 @@ pub trait Forge { gate: CheckState, envelope: &Envelope, ) -> Result<()>; + + /// Title and body of the issue/PR/MR a maintainer mentioned Postil on, used + /// to ground the answer (`postil respond`). `kind` disambiguates the number + /// for forges whose issues and pulls live on different endpoints. + async fn fetch_thread(&self, number: u64, kind: ThreadKind) -> Result<(String, String)>; + + /// Post a top-level comment (Postil's reply to a mention). `kind` selects the + /// issue- vs pull-level endpoint where the forge separates them. + async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()>; } pub fn check_title(envelope: &Envelope) -> String { From f43be89ebf214048df2d539cfbd9eaf288ec09b6 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 13 Jun 2026 08:49:02 +0000 Subject: [PATCH 2/3] respond: parity across GitLab, Bitbucket, and Azure DevOps Implement fetch_thread/post_comment for the remaining forges and rewrite postil respond to dispatch through the trait for every host, dropping the GitHub-only guard. GitLab covers both issues and MRs (issues/notes and merge_requests/notes). Bitbucket (pullrequests/comments) and Azure DevOps (pullRequests/threads) are scoped to pull requests: their issue-tracker and work-item comment endpoints use a different base/version we cannot verify against a live host, so those paths return a clear error and carry a TODO. Scope stays review-and-answer only with the Postil model footer. --- src/cli.rs | 3 +- src/forge/azure.rs | 43 ++++++++++- src/forge/bitbucket.rs | 38 ++++++++- src/forge/gitlab.rs | 44 ++++++++++- src/respond.rs | 170 ++++++++++++++++++++++++++++++----------- 5 files changed, 248 insertions(+), 50 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 6017002..e93f62d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,7 +87,8 @@ pub enum Command { }, /// Reply to an @postil mention on a pull request or issue (interactive bot). Respond { - /// Code host. Only github is supported today. + /// Code host. GitHub and GitLab support PRs/MRs and issues; Bitbucket + /// and Azure DevOps support pull requests only. #[arg(long, value_enum, default_value = "github")] forge: ForgeArg, /// Repository as owner/name. diff --git a/src/forge/azure.rs b/src/forge/azure.rs index 6792bc2..df5b1d7 100644 --- a/src/forge/azure.rs +++ b/src/forge/azure.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result, anyhow}; use serde::Deserialize; use serde_json::json; -use super::{CheckState, Forge, PrMeta, check_summary, check_title}; +use super::{CheckState, Forge, PrMeta, ThreadKind, check_summary, check_title}; use crate::envelope::{Envelope, Finding}; const API_VERSION: &str = "7.1"; @@ -435,6 +435,47 @@ impl Forge for Azure { .await?; Ok(()) } + + /// Title and description of a PR. Work-item comments live under a different + /// `_apis/wit` base and api-version than the git endpoints this client uses, + /// so respond is scoped to pull requests. + async fn fetch_thread(&self, _number: u64, kind: ThreadKind) -> Result<(String, String)> { + // TODO(respond): Azure work-item comments + // (`_apis/wit/workItems/{id}/comments?api-version=7.0-preview.3`, body + // field `text`) use a different base/version; scope respond to PRs. + if kind == ThreadKind::Issue { + return Err(anyhow!( + "postil respond on Azure DevOps supports --pr only (work items not supported)" + )); + } + let pr = self.pr().await?; + Ok((pr.title, pr.description)) + } + + /// Post a top-level PR comment as a non-anchored thread (the bot's reply to + /// a mention) — the same threads endpoint the reviewer uses for its summary. + async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()> { + // TODO(respond): Azure work-item comments are unverified here; scope to PRs. + if kind == ThreadKind::Issue { + return Err(anyhow!( + "postil respond on Azure DevOps supports --pr only (work items not supported)" + )); + } + let resp = self + .request( + reqwest::Method::POST, + self.url(&format!("/pullRequests/{number}/threads"), ""), + ) + .json(&json!({ + "comments": [{ "parentCommentId": 0, "commentType": 1, "content": body }], + "status": 1, + })) + .send() + .await + .context("posting comment")?; + Self::check_ok(resp, "comment post").await?; + Ok(()) + } } /// Both merge commits, or an actionable error: Azure omits them on PRs with diff --git a/src/forge/bitbucket.rs b/src/forge/bitbucket.rs index ab02b9f..ddfbde9 100644 --- a/src/forge/bitbucket.rs +++ b/src/forge/bitbucket.rs @@ -12,7 +12,7 @@ use anyhow::{Context, Result, anyhow}; use serde::Deserialize; use serde_json::json; -use super::{CheckState, Forge, PrMeta, check_summary, check_title}; +use super::{CheckState, Forge, PrMeta, ThreadKind, check_summary, check_title}; use crate::envelope::{Envelope, Finding}; pub struct Bitbucket { @@ -308,4 +308,40 @@ impl Forge for Bitbucket { .await?; Ok(()) } + + /// Title and description of a PR. Bitbucket Cloud's issue tracker is a + /// separate, often-disabled product with a different object shape we cannot + /// verify against a live instance, so respond is scoped to pull requests. + async fn fetch_thread(&self, _number: u64, kind: ThreadKind) -> Result<(String, String)> { + // TODO(respond): Bitbucket issue-tracker comments + // (`/issues/{id}` + `/issues/{id}/comments`) are unverified; scope to PRs. + if kind == ThreadKind::Issue { + return Err(anyhow!( + "postil respond on Bitbucket supports --pr only (issue tracker not supported)" + )); + } + let pr = self.pr_meta().await?; + Ok((pr.title, pr.summary.map(|s| s.raw).unwrap_or_default())) + } + + /// Post a top-level comment on a PR (the bot's reply to a mention). + async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()> { + // TODO(respond): Bitbucket issue-tracker comments are unverified; scope to PRs. + if kind == ThreadKind::Issue { + return Err(anyhow!( + "postil respond on Bitbucket supports --pr only (issue tracker not supported)" + )); + } + let resp = self + .request( + reqwest::Method::POST, + self.url(&format!("/pullrequests/{number}/comments")), + ) + .json(&json!({ "content": { "raw": body } })) + .send() + .await + .context("posting comment")?; + Self::check_ok(resp, "comment post").await?; + Ok(()) + } } diff --git a/src/forge/gitlab.rs b/src/forge/gitlab.rs index dacdc42..7648d42 100644 --- a/src/forge/gitlab.rs +++ b/src/forge/gitlab.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result, anyhow}; use serde::Deserialize; use serde_json::json; -use super::{CheckState, Forge, PrMeta, check_summary, check_title}; +use super::{CheckState, Forge, PrMeta, ThreadKind, check_summary, check_title}; use crate::envelope::{Envelope, Finding}; pub struct GitLab { @@ -313,4 +313,46 @@ impl Forge for GitLab { .await?; Ok(()) } + + /// Title and description of an issue or MR. GitLab's issue and merge-request + /// objects both expose `title` and `description`, so only the resource path + /// differs by `kind`. + async fn fetch_thread(&self, number: u64, kind: ThreadKind) -> Result<(String, String)> { + let resource = match kind { + ThreadKind::Pull => "merge_requests", + ThreadKind::Issue => "issues", + }; + let resp = self + .request( + reqwest::Method::GET, + self.url(&format!("/{resource}/{number}")), + ) + .send() + .await + .context("fetching thread")?; + let v: serde_json::Value = Self::check_ok(resp, "thread fetch").await?.json().await?; + let title = v["title"].as_str().unwrap_or_default().to_string(); + let body = v["description"].as_str().unwrap_or_default().to_string(); + Ok((title, body)) + } + + /// Post a top-level note on an issue or MR (the bot's reply to a mention). + /// Both resources expose `/{resource}/{number}/notes` with a `body` field. + async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()> { + let resource = match kind { + ThreadKind::Pull => "merge_requests", + ThreadKind::Issue => "issues", + }; + let resp = self + .request( + reqwest::Method::POST, + self.url(&format!("/{resource}/{number}/notes")), + ) + .json(&json!({ "body": body })) + .send() + .await + .context("posting note")?; + Self::check_ok(resp, "note post").await?; + Ok(()) + } } diff --git a/src/respond.rs b/src/respond.rs index 9a784e0..754c8ce 100644 --- a/src/respond.rs +++ b/src/respond.rs @@ -1,8 +1,10 @@ //! Interactive bot: reply to an @postil mention on a PR or issue. //! //! Scope is review and answer only — Postil never opens PRs or pushes commits. -//! v1 targets GitHub (the same `GITHUB_TOKEN`/`GITHUB_API_URL` the reviewer -//! uses); other forges return a clear "not yet supported" error. +//! Works across every forge the reviewer supports. PR/MR mentions are grounded +//! on the diff; issue mentions on the issue body. GitHub and GitLab cover both +//! issues and pulls; Bitbucket and Azure DevOps are scoped to PRs (their issue +//! trackers / work items use endpoints we cannot verify against a live host). use std::path::PathBuf; @@ -10,7 +12,9 @@ use anyhow::{Context, Result, anyhow}; use crate::config::Config; use crate::diff; -use crate::forge::{Forge, github::GitHub}; +use crate::forge::{ + Forge, ThreadKind, azure::Azure, bitbucket::Bitbucket, github::GitHub, gitlab::GitLab, +}; use crate::llm::LlmClient; use crate::prompt; use crate::review::ForgeKind; @@ -39,16 +43,11 @@ pub async fn run(args: RespondArgs) -> Result { if let Some(m) = &args.model { cfg.model = m.clone(); } - if args.forge != ForgeKind::GitHub { - return Err(anyhow!( - "postil respond currently supports --forge github only" - )); - } let repo = args .repo .clone() .or_else(|| std::env::var("GITHUB_REPOSITORY").ok()) - .ok_or_else(|| anyhow!("--repo owner/name is required"))?; + .ok_or_else(|| anyhow!("--repo is required"))?; let comment = args .comment .clone() @@ -56,59 +55,138 @@ pub async fn run(args: RespondArgs) -> Result { .filter(|c| !c.trim().is_empty()) .ok_or_else(|| anyhow!("the mention text is required: --comment or POSTIL_COMMENT"))?; - // PR mentions get the diff for grounding; issue mentions get the issue body. - let (number, context) = if let Some(pr) = args.pr { - let forge = GitHub::new(&repo, pr)?; - let meta = forge.fetch_pr_meta().await?; - let raw = forge.fetch_diff().await.context("fetching PR diff")?; - let parsed = diff::parse(&raw); - let (annotated, truncated) = diff::render_annotated(&parsed, MAX_DIFF_BYTES); - let mut ctx = format!( - "Context: pull request #{pr} in {repo}\nTitle: {}\n", - meta.title - ); - if !meta.body.trim().is_empty() { - let body: String = meta.body.chars().take(1500).collect(); - ctx.push_str(&format!("Description:\n{body}\n")); + // The number the mention is on, and whether it is a PR/MR or an issue. + let (number, kind) = match (args.pr, args.issue) { + (Some(pr), _) => (pr, ThreadKind::Pull), + (None, Some(issue)) => (issue, ThreadKind::Issue), + (None, None) => return Err(anyhow!("one of --pr or --issue is required")), + }; + + // Same flow for every forge; the trait carries the per-host endpoints. The + // forge is monomorphized (the trait uses `async fn` and is not dyn-safe), so + // dispatch by kind here, exactly as the reviewer does. + match args.forge { + ForgeKind::GitHub => { + respond_with( + GitHub::new(&repo, number)?, + &cfg, + &repo, + number, + kind, + &comment, + args.no_post, + ) + .await + } + ForgeKind::GitLab => { + respond_with( + GitLab::new(&repo, number)?, + &cfg, + &repo, + number, + kind, + &comment, + args.no_post, + ) + .await } - ctx.push_str("\nDiff (left-margin numbers are new-file lines):\n\n"); - ctx.push_str(&annotated); - if truncated { - ctx.push_str("\n[diff truncated at the size limit]\n"); + ForgeKind::Bitbucket => { + respond_with( + Bitbucket::new(&repo, number)?, + &cfg, + &repo, + number, + kind, + &comment, + args.no_post, + ) + .await } - (pr, ctx) - } else if let Some(issue) = args.issue { - // The issues API needs any PR number for construction; issue ops ignore it. - let forge = GitHub::new(&repo, issue)?; - let (title, body) = forge.fetch_issue(issue).await?; - let body: String = body.chars().take(4000).collect(); - ( - issue, - format!("Context: issue #{issue} in {repo}\nTitle: {title}\n\nIssue body:\n{body}\n"), - ) - } else { - return Err(anyhow!("one of --pr or --issue is required")); - }; + ForgeKind::Azure => { + respond_with( + Azure::new(&repo, number)?, + &cfg, + &repo, + number, + kind, + &comment, + args.no_post, + ) + .await + } + ForgeKind::Local => Err(anyhow!("postil respond needs a remote forge, not --local")), + } +} - let system = prompt::respond_system_prompt(&cfg); +#[allow(clippy::too_many_arguments)] +async fn respond_with( + forge: F, + cfg: &Config, + repo: &str, + number: u64, + kind: ThreadKind, + comment: &str, + no_post: bool, +) -> Result { + let context = build_context(&forge, repo, number, kind).await?; + + let system = prompt::respond_system_prompt(cfg); let user = format!( "{context}\n--- Maintainer's message to you ---\n{}\n\nReply to the message above.", comment.trim() ); - let client = LlmClient::from_env(&cfg)?; - let (answer, model_used) = client.answer(&cfg, &system, &user).await?; + let client = LlmClient::from_env(cfg)?; + let (answer, model_used) = client.answer(cfg, &system, &user).await?; let reply = format!("{answer}\n\nPostil · {model_used}"); - if args.no_post { + if no_post { println!("{reply}"); } else { - let forge = GitHub::new(&repo, number)?; forge - .post_issue_comment(number, &reply) + .post_comment(number, kind, &reply) .await .context("posting reply")?; eprintln!("postil: replied on {repo}#{number}"); } Ok(0) } + +/// Build the grounding context for the model: a PR/MR mention gets the annotated +/// diff, an issue mention gets the issue body. +async fn build_context( + forge: &F, + repo: &str, + number: u64, + kind: ThreadKind, +) -> Result { + match kind { + ThreadKind::Pull => { + let meta = forge.fetch_pr_meta().await?; + let raw = forge.fetch_diff().await.context("fetching PR diff")?; + let parsed = diff::parse(&raw); + let (annotated, truncated) = diff::render_annotated(&parsed, MAX_DIFF_BYTES); + let mut ctx = format!( + "Context: pull request #{number} in {repo}\nTitle: {}\n", + meta.title + ); + if !meta.body.trim().is_empty() { + let body: String = meta.body.chars().take(1500).collect(); + ctx.push_str(&format!("Description:\n{body}\n")); + } + ctx.push_str("\nDiff (left-margin numbers are new-file lines):\n\n"); + ctx.push_str(&annotated); + if truncated { + ctx.push_str("\n[diff truncated at the size limit]\n"); + } + Ok(ctx) + } + ThreadKind::Issue => { + let (title, body) = forge.fetch_thread(number, kind).await?; + let body: String = body.chars().take(4000).collect(); + Ok(format!( + "Context: issue #{number} in {repo}\nTitle: {title}\n\nIssue body:\n{body}\n" + )) + } + } +} From 624e79352664dc749cad0134e59d213caa80ca41 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 13 Jun 2026 08:49:06 +0000 Subject: [PATCH 3/3] test: e2e respond coverage for GitLab, Bitbucket, and Azure Add wiremock tests covering a GitLab MR mention (reply posted to merge_requests/notes), a GitLab issue mention (grounded on the issue body, reply to issues/notes), a Bitbucket PR mention (pullrequests/comments), and an Azure PR mention (pullRequests/threads), each asserting the model footer. Update ROADMAP to reflect respond parity shipping. --- ROADMAP.md | 12 ++- tests/e2e.rs | 282 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 63f51c1..ea1a139 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,8 +7,9 @@ - Two-check semantics (postil/review advisory, postil/gate blocking); `gate.onError: block|advisory` for fail-open on provider outages. - Incremental re-review with resolved/carried reconciliation. -- Interactive bot (`postil respond`): replies to @postil mentions on PRs and issues. - Review-and-answer only — never opens PRs or pushes. +- Interactive bot (`postil respond`): replies to @postil mentions across every forge. + GitHub and GitLab cover PRs/MRs and issues; Bitbucket and Azure DevOps cover pull + requests. Review-and-answer only — never opens PRs or pushes. - SARIF 2.1.0 output (`--sarif`) for code-scanning ingestion. - Repo guardrails file (`.postil/guardrails.md`) injected into the prompt; violations surface as `kind: guardrail` findings that quote the rule. @@ -34,9 +35,10 @@ large PRs; concurrent per-file content fetches for the Azure reconstruction. - Learning from dismissals: feed comment-resolution outcomes from the hosted platform back into per-repo suppression hints. -- `postil respond` parity on GitLab/Bitbucket/Azure (today it is GitHub-only), and a - visible error reply when the hosted bot exhausts its retries (today a dead respond - job is only logged). +- `postil respond` on the Bitbucket issue tracker and Azure DevOps work items (today + scoped to PRs there; those comment endpoints use a different base/version we have not + confirmed against a live host), and a visible error reply when the hosted bot exhausts + its retries (today a dead respond job is only logged). - An `/evidence` benchmark: Postil's own silence rate and confirmed-finding rate on public OSS PRs, with raw envelopes. diff --git a/tests/e2e.rs b/tests/e2e.rs index 1774fa8..822a71e 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1097,6 +1097,288 @@ async fn respond_to_issue_mention_uses_issue_body() { })); } +#[tokio::test] +async fn respond_gitlab_mr_mention_posts_note() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(llm_text( + "Line 41 interpolates `user_input` into the query — parameterize it.", + ))) + .mount(&server) + .await; + // MR metadata (title/description + diff refs) for grounding. + Mock::given(method("GET")) + .and(path_regex(r"^/projects/.+/merge_requests/5$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "title": "Add login", + "description": "MR body", + "diff_refs": {"base_sha": "b", "start_sha": "s", "head_sha": "h"} + }))) + .mount(&server) + .await; + // MR file diffs (paginated; one short page ends iteration). + Mock::given(method("GET")) + .and(path_regex(r"^/projects/.+/merge_requests/5/diffs$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ + "old_path": "src/auth.rs", "new_path": "src/auth.rs", + "diff": "@@ -40,6 +40,8 @@ fn login() {\n context line\n+let token = format!(\"{}\", user_input);\n+exec_query(&token);\n trailing context\n", + "new_file": false, "deleted_file": false + }]))) + .mount(&server) + .await; + // The reply note endpoint. + Mock::given(method("POST")) + .and(path_regex(r"^/projects/.+/merge_requests/5/notes$")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .env("GITLAB_API_URL", server.uri()) + .env("GITLAB_TOKEN", "gl-test-token") + .args([ + "respond", + "--forge", + "gitlab", + "--repo", + "acme/api", + "--pr", + "5", + "--comment", + "@postil is this safe?", + ]) + .assert() + .success(); + + let reqs = server.received_requests().await.unwrap(); + let note = reqs + .iter() + .find(|r| { + r.method == wiremock::http::Method::POST + && r.url.path().ends_with("/merge_requests/5/notes") + }) + .expect("reply note posted to the MR"); + let body: Value = note.body_json().unwrap(); + let text = body["body"].as_str().unwrap(); + assert!(text.contains("parameterize")); + assert!(text.contains("Postil ·")); // footer with model attribution +} + +#[tokio::test] +async fn respond_gitlab_issue_mention_uses_issue_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(llm_text( + "This looks like connection-pool exhaustion under load, not a logic bug.", + ))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(r"^/projects/.+/issues/9$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "title": "Timeouts under load", + "description": "Requests hang after ~200 concurrent users." + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path_regex(r"^/projects/.+/issues/9/notes$")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .env("GITLAB_API_URL", server.uri()) + .env("GITLAB_TOKEN", "gl-test-token") + .args([ + "respond", + "--forge", + "gitlab", + "--repo", + "acme/api", + "--issue", + "9", + "--comment", + "@postil what is happening?", + ]) + .assert() + .success(); + + let reqs = server.received_requests().await.unwrap(); + // The model was grounded on the issue body. + let llm = reqs + .iter() + .find(|r| r.url.path() == "/chat/completions") + .unwrap(); + let sent: Value = llm.body_json().unwrap(); + let user_msg = sent["messages"][1]["content"].as_str().unwrap(); + assert!(user_msg.contains("200 concurrent users")); + // The reply landed on the issue notes endpoint, not the MR one. + assert!(reqs.iter().any(|r| { + r.method == wiremock::http::Method::POST && r.url.path().ends_with("/issues/9/notes") + })); +} + +#[tokio::test] +async fn respond_bitbucket_pr_mention_posts_comment() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(llm_text( + "Line 41 interpolates `user_input` — that is the injection risk.", + ))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repositories/acme/api/pullrequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "title": "Add login", + "summary": {"raw": "PR body"}, + "source": {"commit": {"hash": "headsha111"}}, + "destination": {"commit": {"hash": "basesha222"}} + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repositories/acme/api/pullrequests/7/diff")) + .respond_with(ResponseTemplate::new(200).set_body_string(DIFF)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/repositories/acme/api/pullrequests/7/comments")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .env("BITBUCKET_API_URL", server.uri()) + .env("BITBUCKET_TOKEN", "bb-test-token") + .env_remove("BITBUCKET_USER") + .args([ + "respond", + "--forge", + "bitbucket", + "--repo", + "acme/api", + "--pr", + "7", + "--comment", + "@postil is this safe?", + ]) + .assert() + .success(); + + let reqs = server.received_requests().await.unwrap(); + let comment = reqs + .iter() + .find(|r| { + r.method == wiremock::http::Method::POST + && r.url.path() == "/repositories/acme/api/pullrequests/7/comments" + }) + .expect("reply comment posted to the PR"); + let body: Value = comment.body_json().unwrap(); + let text = body["content"]["raw"].as_str().unwrap(); + assert!(text.contains("injection risk")); + assert!(text.contains("Postil ·")); +} + +#[tokio::test] +async fn respond_azure_pr_mention_posts_thread() { + let server = MockServer::start().await; + let old_content = "fn login() {\n let token = sanitize(user_input);\n}\n"; + let new_content = "fn login() {\n let token = user_input;\n}\n"; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(llm_text( + "Line 2 drops the sanitize() call — that is the risk.", + ))) + .mount(&server) + .await; + let pr_path = "/myorg/myproj/_apis/git/repositories/myrepo/pullRequests/7"; + Mock::given(method("GET")) + .and(path(pr_path)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "title": "Add login", "description": "PR body", + "lastMergeSourceCommit": {"commitId": "HEAD"}, + "lastMergeTargetCommit": {"commitId": "BASE"} + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/myorg/myproj/_apis/git/repositories/myrepo/diffs/commits", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "changes": [{"item": {"path": "/src/auth.rs"}, "changeType": "edit"}] + }))) + .mount(&server) + .await; + let items_path = "/myorg/myproj/_apis/git/repositories/myrepo/items"; + Mock::given(method("GET")) + .and(path(items_path)) + .and(query_param("version", "BASE")) + .respond_with(ResponseTemplate::new(200).set_body_string(old_content)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(items_path)) + .and(query_param("version", "HEAD")) + .respond_with(ResponseTemplate::new(200).set_body_string(new_content)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path( + "/myorg/myproj/_apis/git/repositories/myrepo/pullRequests/7/threads", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .env("AZURE_DEVOPS_API_URL", server.uri()) + .env("AZURE_DEVOPS_TOKEN", "az-test-pat") + .args([ + "respond", + "--forge", + "azure", + "--repo", + "myorg/myproj/myrepo", + "--pr", + "7", + "--comment", + "@postil is this safe?", + ]) + .assert() + .success(); + + let reqs = server.received_requests().await.unwrap(); + let thread = reqs + .iter() + .find(|r| { + r.method == wiremock::http::Method::POST + && r.url.path().ends_with("/pullRequests/7/threads") + }) + .expect("reply thread posted to the PR"); + let body: Value = thread.body_json().unwrap(); + let text = body["comments"][0]["content"].as_str().unwrap(); + assert!(text.contains("risk")); + assert!(text.contains("Postil ·")); +} + #[test] fn init_writes_starter_and_config_shows_provenance() { let dir = tempfile::tempdir().unwrap();