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
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,7 @@ Implemented lanes:
|---|---|
| Issue assigned to bot user (`@cody`) | Agent picks up issue, opens PR |
| `coven:` label applied to issue | Same as above |
| `@cody` mention in issue comment | Agent responds / iterates |
| PR review comment `@cody fix:` | Agent addresses review feedback |
| Maintainer command in a comment (`@cody <command>`) | See the command table below |
| PR opened / synchronize / reopened / ready_for_review | Automatic hosted review when the `[review]` policy enables the lane (drafts skipped by default; newer pushes supersede queued reviews of the same PR) |
| Review label applied to a PR | Explicit per-PR review opt-in — works even with the automatic lane off, including drafts |

Expand All @@ -108,9 +107,32 @@ Planned lanes:
| Trigger | Status |
|---|---|
| Push / commit-range review | `push` events are parsed and typed with fixtures today; execution needs a PR-less task kind, which ships with headless contract v3 |
| Mention command protocol (re-run, deepen, fix) | Issue #13 |
| Advisory / blocking publication gates | Issue #11 |

## Maintainer commands

A mention only acts when it is the **first token of the comment**, followed by a
command verb — `@cody review`, `@cody fix: the lint is failing`. Casual
mentions mid-sentence trigger nothing, and an unknown verb in command position
gets a clarification reply instead of launching work. Every command except
`status` requires **write access** to the repository; the familiar's own
comments never re-trigger it.
Comment on lines +114 to +119

| Command | On an issue | On a PR |
|---|---|---|
| `review` | Clarification (needs a PR) | Hosted review of the PR |
| `fix [text]` | Fix the issue (opens a PR) | Address the feedback in the comment |
| `deepen` | Clarification | Re-review with a wider lens (supporting files, tests) |
| `retry` | Re-run the fix lane | Re-run the review |
| `cancel` | Clarification (PR reviews only) | Cancel queued reviews for the PR (in-flight work finishes) |
| `remember` / `forget` | Acknowledged; persistence lands with the memory governance contract (#6) | Same |
| `status` | Current task state for this thread | Same |

Each familiar keeps **one marker-backed status comment per issue/PR**, edited
in place through the task lifecycle (working → done / needs input / failed),
with links to the Check Run, PR, and Cave session — repeated runs never stack
duplicate comments.

---

## Status
Expand All @@ -122,7 +144,8 @@ Planned lanes:
| Webhook HMAC validation | Implemented | Rejects unsigned or invalid GitHub webhook payloads. |
| Issue assignment trigger | Implemented | Routes matching bot assignees to configured familiars. |
| Label trigger | Implemented | Routes configured `trigger_labels` such as `coven:fix`. |
| Issue / PR mention trigger | Implemented | Ignores familiar bot self-comments to avoid loops. |
| Maintainer command protocol | Implemented | Typed `@familiar <verb>` grammar; casual mentions ignored; write-access gate; self-comments never re-trigger. |
| Marker-backed status comments | Implemented | One edited-in-place status surface per issue/PR; no duplicate bot comments. |
| PR lifecycle review trigger | Implemented | Policy-gated auto-review on opened/synchronize/reopened/ready_for_review plus label opt-in; familiar-authored PRs are never auto-reviewed. |
| Push / commit review trigger | Partial | Events parsed and typed with fixtures; execution lane needs headless contract v3. |
| GitHub App installation tokens | Implemented | Mints installation access tokens from the App private key. |
Expand Down
30 changes: 23 additions & 7 deletions crates/github/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ pub struct IssueCommentEvent {
pub repo_owner: String,
pub repo_name: String,
pub issue_number: u64,
/// Issue (or PR) title — needed when a `fix` command turns the comment
/// into a FixIssue task (issue #13).
pub issue_title: String,
pub issue_body: String,
pub comment_body: String,
pub commenter_login: String,
/// `issue_comment` fires for pull-request conversation comments as well as
Expand All @@ -166,6 +170,7 @@ pub struct PrReviewEvent {
pub repo_owner: String,
pub repo_name: String,
pub pr_number: u64,
pub pr_title: String,
pub review_body: String,
/// Review verdict: `approved`, `changes_requested`, or `commented`.
pub review_state: String,
Expand All @@ -178,6 +183,7 @@ pub struct PrReviewCommentEvent {
pub repo_owner: String,
pub repo_name: String,
pub pr_number: u64,
pub pr_title: String,
pub comment_body: String,
pub commenter_login: String,
}
Expand All @@ -191,6 +197,11 @@ pub struct Task {
pub repo_name: String,
pub kind: TaskKind,
pub familiar_id: String,
/// Login of the maintainer whose command initiated this task (issue #13).
/// The worker checks their repo permission pre-flight and declines below
/// `write`. `None` for tasks from non-command triggers.
#[serde(default)]
pub commander: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -210,18 +221,23 @@ pub enum TaskKind {
issue_number: u64,
comment_body: String,
},
/// Adapter-initiated hosted review of a pull request (issue #10). Carries
/// the refs captured at event time; supersession — not ref pinning — keeps
/// reviews current when the head moves.
/// Adapter-initiated hosted review of a pull request (issue #10). Target
/// refs are resolved live at execution; supersession — not ref pinning —
/// keeps reviews current when the head moves.
ReviewPullRequest {
pr_number: u64,
pr_title: String,
head_ref: String,
head_sha: String,
base_ref: String,
/// The webhook action that triggered the review (opened, synchronize, …).
/// What triggered the review: a webhook action (opened, synchronize, …)
/// or a maintainer command (`command:review`, `command:deepen`, …).
reason: String,
},
/// Adapter-only reply on an issue/PR conversation (issue #13): command
/// acknowledgements, clarifications, status answers, permission declines.
/// Executed without spawning coven-code.
CommandReply {
issue_number: u64,
body: String,
},
}

/// Structured result envelope written by coven-code --headless.
Expand Down
108 changes: 108 additions & 0 deletions crates/github/src/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,111 @@ mod tests {
assert_eq!(request.body, json!({ "body": "On it" }));
}
}

/// One issue/PR conversation comment, trimmed to what marker lookup needs.
#[derive(Debug, Deserialize)]
pub struct IssueComment {
pub id: u64,
pub body: String,
pub user: CommentUser,
}

#[derive(Debug, Deserialize)]
pub struct CommentUser {
pub login: String,
}

/// Lists the first 100 conversation comments on an issue or PR (oldest first).
/// Marker-backed status comments are posted early in a thread, so a single
/// page suffices; threads beyond 100 comments fall back to posting fresh
/// (issue #13).
pub async fn list_comments_with_base_url(
api_base_url: &str,
installation_token: &str,
repo_owner: &str,
repo_name: &str,
issue_number: u64,
) -> Result<Vec<IssueComment>> {
let client = client()?;
let response = send_json(
&client,
api_base_url,
installation_token,
list_comments_request(repo_owner, repo_name, issue_number),
)
.await?;
Ok(response.json().await?)
}

/// Edits an existing conversation comment in place (issue #13).
pub async fn update_comment_with_base_url(
api_base_url: &str,
installation_token: &str,
repo_owner: &str,
repo_name: &str,
comment_id: u64,
body: &str,
) -> Result<()> {
let client = client()?;
send_json(
&client,
api_base_url,
installation_token,
update_comment_request(repo_owner, repo_name, comment_id, body),
)
.await?;
Ok(())
}

fn list_comments_request(repo_owner: &str, repo_name: &str, issue_number: u64) -> GitHubRequest {
GitHubRequest {
method: "GET",
path: format!("/repos/{repo_owner}/{repo_name}/issues/{issue_number}/comments?per_page=100"),
body: serde_json::Value::Null,
}
}

fn update_comment_request(
repo_owner: &str,
repo_name: &str,
comment_id: u64,
body: &str,
) -> GitHubRequest {
GitHubRequest {
method: "PATCH",
path: format!("/repos/{repo_owner}/{repo_name}/issues/comments/{comment_id}"),
body: serde_json::json!({ "body": body }),
}
}

#[cfg(test)]
mod comment_tests {
use super::*;
use serde_json::json;

#[test]
fn list_comments_request_targets_issue_comments_endpoint() {
let request = list_comments_request("octo", "repo", 42);
assert_eq!(request.method, "GET");
assert_eq!(request.path, "/repos/octo/repo/issues/42/comments?per_page=100");
}

#[test]
fn update_comment_request_patches_the_comment_by_id() {
let request = update_comment_request("octo", "repo", 77, "new body");
assert_eq!(request.method, "PATCH");
assert_eq!(request.path, "/repos/octo/repo/issues/comments/77");
assert_eq!(request.body["body"], json!("new body"));
}

#[test]
fn issue_comment_deserializes_id_body_and_author() {
let comments: Vec<IssueComment> = serde_json::from_value(json!([
{ "id": 7, "body": "<!-- coven:cody:o/r#42 -->\nStatus: working", "user": { "login": "coven-cody[bot]" }, "created_at": "2026-07-06T00:00:00Z" }
]))
.unwrap();
assert_eq!(comments[0].id, 7);
assert!(comments[0].body.contains("coven:cody"));
assert_eq!(comments[0].user.login, "coven-cody[bot]");
}
}
63 changes: 63 additions & 0 deletions crates/github/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,66 @@ mod tests {
assert_eq!(body.base.sha, "basesha");
}
}

/// Fetches the repository permission level GitHub reports for a user:
/// `admin`, `write`, `read`, or `none` (`maintain`/`triage` map onto these in
/// the legacy `permission` field). Used to gate maintainer commands (#13);
/// only requires the always-granted metadata scope.
pub async fn get_collaborator_permission_with_base_url(
api_base_url: &str,
installation_token: &str,
owner: &str,
name: &str,
username: &str,
) -> Result<String> {
let client = client()?;
let response = send_json(
&client,
api_base_url,
installation_token,
collaborator_permission_request(owner, name, username),
)
.await?;
let body: CollaboratorPermission = response.json().await?;
Ok(body.permission)
}

#[derive(Debug, serde::Deserialize)]
struct CollaboratorPermission {
permission: String,
}

fn collaborator_permission_request(owner: &str, name: &str, username: &str) -> GitHubRequest {
GitHubRequest {
method: "GET",
path: format!("/repos/{owner}/{name}/collaborators/{username}/permission"),
body: serde_json::Value::Null,
}
}

#[cfg(test)]
mod permission_tests {
use super::*;
use serde_json::json;

#[test]
fn collaborator_permission_request_targets_permission_endpoint() {
let request = collaborator_permission_request("octo", "repo", "hexadecimal-cat");
assert_eq!(request.method, "GET");
assert_eq!(
request.path,
"/repos/octo/repo/collaborators/hexadecimal-cat/permission"
);
}

#[test]
fn collaborator_permission_extracts_the_legacy_permission_field() {
let body: CollaboratorPermission = serde_json::from_value(json!({
"permission": "write",
"role_name": "maintain",
"user": { "login": "hexadecimal-cat" }
}))
.unwrap();
assert_eq!(body.permission, "write");
}
}
4 changes: 4 additions & 0 deletions crates/github/src/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ fn task_list_item(task: &Task, familiar_name: &str) -> TaskListItem {
pr_title,
..
} => (*pr_number, format!("Review PR #{pr_number}: {pr_title}")),
TaskKind::CommandReply { issue_number, .. } => {
(*issue_number, format!("Reply on #{issue_number}"))
}
};

TaskListItem {
Expand Down Expand Up @@ -185,6 +188,7 @@ mod tests {
issue_title: "Fix auth".to_string(),
issue_body: "Body".to_string(),
},
commander: None,
}
}

Expand Down
Loading
Loading