Skip to content

Commit 2de2411

Browse files
authored
feat: maintainer command protocol and marker-backed status comments (#13) (#39)
* feat(github): comment listing/updating and collaborator permission lookup (#13) Signed-off-by: Val Alexander <bunsthedev@gmail.com> * feat(github): command-aware task model (#13) Task gains an optional commander login for permission gating; CommandReply is a new adapter-only kind executed without spawning coven-code; ReviewPullRequest drops its write-only event-time ref fields — supersession plus live ref resolution already own correctness, and command-created reviews have no refs at parse time. Signed-off-by: Val Alexander <bunsthedev@gmail.com> * feat(webhook): typed maintainer command parser (#13) Signed-off-by: Val Alexander <bunsthedev@gmail.com> * feat(webhook): route maintainer commands into typed tasks (#13) Comment surfaces now speak the command protocol: only command-position mentions act, casual mentions are ignored, and unknown verbs earn a clarification reply. cancel tombstones queued PR reviews via the supersession registry; status answers from the task store; remember/forget acknowledge and defer to the memory governance contract (#6). Signed-off-by: Val Alexander <bunsthedev@gmail.com> * feat(worker): marker-backed status comments and command execution (#13) All worker comments funnel through a single marker-backed status surface per target, edited in place through the lifecycle (working / done+links / needs input / failed / declined). CommandReply tasks execute adapter-side with no coven-code session or Check Run. Command-initiated work is permission-gated: commanders below write access are declined on the status surface before any work is spent. A deepen command widens the review audit instruction. Signed-off-by: Val Alexander <bunsthedev@gmail.com> * docs: document maintainer commands and marker-backed status comments (#13) Signed-off-by: Val Alexander <bunsthedev@gmail.com> --------- Signed-off-by: Val Alexander <bunsthedev@gmail.com>
1 parent 0024952 commit 2de2411

12 files changed

Lines changed: 1534 additions & 287 deletions

File tree

README.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,7 @@ Implemented lanes:
9898
|---|---|
9999
| Issue assigned to bot user (`@cody`) | Agent picks up issue, opens PR |
100100
| `coven:` label applied to issue | Same as above |
101-
| `@cody` mention in issue comment | Agent responds / iterates |
102-
| PR review comment `@cody fix:` | Agent addresses review feedback |
101+
| Maintainer command in a comment (`@cody <command>`) | See the command table below |
103102
| 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) |
104103
| Review label applied to a PR | Explicit per-PR review opt-in — works even with the automatic lane off, including drafts |
105104

@@ -108,9 +107,32 @@ Planned lanes:
108107
| Trigger | Status |
109108
|---|---|
110109
| 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 |
111-
| Mention command protocol (re-run, deepen, fix) | Issue #13 |
112110
| Advisory / blocking publication gates | Issue #11 |
113111

112+
## Maintainer commands
113+
114+
A mention only acts when it is the **first token of the comment**, followed by a
115+
command verb — `@cody review`, `@cody fix: the lint is failing`. Casual
116+
mentions mid-sentence trigger nothing, and an unknown verb in command position
117+
gets a clarification reply instead of launching work. Every command except
118+
`status` requires **write access** to the repository; the familiar's own
119+
comments never re-trigger it.
120+
121+
| Command | On an issue | On a PR |
122+
|---|---|---|
123+
| `review` | Clarification (needs a PR) | Hosted review of the PR |
124+
| `fix [text]` | Fix the issue (opens a PR) | Address the feedback in the comment |
125+
| `deepen` | Clarification | Re-review with a wider lens (supporting files, tests) |
126+
| `retry` | Re-run the fix lane | Re-run the review |
127+
| `cancel` | Clarification (PR reviews only) | Cancel queued reviews for the PR (in-flight work finishes) |
128+
| `remember` / `forget` | Acknowledged; persistence lands with the memory governance contract (#6) | Same |
129+
| `status` | Current task state for this thread | Same |
130+
131+
Each familiar keeps **one marker-backed status comment per issue/PR**, edited
132+
in place through the task lifecycle (working → done / needs input / failed),
133+
with links to the Check Run, PR, and Cave session — repeated runs never stack
134+
duplicate comments.
135+
114136
---
115137

116138
## Status
@@ -122,7 +144,8 @@ Planned lanes:
122144
| Webhook HMAC validation | Implemented | Rejects unsigned or invalid GitHub webhook payloads. |
123145
| Issue assignment trigger | Implemented | Routes matching bot assignees to configured familiars. |
124146
| Label trigger | Implemented | Routes configured `trigger_labels` such as `coven:fix`. |
125-
| Issue / PR mention trigger | Implemented | Ignores familiar bot self-comments to avoid loops. |
147+
| Maintainer command protocol | Implemented | Typed `@familiar <verb>` grammar; casual mentions ignored; write-access gate; self-comments never re-trigger. |
148+
| Marker-backed status comments | Implemented | One edited-in-place status surface per issue/PR; no duplicate bot comments. |
126149
| 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. |
127150
| Push / commit review trigger | Partial | Events parsed and typed with fixtures; execution lane needs headless contract v3. |
128151
| GitHub App installation tokens | Implemented | Mints installation access tokens from the App private key. |

crates/github/src/lib.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ pub struct IssueCommentEvent {
147147
pub repo_owner: String,
148148
pub repo_name: String,
149149
pub issue_number: u64,
150+
/// Issue (or PR) title — needed when a `fix` command turns the comment
151+
/// into a FixIssue task (issue #13).
152+
pub issue_title: String,
153+
pub issue_body: String,
150154
pub comment_body: String,
151155
pub commenter_login: String,
152156
/// `issue_comment` fires for pull-request conversation comments as well as
@@ -166,6 +170,7 @@ pub struct PrReviewEvent {
166170
pub repo_owner: String,
167171
pub repo_name: String,
168172
pub pr_number: u64,
173+
pub pr_title: String,
169174
pub review_body: String,
170175
/// Review verdict: `approved`, `changes_requested`, or `commented`.
171176
pub review_state: String,
@@ -178,6 +183,7 @@ pub struct PrReviewCommentEvent {
178183
pub repo_owner: String,
179184
pub repo_name: String,
180185
pub pr_number: u64,
186+
pub pr_title: String,
181187
pub comment_body: String,
182188
pub commenter_login: String,
183189
}
@@ -191,6 +197,11 @@ pub struct Task {
191197
pub repo_name: String,
192198
pub kind: TaskKind,
193199
pub familiar_id: String,
200+
/// Login of the maintainer whose command initiated this task (issue #13).
201+
/// The worker checks their repo permission pre-flight and declines below
202+
/// `write`. `None` for tasks from non-command triggers.
203+
#[serde(default)]
204+
pub commander: Option<String>,
194205
}
195206

196207
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -210,18 +221,23 @@ pub enum TaskKind {
210221
issue_number: u64,
211222
comment_body: String,
212223
},
213-
/// Adapter-initiated hosted review of a pull request (issue #10). Carries
214-
/// the refs captured at event time; supersession — not ref pinning — keeps
215-
/// reviews current when the head moves.
224+
/// Adapter-initiated hosted review of a pull request (issue #10). Target
225+
/// refs are resolved live at execution; supersession — not ref pinning —
226+
/// keeps reviews current when the head moves.
216227
ReviewPullRequest {
217228
pr_number: u64,
218229
pr_title: String,
219-
head_ref: String,
220-
head_sha: String,
221-
base_ref: String,
222-
/// The webhook action that triggered the review (opened, synchronize, …).
230+
/// What triggered the review: a webhook action (opened, synchronize, …)
231+
/// or a maintainer command (`command:review`, `command:deepen`, …).
223232
reason: String,
224233
},
234+
/// Adapter-only reply on an issue/PR conversation (issue #13): command
235+
/// acknowledgements, clarifications, status answers, permission declines.
236+
/// Executed without spawning coven-code.
237+
CommandReply {
238+
issue_number: u64,
239+
body: String,
240+
},
225241
}
226242

227243
/// Structured result envelope written by coven-code --headless.

crates/github/src/pr.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,111 @@ mod tests {
188188
assert_eq!(request.body, json!({ "body": "On it" }));
189189
}
190190
}
191+
192+
/// One issue/PR conversation comment, trimmed to what marker lookup needs.
193+
#[derive(Debug, Deserialize)]
194+
pub struct IssueComment {
195+
pub id: u64,
196+
pub body: String,
197+
pub user: CommentUser,
198+
}
199+
200+
#[derive(Debug, Deserialize)]
201+
pub struct CommentUser {
202+
pub login: String,
203+
}
204+
205+
/// Lists the first 100 conversation comments on an issue or PR (oldest first).
206+
/// Marker-backed status comments are posted early in a thread, so a single
207+
/// page suffices; threads beyond 100 comments fall back to posting fresh
208+
/// (issue #13).
209+
pub async fn list_comments_with_base_url(
210+
api_base_url: &str,
211+
installation_token: &str,
212+
repo_owner: &str,
213+
repo_name: &str,
214+
issue_number: u64,
215+
) -> Result<Vec<IssueComment>> {
216+
let client = client()?;
217+
let response = send_json(
218+
&client,
219+
api_base_url,
220+
installation_token,
221+
list_comments_request(repo_owner, repo_name, issue_number),
222+
)
223+
.await?;
224+
Ok(response.json().await?)
225+
}
226+
227+
/// Edits an existing conversation comment in place (issue #13).
228+
pub async fn update_comment_with_base_url(
229+
api_base_url: &str,
230+
installation_token: &str,
231+
repo_owner: &str,
232+
repo_name: &str,
233+
comment_id: u64,
234+
body: &str,
235+
) -> Result<()> {
236+
let client = client()?;
237+
send_json(
238+
&client,
239+
api_base_url,
240+
installation_token,
241+
update_comment_request(repo_owner, repo_name, comment_id, body),
242+
)
243+
.await?;
244+
Ok(())
245+
}
246+
247+
fn list_comments_request(repo_owner: &str, repo_name: &str, issue_number: u64) -> GitHubRequest {
248+
GitHubRequest {
249+
method: "GET",
250+
path: format!("/repos/{repo_owner}/{repo_name}/issues/{issue_number}/comments?per_page=100"),
251+
body: serde_json::Value::Null,
252+
}
253+
}
254+
255+
fn update_comment_request(
256+
repo_owner: &str,
257+
repo_name: &str,
258+
comment_id: u64,
259+
body: &str,
260+
) -> GitHubRequest {
261+
GitHubRequest {
262+
method: "PATCH",
263+
path: format!("/repos/{repo_owner}/{repo_name}/issues/comments/{comment_id}"),
264+
body: serde_json::json!({ "body": body }),
265+
}
266+
}
267+
268+
#[cfg(test)]
269+
mod comment_tests {
270+
use super::*;
271+
use serde_json::json;
272+
273+
#[test]
274+
fn list_comments_request_targets_issue_comments_endpoint() {
275+
let request = list_comments_request("octo", "repo", 42);
276+
assert_eq!(request.method, "GET");
277+
assert_eq!(request.path, "/repos/octo/repo/issues/42/comments?per_page=100");
278+
}
279+
280+
#[test]
281+
fn update_comment_request_patches_the_comment_by_id() {
282+
let request = update_comment_request("octo", "repo", 77, "new body");
283+
assert_eq!(request.method, "PATCH");
284+
assert_eq!(request.path, "/repos/octo/repo/issues/comments/77");
285+
assert_eq!(request.body["body"], json!("new body"));
286+
}
287+
288+
#[test]
289+
fn issue_comment_deserializes_id_body_and_author() {
290+
let comments: Vec<IssueComment> = serde_json::from_value(json!([
291+
{ "id": 7, "body": "<!-- coven:cody:o/r#42 -->\nStatus: working", "user": { "login": "coven-cody[bot]" }, "created_at": "2026-07-06T00:00:00Z" }
292+
]))
293+
.unwrap();
294+
assert_eq!(comments[0].id, 7);
295+
assert!(comments[0].body.contains("coven:cody"));
296+
assert_eq!(comments[0].user.login, "coven-cody[bot]");
297+
}
298+
}

crates/github/src/repo.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,66 @@ mod tests {
276276
assert_eq!(body.base.sha, "basesha");
277277
}
278278
}
279+
280+
/// Fetches the repository permission level GitHub reports for a user:
281+
/// `admin`, `write`, `read`, or `none` (`maintain`/`triage` map onto these in
282+
/// the legacy `permission` field). Used to gate maintainer commands (#13);
283+
/// only requires the always-granted metadata scope.
284+
pub async fn get_collaborator_permission_with_base_url(
285+
api_base_url: &str,
286+
installation_token: &str,
287+
owner: &str,
288+
name: &str,
289+
username: &str,
290+
) -> Result<String> {
291+
let client = client()?;
292+
let response = send_json(
293+
&client,
294+
api_base_url,
295+
installation_token,
296+
collaborator_permission_request(owner, name, username),
297+
)
298+
.await?;
299+
let body: CollaboratorPermission = response.json().await?;
300+
Ok(body.permission)
301+
}
302+
303+
#[derive(Debug, serde::Deserialize)]
304+
struct CollaboratorPermission {
305+
permission: String,
306+
}
307+
308+
fn collaborator_permission_request(owner: &str, name: &str, username: &str) -> GitHubRequest {
309+
GitHubRequest {
310+
method: "GET",
311+
path: format!("/repos/{owner}/{name}/collaborators/{username}/permission"),
312+
body: serde_json::Value::Null,
313+
}
314+
}
315+
316+
#[cfg(test)]
317+
mod permission_tests {
318+
use super::*;
319+
use serde_json::json;
320+
321+
#[test]
322+
fn collaborator_permission_request_targets_permission_endpoint() {
323+
let request = collaborator_permission_request("octo", "repo", "hexadecimal-cat");
324+
assert_eq!(request.method, "GET");
325+
assert_eq!(
326+
request.path,
327+
"/repos/octo/repo/collaborators/hexadecimal-cat/permission"
328+
);
329+
}
330+
331+
#[test]
332+
fn collaborator_permission_extracts_the_legacy_permission_field() {
333+
let body: CollaboratorPermission = serde_json::from_value(json!({
334+
"permission": "write",
335+
"role_name": "maintain",
336+
"user": { "login": "hexadecimal-cat" }
337+
}))
338+
.unwrap();
339+
assert_eq!(body.permission, "write");
340+
}
341+
}

crates/github/src/tasks.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ fn task_list_item(task: &Task, familiar_name: &str) -> TaskListItem {
146146
pr_title,
147147
..
148148
} => (*pr_number, format!("Review PR #{pr_number}: {pr_title}")),
149+
TaskKind::CommandReply { issue_number, .. } => {
150+
(*issue_number, format!("Reply on #{issue_number}"))
151+
}
149152
};
150153

151154
TaskListItem {
@@ -185,6 +188,7 @@ mod tests {
185188
issue_title: "Fix auth".to_string(),
186189
issue_body: "Body".to_string(),
187190
},
191+
commander: None,
188192
}
189193
}
190194

0 commit comments

Comments
 (0)