Skip to content

Commit 7fd6522

Browse files
jdclaude
andauthored
fix(http): set User-Agent on the shared reqwest client (#1580)
`stack push` (and every other GitHub-backed command) has been failing intermittently with `403 Request forbidden by administrative rules` because GitHub's REST API rejects requests that don't carry a User-Agent header, and `reqwest::Client:: builder()` doesn't set one by default. Symptom in production: $ mergify stack push mergify: HTTP 403 Forbidden: Request forbidden by administrative rules. Please make sure your request has a User-Agent header (https://docs.github.com/en/rest/overview/resources-in-the- rest-api#user-agent-required). Fix: configure the shared `Client` with `User-Agent: mergify-cli/<crate-version>`. The header value uses Cargo's package version (the placeholder `0.0.0` in dev, the stamped calver in release builds via the upcoming version-from-tag PR); GitHub only cares that the header is present and identifies the client, not the format of the value. Add a wiremock test that pins the header so dropping the `.user_agent(...)` call again surfaces as a test failure instead of a prod 403. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5df5940 commit 7fd6522

1 file changed

Lines changed: 31 additions & 0 deletions

File tree

crates/mergify-core/src/http.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ use crate::error::CliError;
2727
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
2828
const DEFAULT_MAX_ATTEMPTS: u32 = 3;
2929
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
30+
/// User-Agent header sent on every request. GitHub's REST API
31+
/// rejects requests without one (`403 Request forbidden by
32+
/// administrative rules`), so this is non-negotiable. Cargo's
33+
/// package version is fine here — calver vs semver doesn't
34+
/// matter to GitHub, only that the header is present and
35+
/// identifies the client.
36+
const USER_AGENT: &str = concat!("mergify-cli/", env!("CARGO_PKG_VERSION"));
3037
/// Cap on how many bytes of an error response body we surface in
3138
/// `CliError`. A misbehaving server can return arbitrarily large
3239
/// payloads; truncating keeps the CLI output sane and bounds memory
@@ -111,6 +118,7 @@ impl Client {
111118
let token_opt = (!token_str.is_empty()).then_some(token_str);
112119
let inner = reqwest::Client::builder()
113120
.timeout(REQUEST_TIMEOUT)
121+
.user_agent(USER_AGENT)
114122
.build()
115123
.map_err(|e| CliError::Generic(format!("build HTTP client: {e}")))?;
116124
Ok(Self {
@@ -479,6 +487,29 @@ mod tests {
479487
assert_eq!(got, Foo { bar: 42 });
480488
}
481489

490+
#[tokio::test]
491+
async fn requests_carry_user_agent_header() {
492+
// GitHub's REST API rejects requests with no User-Agent
493+
// ("403 Request forbidden by administrative rules"). The
494+
// reqwest builder doesn't set one by default, so dropping
495+
// the explicit `.user_agent(...)` call would silently
496+
// break every GitHub-backed command. Pin the header value
497+
// to `mergify-cli/<crate-version>` so a regression here
498+
// surfaces as a test failure, not as a prod outage.
499+
let server = MockServer::start().await;
500+
let expected_ua = format!("mergify-cli/{}", env!("CARGO_PKG_VERSION"));
501+
Mock::given(method("GET"))
502+
.and(path("/foo"))
503+
.and(header("User-Agent", expected_ua.as_str()))
504+
.respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 1 }))
505+
.expect(1)
506+
.mount(&server)
507+
.await;
508+
509+
let client = fast_client(&server, ApiFlavor::GitHub);
510+
let _: Foo = client.get("/foo").await.unwrap();
511+
}
512+
482513
#[tokio::test]
483514
async fn empty_token_skips_auth_header() {
484515
let server = MockServer::start().await;

0 commit comments

Comments
 (0)