fabro/lib/crates/fabro-api/tests/pull_request_round_trip.rs
Bryan Helmkamp d09e6cde33
feat(pr): support GitHub pull request associations (#270)
## Summary

Adds event-sourced pull request association management for runs while
preserving Fabro-created PR creation. A run can now store a current
GitHub PR association, replace it by linking another GitHub PR URL, and
remove it through an unlink event.

## What Changed

- Added `pull_request.linked` and `pull_request.unlinked` events,
projection replay support, and optional PR metadata fields in shared
pull request records.
- Added API, server, and client support for `PUT
/runs/{id}/pull_request` and `DELETE /runs/{id}/pull_request`; linking
accepts GitHub PR URLs, infers owner/repo/number, and captures live
GitHub title and branch metadata when available.
- Added `fabro pr link` and `fabro pr unlink`, updated `fabro pr view`,
and kept create/merge/close behavior guarded to GitHub PRs with usable
coordinates.
- Updated web UI rendering and internal event docs so stored PR links
display cleanly when live GitHub details are unavailable.

## Testing

- `cargo +nightly-2026-04-14 fmt --check --all`
- `git diff --check`
- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-types -p fabro-store -p fabro-server -p
fabro-cli`
- `bun run typecheck` in `lib/packages/fabro-api-client`
- `bun run typecheck` in `apps/fabro-web`
- `bun test` in `apps/fabro-web`

Refs https://github.com/fabro-sh/fabro/issues/235

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)

---------

Co-authored-by: Haroldo Olivieri <6575718+haroldolivieri@users.noreply.github.com>
2026-05-16 12:47:27 -04:00

138 lines
4 KiB
Rust

use std::any::{TypeId, type_name};
use fabro_api::types::MergeRunPullRequestRequest;
use fabro_types::settings::run::MergeStrategy;
use fabro_types::{PullRequest, PullRequestLink, PullRequestResponse};
use serde_json::json;
#[test]
fn pull_request_response_reuses_domain_types() {
let response: PullRequestResponse = serde_json::from_value(json!({
"data": {
"link": {
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
},
"details": null
},
"meta": {
"details_status": "unavailable",
"details_unavailable_reason": "integration_unavailable"
}
}))
.expect("response should deserialize");
assert_same_type_as_pull_request(&response.data);
assert_same_type_as_pull_request_link(&response.data.link);
}
#[test]
fn merge_request_reuses_run_merge_strategy_type() {
let request: MergeRunPullRequestRequest = serde_json::from_value(json!({ "method": "squash" }))
.expect("merge request should deserialize");
assert_same_type_as_merge_strategy(&request.method);
}
#[test]
fn merge_strategy_json_matches_openapi_shape() {
assert_eq!(serde_json::to_value(MergeStrategy::Merge).unwrap(), "merge");
assert_eq!(
serde_json::to_value(MergeStrategy::Squash).unwrap(),
"squash"
);
assert_eq!(
serde_json::to_value(MergeStrategy::Rebase).unwrap(),
"rebase"
);
}
#[test]
fn pull_request_link_json_matches_openapi_shape() {
let fixture = json!({
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
});
let domain_record: PullRequestLink =
serde_json::from_value(fixture.clone()).expect("domain link should deserialize");
assert_eq!(serde_json::to_value(domain_record).unwrap(), fixture);
}
#[test]
fn pull_request_response_json_matches_openapi_shape() {
let fixture = json!({
"data": {
"link": {
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
},
"details": {
"title": "Move PR commands server-side",
"body": "Detailed description",
"state": "closed",
"draft": false,
"merged": true,
"merged_at": "2026-04-23T15:45:00Z",
"mergeable": false,
"additions": 234,
"deletions": 67,
"changed_files": 5,
"author": {
"login": "octocat"
},
"head_branch": "fabro/run/demo",
"base_branch": "main",
"timestamps": {
"created_at": "2026-04-23T15:40:00Z",
"updated_at": "2026-04-23T15:45:00Z"
}
}
},
"meta": {
"details_status": "available"
}
});
let detail: PullRequestResponse =
serde_json::from_value(fixture.clone()).expect("response should deserialize");
assert_eq!(serde_json::to_value(detail).unwrap(), fixture);
}
fn assert_same_type_as_pull_request<T: 'static>(_: &T) {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<PullRequest>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<PullRequest>()
);
}
fn assert_same_type_as_pull_request_link<T: 'static>(_: &T) {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<PullRequestLink>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<PullRequestLink>()
);
}
fn assert_same_type_as_merge_strategy<T: 'static>(_: &T) {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<MergeStrategy>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<MergeStrategy>()
);
}