Skip to content
Draft
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: 8 additions & 6 deletions crates/gl/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,9 @@ async fn call_tool(
let name = args["name"].as_str().context("missing 'name'")?;
let owner = resolve_owner(&args, &client).await?;
let repo = crate::http::read_json(
client.get(&format!("/api/v1/repos/{owner}/{name}")).await?,
client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}"))
.await?,
"repo",
)
.await?;
Expand All @@ -708,7 +710,7 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let commits = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{name}/commits"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/commits"))
.await?,
"commits",
)
Expand Down Expand Up @@ -849,7 +851,7 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let resp = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?,
"pull requests",
)
Expand All @@ -863,14 +865,14 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let pr = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?,
"pull request",
)
.await?;
let reviews = crate::http::read_json(
client
.get(&format!(
.get_maybe_signed(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?,
Expand All @@ -888,7 +890,7 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let resp = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?,
"diff",
)
Expand Down
136 changes: 50 additions & 86 deletions crates/gl/src/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,11 @@ async fn cmd_create(
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let client = NodeClient::new(&node, Some(keypair));

let resp = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?,
"pull requests",
)
Expand Down Expand Up @@ -290,11 +290,11 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let client = NodeClient::new(&node, Some(keypair));

let pr = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?,
"pull request",
)
Expand All @@ -318,7 +318,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
// Show reviews
let reviews = crate::http::read_json(
client
.get(&format!(
.get_maybe_signed(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?,
Expand Down Expand Up @@ -352,7 +352,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
// Show comments
let comments = crate::http::read_json(
client
.get(&format!(
.get_maybe_signed(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?,
Expand Down Expand Up @@ -381,11 +381,11 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
async fn cmd_diff(repo: String, number: u64, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let client = NodeClient::new(&node, Some(keypair));

let resp = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?,
"diff",
)
Expand Down Expand Up @@ -484,11 +484,11 @@ async fn cmd_comment(
async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let client = NodeClient::new(&node, Some(keypair));

let resp = crate::http::read_json(
client
.get(&format!(
.get_maybe_signed(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?,
Expand Down Expand Up @@ -549,6 +549,9 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls$".to_string()),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"pulls":[]}"#)
Expand All @@ -575,6 +578,9 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls$".to_string()),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"pulls":[{"number":1,"title":"Add feature","status":"open","source_branch":"feat","target_branch":"main","author_did":"did:key:z6MkTest"}]}"#)
Expand Down Expand Up @@ -668,6 +674,9 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1$".to_string()),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"number":1,"title":"Fix it","status":"open","source_branch":"fix","target_branch":"main","author_did":"did:key:z6MkTest","body":"some body"}"#)
Expand All @@ -680,6 +689,9 @@ mod tests {
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/reviews$".to_string(),
),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"reviews":[]}"#)
Expand All @@ -692,6 +704,9 @@ mod tests {
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string(),
),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"comments":[]}"#)
Expand Down Expand Up @@ -783,6 +798,9 @@ mod tests {
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string(),
),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"comments":[]}"#)
Expand Down Expand Up @@ -827,76 +845,22 @@ mod tests {
}

#[tokio::test]
async fn test_cmd_merge_success() {
async fn cmd_diff_surfaces_denial_not_empty() {
let dir = TempDir::new().unwrap();
write_identity(&dir);

let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/2/merge$".to_string()),
"GET",
mockito::Matcher::Regex(r"/pulls/1/diff$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"merge_sha":"abc123def456789"}"#)
.create_async()
.await;

cmd_merge(
"myrepo".to_string(),
2,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}

// ── Gated PR reads surface node denials, not empty/stub renders (#123) ──

#[tokio::test]
async fn cmd_list_surfaces_denial_not_empty() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/pulls$".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(
result.is_err(),
"cmd_list must Err on 404, not print 'No pull requests'"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}

#[tokio::test]
async fn cmd_view_surfaces_denial_not_stub() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// The PR fetch is first; a 404 there errors before the reviews/comments reads.
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_view(
let result = cmd_diff(
"myrepo".to_string(),
1,
server.url(),
Expand All @@ -905,68 +869,68 @@ mod tests {
.await;
assert!(
result.is_err(),
"cmd_view must Err on 404, not print a stub PR"
"cmd_diff must Err on 404, not print 'No diff'"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}

#[tokio::test]
async fn cmd_diff_surfaces_denial_not_empty() {
async fn cmd_comments_surfaces_denial_not_empty() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/diff$".to_string()),
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_diff(
let result = cmd_comments(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(
result.is_err(),
"cmd_diff must Err on 404, not print 'No diff'"
);
assert!(result.is_err(), "cmd_comments must Err on a gated 404");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}

// cmd_diff had no signing coverage, so the unsigned read (#115) was invisible
// here. An identity is supplied, so the request must be signed.
#[tokio::test]
async fn cmd_comments_surfaces_denial_not_empty() {
async fn test_cmd_diff_signs_request() {
let dir = TempDir::new().unwrap();
write_identity(&dir);

let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1/diff$".to_string()),
)
.with_status(404)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.with_body(r#"{"diff":"diff --git a/x b/x"}"#)
.create_async()
.await;
let result = cmd_comments(

cmd_diff(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(result.is_err(), "cmd_comments must Err on a gated 404");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
.await
.unwrap();
}
}
Loading
Loading