From ce237555364f6d03528c7a785ddf9c830f96fe49 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 25 Jun 2026 03:14:26 -0500 Subject: [PATCH 1/3] Introduce headless contract, webhook parsing, and repo client Add a locked headless execution contract (v1) with doc, JSON schemas, and golden fixtures. Make session briefs tokenless and enforce COVEN_GIT_TOKEN as the git-auth channel. Add HEADLESS_CONTRACT_VERSION, contract_version fields to brief/result types, and conformance tests. Implement repo metadata client to resolve branch/PR SHAs and wire it into worker task execution (CheckRun target, brief default_branch, PR base ref). Enhance webhook parsing/routing: PR review events, PR comment detection, ping handling, and robust mention matching. Several tests and fixtures added; update docs (security, self-hosting, README). --- COVEN-GITHUB.md | 11 + README.md | 7 +- crates/github/src/lib.rs | 50 +++- crates/github/src/repo.rs | 225 +++++++++++++++ crates/github/src/tasks.rs | 1 + crates/webhook/src/events.rs | 34 +++ crates/webhook/src/routes.rs | 223 ++++++++++++++- .../tests/fixtures/issue_assigned.json | 18 ++ .../tests/fixtures/issue_comment_created.json | 22 ++ .../tests/fixtures/issue_comment_on_pr.json | 26 ++ .../webhook/tests/fixtures/issue_labeled.json | 18 ++ crates/webhook/tests/fixtures/ping.json | 20 ++ .../pull_request_review_comment_created.json | 22 ++ .../pull_request_review_submitted.json | 21 ++ crates/webhook/tests/parse_fixtures.rs | 128 +++++++++ crates/worker/src/brief.rs | 100 ++++++- crates/worker/src/lib.rs | 86 +++++- crates/worker/tests/contract.rs | 97 +++++++ docs/app-manifest.json | 26 ++ docs/contracts/result.example.json | 12 + docs/contracts/result.schema.json | 44 +++ docs/contracts/session-brief.example.json | 25 ++ docs/contracts/session-brief.schema.json | 89 ++++++ docs/headless-contract.md | 258 ++++++++++++++++++ docs/security.md | 6 +- docs/self-hosting.md | 29 ++ 26 files changed, 1549 insertions(+), 49 deletions(-) create mode 100644 crates/github/src/repo.rs create mode 100644 crates/webhook/tests/fixtures/issue_assigned.json create mode 100644 crates/webhook/tests/fixtures/issue_comment_created.json create mode 100644 crates/webhook/tests/fixtures/issue_comment_on_pr.json create mode 100644 crates/webhook/tests/fixtures/issue_labeled.json create mode 100644 crates/webhook/tests/fixtures/ping.json create mode 100644 crates/webhook/tests/fixtures/pull_request_review_comment_created.json create mode 100644 crates/webhook/tests/fixtures/pull_request_review_submitted.json create mode 100644 crates/webhook/tests/parse_fixtures.rs create mode 100644 crates/worker/tests/contract.rs create mode 100644 docs/app-manifest.json create mode 100644 docs/contracts/result.example.json create mode 100644 docs/contracts/result.schema.json create mode 100644 docs/contracts/session-brief.example.json create mode 100644 docs/contracts/session-brief.schema.json create mode 100644 docs/headless-contract.md diff --git a/COVEN-GITHUB.md b/COVEN-GITHUB.md index e8b7350..0a3eab0 100644 --- a/COVEN-GITHUB.md +++ b/COVEN-GITHUB.md @@ -116,6 +116,17 @@ The same lifecycle in operational prose: ## coven-code Delta: Headless Mode +> **The normative, locked contract lives in [`docs/headless-contract.md`](docs/headless-contract.md) +> (contract version `1`).** That document is the single source of truth, with +> machine-checkable JSON Schemas and golden fixtures in [`docs/contracts/`](docs/contracts/) +> and a conformance test (`crates/worker/tests/contract.rs`). The summary below +> is illustrative; where it disagrees with the contract, the contract wins. In +> particular the contract supersedes this section on three points: the session +> brief is **tokenless** (git auth flows via the `COVEN_GIT_TOKEN` env var, not +> `GIT_ASKPASS`/an embedded `auth.token`), the brief carries a `task` tagged +> union (not an `issue` object), and the v1 `result.json` envelope does **not** +> include an `events` array (progress streaming is deferred to M2). + The execution runtime needs the following additions to work as a GitHub App backend: ### `--headless` flag diff --git a/README.md b/README.md index a828147..b2b39b2 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,10 @@ For deeper system, sequence, state, security-boundary, and hosted deployment dia | Label trigger | Implemented | Routes configured `trigger_labels` such as `coven:fix`. | | Issue / PR mention trigger | Implemented | Ignores familiar bot self-comments to avoid loops. | | GitHub App installation tokens | Implemented | Mints installation access tokens from the App private key. | -| Check Run creation and completion | Partial | Creates and updates Check Runs; branch/SHA resolution still needs production hardening. | -| `coven-code --headless` execution | Partial | Worker spawns headless sessions and enforces task timeouts; result quality depends on the runtime. | -| Pull request creation | Partial | Opens draft PRs from session results; base branch is still hardcoded to `main`. | +| Check Run creation and completion | Partial | Creates and updates Check Runs against the resolved target head SHA; stale-ref revalidation before publish is still planned. | +| Headless execution contract | Locked (v1) | Brief, result envelope, exit codes, and git-auth channel are pinned in [`docs/headless-contract.md`](docs/headless-contract.md) with JSON Schemas, golden fixtures, and a conformance test. | +| `coven-code --headless` execution | Partial | Worker spawns headless sessions with a tokenless session brief and enforces task timeouts; result quality depends on the runtime. | +| Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. | | CovenCave task polling | Partial | In-memory task API exists for local oversight; hosted control-plane auth and persistence are planned. | | Durable queue / task store | Planned | Required for hosted reliability and restarts. | | Hosted tier | Planned | See [Hosted vs self-hosted](docs/hosted-vs-self-hosted.md). | diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index b4c2605..70358a8 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -5,9 +5,18 @@ use serde::{Deserialize, Serialize}; pub mod check_run; pub mod installation; pub mod pr; +pub mod repo; pub mod tasks; pub const DEFAULT_API_BASE_URL: &str = "https://api.github.com"; + +/// Major version of the coven-code headless execution contract this adapter +/// speaks. See `docs/headless-contract.md`. Bump only on breaking changes. +pub const HEADLESS_CONTRACT_VERSION: &str = "1"; + +fn default_contract_version() -> String { + HEADLESS_CONTRACT_VERSION.to_string() +} const GITHUB_API_VERSION: &str = "2026-03-10"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,14 +44,16 @@ async fn send_json( request: GitHubRequest, ) -> anyhow::Result { let method = reqwest::Method::from_bytes(request.method.as_bytes())?; - let response = client + let mut builder = client .request(method, api_url(base_url, &request.path)) .bearer_auth(token) .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", GITHUB_API_VERSION) - .json(&request.body) - .send() - .await?; + .header("X-GitHub-Api-Version", GITHUB_API_VERSION); + // GET/metadata requests carry no body; only attach JSON for mutations. + if !request.body.is_null() { + builder = builder.json(&request.body); + } + let response = builder.send().await?; if !response.status().is_success() { let status = response.status(); @@ -60,7 +71,10 @@ pub enum GitHubEvent { IssueAssigned(IssueAssignedEvent), IssueLabeled(IssueLabeledEvent), IssueComment(IssueCommentEvent), + PullRequestReview(PrReviewEvent), PullRequestReviewComment(PrReviewCommentEvent), + /// `ping` delivery GitHub sends when a webhook is first configured. + Ping, Unsupported { name: String }, } @@ -94,6 +108,27 @@ pub struct IssueCommentEvent { pub issue_number: u64, pub comment_body: String, pub commenter_login: String, + /// `issue_comment` fires for pull-request conversation comments as well as + /// issue comments. GitHub flags the former with an `issue.pull_request` + /// object; this lets routing send PR comments through PR iteration rather + /// than issue-mention handling. + pub on_pull_request: bool, +} + +/// Top-level pull request review submission (`pull_request_review` → `submitted`). +/// +/// Distinct from [`PrReviewCommentEvent`], which is a single inline comment on a +/// diff hunk. This carries the review summary body and verdict (state). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PrReviewEvent { + pub installation_id: u64, + pub repo_owner: String, + pub repo_name: String, + pub pr_number: u64, + pub review_body: String, + /// Review verdict: `approved`, `changes_requested`, or `commented`. + pub review_state: String, + pub reviewer_login: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -139,6 +174,11 @@ pub enum TaskKind { /// Structured result envelope written by coven-code --headless. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SessionResult { + /// Contract major version. Defaults to the current version when a runtime + /// omits it, but conformant producers MUST emit it. See + /// `docs/headless-contract.md`. + #[serde(default = "default_contract_version")] + pub contract_version: String, pub status: SessionStatus, pub branch: Option, pub commits: Vec, diff --git a/crates/github/src/repo.rs b/crates/github/src/repo.rs new file mode 100644 index 0000000..529e5da --- /dev/null +++ b/crates/github/src/repo.rs @@ -0,0 +1,225 @@ +//! GitHub repository, branch, and pull request metadata client. +//! +//! Used to resolve the target refs a task operates on instead of relying on +//! placeholders like `"HEAD"` or a hardcoded `"main"` base branch. + +use anyhow::Result; +use serde::Deserialize; + +use crate::{client, send_json, GitHubRequest, DEFAULT_API_BASE_URL}; + +/// Repository metadata we care about for routing and publication. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RepoMetadata { + pub default_branch: String, +} + +/// Pull request refs needed to attach checks and open/update PRs correctly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PullRequestRefs { + pub head_ref: String, + pub head_sha: String, + pub base_ref: String, + pub base_sha: String, +} + +#[derive(Debug, Deserialize)] +struct BranchResponse { + commit: CommitRef, +} + +#[derive(Debug, Deserialize)] +struct CommitRef { + sha: String, +} + +#[derive(Debug, Deserialize)] +struct PullRequestMetaResponse { + head: PrRef, + base: PrRef, +} + +#[derive(Debug, Deserialize)] +struct PrRef { + #[serde(rename = "ref")] + ref_name: String, + sha: String, +} + +/// Fetches repository metadata (default branch, etc.). +pub async fn get_repo(installation_token: &str, owner: &str, name: &str) -> Result { + get_repo_with_base_url(DEFAULT_API_BASE_URL, installation_token, owner, name).await +} + +pub async fn get_repo_with_base_url( + api_base_url: &str, + installation_token: &str, + owner: &str, + name: &str, +) -> Result { + let client = client()?; + let response = send_json( + &client, + api_base_url, + installation_token, + get_repo_request(owner, name), + ) + .await?; + Ok(response.json().await?) +} + +/// Resolves the current commit SHA at the tip of a branch. +pub async fn get_branch_sha( + installation_token: &str, + owner: &str, + name: &str, + branch: &str, +) -> Result { + get_branch_sha_with_base_url( + DEFAULT_API_BASE_URL, + installation_token, + owner, + name, + branch, + ) + .await +} + +pub async fn get_branch_sha_with_base_url( + api_base_url: &str, + installation_token: &str, + owner: &str, + name: &str, + branch: &str, +) -> Result { + let client = client()?; + let response = send_json( + &client, + api_base_url, + installation_token, + get_branch_request(owner, name, branch), + ) + .await?; + let body: BranchResponse = response.json().await?; + Ok(body.commit.sha) +} + +/// Fetches the head/base refs and SHAs for a pull request. +pub async fn get_pull_request_refs( + installation_token: &str, + owner: &str, + name: &str, + pr_number: u64, +) -> Result { + get_pull_request_refs_with_base_url( + DEFAULT_API_BASE_URL, + installation_token, + owner, + name, + pr_number, + ) + .await +} + +pub async fn get_pull_request_refs_with_base_url( + api_base_url: &str, + installation_token: &str, + owner: &str, + name: &str, + pr_number: u64, +) -> Result { + let client = client()?; + let response = send_json( + &client, + api_base_url, + installation_token, + get_pull_request_request(owner, name, pr_number), + ) + .await?; + let body: PullRequestMetaResponse = response.json().await?; + Ok(PullRequestRefs { + head_ref: body.head.ref_name, + head_sha: body.head.sha, + base_ref: body.base.ref_name, + base_sha: body.base.sha, + }) +} + +fn get_repo_request(owner: &str, name: &str) -> GitHubRequest { + GitHubRequest { + method: "GET", + path: format!("/repos/{owner}/{name}"), + body: serde_json::Value::Null, + } +} + +fn get_branch_request(owner: &str, name: &str, branch: &str) -> GitHubRequest { + GitHubRequest { + method: "GET", + path: format!("/repos/{owner}/{name}/branches/{branch}"), + body: serde_json::Value::Null, + } +} + +fn get_pull_request_request(owner: &str, name: &str, pr_number: u64) -> GitHubRequest { + GitHubRequest { + method: "GET", + path: format!("/repos/{owner}/{name}/pulls/{pr_number}"), + body: serde_json::Value::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn get_repo_request_targets_repo_endpoint() { + let request = get_repo_request("octo", "repo"); + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/repos/octo/repo"); + } + + #[test] + fn get_branch_request_targets_branch_endpoint() { + let request = get_branch_request("octo", "repo", "develop"); + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/repos/octo/repo/branches/develop"); + } + + #[test] + fn get_pull_request_request_targets_pulls_endpoint() { + let request = get_pull_request_request("octo", "repo", 7); + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/repos/octo/repo/pulls/7"); + } + + #[test] + fn repo_metadata_deserializes_default_branch() { + let meta: RepoMetadata = + serde_json::from_value(json!({ "default_branch": "master", "id": 99 })).unwrap(); + assert_eq!(meta.default_branch, "master"); + } + + #[test] + fn branch_response_extracts_commit_sha() { + let body: BranchResponse = + serde_json::from_value(json!({ "name": "main", "commit": { "sha": "abc123" } })) + .unwrap(); + assert_eq!(body.commit.sha, "abc123"); + } + + #[test] + fn pull_request_meta_extracts_head_and_base_refs() { + let body: PullRequestMetaResponse = serde_json::from_value(json!({ + "head": { "ref": "feature", "sha": "headsha" }, + "base": { "ref": "develop", "sha": "basesha" } + })) + .unwrap(); + assert_eq!(body.head.ref_name, "feature"); + assert_eq!(body.head.sha, "headsha"); + assert_eq!(body.base.ref_name, "develop"); + assert_eq!(body.base.sha, "basesha"); + } +} diff --git a/crates/github/src/tasks.rs b/crates/github/src/tasks.rs index 781eb68..0c91fb1 100644 --- a/crates/github/src/tasks.rs +++ b/crates/github/src/tasks.rs @@ -170,6 +170,7 @@ mod tests { "task-1", "OpenCoven/coven-code", &SessionResult { + contract_version: crate::HEADLESS_CONTRACT_VERSION.to_string(), status: SessionStatus::Success, branch: Some("cody/fix-auth".to_string()), commits: vec![], diff --git a/crates/webhook/src/events.rs b/crates/webhook/src/events.rs index d8294a6..26cd8e9 100644 --- a/crates/webhook/src/events.rs +++ b/crates/webhook/src/events.rs @@ -2,6 +2,7 @@ use coven_github_api::{ GitHubEvent, IssueAssignedEvent, IssueCommentEvent, IssueLabeledEvent, PrReviewCommentEvent, + PrReviewEvent, }; use serde::Deserialize; @@ -13,6 +14,7 @@ pub struct WebhookPayload { pub repository: Option, pub issue: Option, pub comment: Option, + pub review: Option, pub label: Option