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
14 changes: 13 additions & 1 deletion COVEN-GITHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -205,7 +216,8 @@ The PR body and issue comments are written in the familiar's voice:
I looked at issue #42 and here's what I found:

The OAuth token refresh path in `src/auth/refresh.rs` wasn't accounting for
clock skew between the client and the auth server. I added a 60-second buffer\nto the expiry check.
clock skew between the client and the auth server. I added a 60-second buffer
to the expiry check.

**Changed:** `src/auth/refresh.rs` (+12 / -3)
**Tests:** 8/8 passing (added 2 regression cases)
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand All @@ -131,7 +132,9 @@ cd coven-github
cargo build --release

# Configure (see config/example.toml)
cp config/example.toml config/local.toml\n# Set: github_app_id, private_key_path, webhook_secret, familiar config
cp config/example.toml config/local.toml
# Then set in config/local.toml: github.app_id, github.private_key_path,
# github.webhook_secret, worker.coven_code_bin, and a [[familiars]] block.

# Run
./target/release/coven-github serve --config config/local.toml
Expand Down
50 changes: 45 additions & 5 deletions crates/github/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -35,14 +44,16 @@ async fn send_json(
request: GitHubRequest,
) -> anyhow::Result<reqwest::Response> {
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();
Expand All @@ -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 },
}

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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<String>,
pub commits: Vec<CommitInfo>,
Expand Down
225 changes: 225 additions & 0 deletions crates/github/src/repo.rs
Original file line number Diff line number Diff line change
@@ -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<RepoMetadata> {
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<RepoMetadata> {
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<String> {
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<String> {
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<PullRequestRefs> {
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<PullRequestRefs> {
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");
}
}
Loading
Loading