Files
tireless/crates/tireless-data/tests/gitea_client.rs
rob thijssen 98f193d16b
All checks were successful
deploy / deploy (push) Successful in 6m1s
feat(data): implement JobStore and the Gitea read client
Closes #3 and #4.

Both specs had gaps that only appeared on contact, which is worth recording
because it is evidence for the plan-quality question in #10:

- `enqueue(&[DiscoveredIssue])` cannot know a JobKind. It is derivable from
  labels, so the store now carries the LabelProtocol and core gained
  `job_kind_for`. The precedence when an operator applies several mode labels
  had to be decided: plan beats implement, because planning produces the
  implementation children and so loses nothing, while the reverse silently
  discards the decomposition that was also asked for.
- `claim_next(worker, allowed_lanes)` had no lane to filter on. Migration 0002
  adds one, recorded at enqueue by `routing::lane_for` — the same function
  `route` now delegates to, so the two cannot drift. Deriving it at claim time
  instead would mean a forge request per claim, since labels are not stored.
- `list_opted_in_issues` returned a bare Vec with nowhere to put the ETag that
  #4's own step 3 requires, so a caller could not make the next poll
  conditional. It returns an `IssuePage` now, which also carries `not_modified`
  — a 304 is not an empty repo, and a poller that conflated them would treat
  every quiet poll as every issue having disappeared.

The claim is one statement: a CTE takes the row lock with SKIP LOCKED and the
update writes the claim, so select and update share a transaction without
managing one by hand. Returning a job to pending clears the claim, because
`pending_holds_no_claim` refuses the half-done version — the database is what
makes lease expiry safe rather than the code remembering to.

Enum values round-trip through serde rather than a hand-written match, so the
schema's check constraints and the Rust types are provably one vocabulary.

Also replaced a test from #2 that asserted exactly one migration exists. It
failed on the first legitimate migration, which teaches people to edit the
assertion rather than think. It now asserts what it was reaching for: versions
unique and ascending.

Verified against Postgres 18 — 15 database tests covering enqueue idempotency
across repeated polls, lane filtering including the agent:oc override, claim
metadata, lease expiry and reclaim, terminal jobs refusing transition, renewal
requiring you still hold the claim, and re-routing a queued job when its labels
change. Plus 12 mock-forge tests: If-None-Match sent and 304 distinguished from
empty, ETag surfaced, 429 retried but bounded, 503 retried then succeeding, 404
not retried, pull requests filtered out, and every unimplemented write failing
without touching the forge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 20:20:09 +03:00

323 lines
11 KiB
Rust

//! `GiteaClient` against a mock forge.
//!
//! These need no database and run in the normal `cargo test`, unlike the
//! migration tests. The behaviour under test is entirely about being a polite
//! API client — conditional requests, and backing off rather than hammering —
//! which is exactly the part that is invisible when it goes wrong: an
//! unconditional poller and a conditional one look identical from the outside
//! until someone reads the forge's access log.
use chrono::Utc;
use tireless_core::port::ForgeClient;
use tireless_data::GiteaClient;
use tireless_entities::{Forge, PollSchedule, TrackedRepo};
use uuid::Uuid;
use wiremock::matchers::{header, header_exists, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn repo(base_etag: Option<&str>) -> TrackedRepo {
TrackedRepo {
id: Uuid::new_v4(),
forge: Forge::Gitea,
owner: "lair".into(),
repo: "tireless".into(),
clone_url: "gitea@git.internal:lair/tireless.git".into(),
default_branch: "main".into(),
schedule: PollSchedule::default(),
last_etag: base_etag.map(str::to_owned),
last_polled_at: None,
created_at: Utc::now(),
}
}
const ISSUES_PATH: &str = "/api/v1/repos/lair/tireless/issues";
#[tokio::test]
async fn a_stored_etag_is_sent_as_if_none_match() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.and(header("if-none-match", "\"abc123\""))
.respond_with(ResponseTemplate::new(304))
.expect(1)
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let page = client
.list_opted_in_issues(&repo(Some("\"abc123\"")))
.await
.expect("list");
assert!(page.not_modified, "304 must be reported as not-modified");
assert!(page.issues.is_empty());
// The mock's `.expect(1)` asserts on drop that the conditional header was
// actually sent — without it the request would not have matched at all.
}
#[tokio::test]
async fn not_modified_is_distinguishable_from_an_empty_repo() {
// The distinction the `IssuePage` type exists for. A poller that conflated
// them would treat every quiet poll as "every issue disappeared".
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let page = client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
assert!(page.issues.is_empty());
assert!(
!page.not_modified,
"a genuinely empty repo is not a 304, and the caller must be able to tell"
);
}
#[tokio::test]
async fn no_stored_etag_means_no_conditional_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&server)
.await;
// A first poll has nothing to be conditional on. If this sent a stale or
// empty If-None-Match the forge could answer 304 to a client that has never
// seen the issues.
let client = GiteaClient::new(&server.uri(), "token").expect("client");
client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
}
#[tokio::test]
async fn the_returned_etag_is_surfaced_for_the_next_poll() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", "\"v2\"")
.set_body_json(serde_json::json!([])),
)
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let page = client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
assert_eq!(
page.etag.as_deref(),
Some("\"v2\""),
"without this the next poll cannot be conditional, and every poll refetches in full"
);
}
#[tokio::test]
async fn the_token_is_sent_as_a_gitea_token_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.and(header("authorization", "token s3cret"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "s3cret").expect("client");
client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
}
#[tokio::test]
async fn a_429_is_retried_and_then_given_up_on() {
let server = MockServer::start().await;
// Always throttled. The client must try a bounded number of times and then
// return, rather than looping until the poll interval or forever.
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.expect(3)
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let err = client
.list_opted_in_issues(&repo(None))
.await
.expect_err("a permanently throttled forge is an error, not an empty result");
assert!(err.to_string().contains("429"), "{err}");
// `.expect(3)` asserts the attempt budget on drop: retried, but bounded.
}
#[tokio::test]
async fn a_500_is_retried_and_a_later_success_is_returned() {
let server = MockServer::start().await;
// Fail twice, then succeed — the transient case retrying exists for.
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(2)
.expect(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"number": 4, "title": "x", "state": "open",
"labels": [{"name": "tireless"}, {"name": "tireless/plan"}]}
])))
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let page = client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
assert_eq!(page.issues.len(), 1);
assert_eq!(page.issues[0].issue.number, 4);
}
#[tokio::test]
async fn a_404_is_not_retried() {
let server = MockServer::start().await;
// A repo that does not exist, or a token that cannot see it, will not fix
// itself. Retrying spends the forge's patience to learn nothing.
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
assert!(client.list_opted_in_issues(&repo(None)).await.is_err());
}
#[tokio::test]
async fn pull_requests_are_filtered_out_of_the_issue_list() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"number": 1, "title": "an issue", "state": "open", "labels": []},
{"number": 2, "title": "a pull request", "state": "open", "labels": [],
"pull_request": {"merged": false}}
])))
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let page = client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
assert_eq!(
page.issues.len(),
1,
"a pull request must not be enqueued as an issue"
);
assert_eq!(page.issues[0].issue.number, 1);
}
#[tokio::test]
async fn labels_and_body_reach_the_caller() {
// The router reads labels, and the planner reads the body. Dropping either
// in the mapping produces work that is silently wrong rather than absent.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"number": 9, "title": "t", "body": "the description", "state": "open",
"labels": [{"name": "tireless"}, {"name": "tireless/agent:oc"}]}
])))
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let page = client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
let issue = &page.issues[0];
assert_eq!(issue.body, "the description");
assert_eq!(issue.labels, vec!["tireless", "tireless/agent:oc"]);
assert_eq!(issue.issue.owner, "lair");
assert_eq!(issue.issue.repo, "tireless");
}
#[tokio::test]
async fn the_request_asks_only_for_open_issues() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(ISSUES_PATH))
.and(header_exists("authorization"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&server)
.await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
client
.list_opted_in_issues(&repo(None))
.await
.expect("list");
let requests = server.received_requests().await.expect("requests");
let query = requests[0].url.query().unwrap_or_default();
assert!(query.contains("state=open"), "query was {query:?}");
assert!(query.contains("type=issues"), "query was {query:?}");
}
#[tokio::test]
async fn every_write_reports_that_it_is_not_implemented() {
// Stage 1 reads. If any of these ever returns Ok without doing the work,
// stage 2 looks finished while the forge sees nothing.
use tireless_entities::IssueRef;
let server = MockServer::start().await;
let client = GiteaClient::new(&server.uri(), "token").expect("client");
let issue = IssueRef {
forge: Forge::Gitea,
owner: "lair".into(),
repo: "tireless".into(),
number: 1,
};
assert!(client.add_label(&issue, "x").await.is_err());
assert!(client.remove_label(&issue, "x").await.is_err());
assert!(client.comment(&issue, "x").await.is_err());
assert!(
client
.create_issue(&repo(None), "t", "b", &[])
.await
.is_err()
);
assert!(
client
.open_pull_request(&repo(None), "h", "t", "b")
.await
.is_err()
);
assert_eq!(
server.received_requests().await.expect("requests").len(),
0,
"a not-implemented write must not reach the forge at all"
);
}