Skip to content
Merged
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
12 changes: 7 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 42 additions & 1 deletion src/forge/azure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion src/forge/bitbucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
}
}
59 changes: 30 additions & 29 deletions src/forge/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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(())
}
}
44 changes: 43 additions & 1 deletion src/forge/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
}
}
20 changes: 20 additions & 0 deletions src/forge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` in markdown comments
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading