fabro/lib/crates/fabro-cli/tests/it/cmd/pr_view.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

246 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![allow(
clippy::absolute_paths,
reason = "This test module prefers explicit type paths over extra imports."
)]
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::run_event::PullRequestCreatedProps;
use fabro_types::{EventBody, RunEvent, RunId};
use httpmock::MockServer;
use super::support::{mock_resolved_run, server_endpoint, setup_seeded_completed_dry_run};
use crate::support::unique_run_id;
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["pr", "view", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
View pull request details
Usage: fabro pr view [OPTIONS] <RUN_ID>
Arguments:
<RUN_ID> Run ID or prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn pr_view_missing_pull_request_json_errors() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let mut cmd = context.command();
cmd.args(["pr", "view", &run.run_id]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No pull request found in store. Create one first with: fabro pr create [ULID]
");
}
#[test]
fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let run_id: RunId = run.run_id.parse().unwrap();
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let (client, base_url) =
server_endpoint(&context.storage_dir).expect("server endpoint should exist");
let event = RunEvent {
id: ulid::Ulid::new().to_string(),
ts: chrono::Utc::now(),
run_id,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::PullRequestCreated(PullRequestCreatedProps {
pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
pr_number: 123,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
base_branch: "main".to_string(),
head_branch: "fabro/run/demo".to_string(),
title: "Map the constellations".to_string(),
draft: false,
}),
};
client
.post(format!("{base_url}/api/v1/runs/{run_id}/events"))
.json(&event)
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
});
let mut cmd = context.command();
cmd.args(["pr", "view", &run.run_id]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
#123 Pull request
URL: https://github.com/fabro-sh/fabro/pull/123
Details: unavailable (integration_unavailable)
----- stderr -----
");
}
#[test]
fn pr_view_uses_server_pull_request_endpoint_and_renders_merged_state() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let resolve_mock = mock_resolved_run(&server, "nightly-build", &run_id);
let detail_mock = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/pull_request"));
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": {
"link": {
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
},
"details": {
"title": "Map the constellations",
"body": "Detailed description",
"state": "closed",
"draft": false,
"merged": true,
"merged_at": "2026-04-06T12:30:00Z",
"mergeable": false,
"additions": 10,
"deletions": 3,
"changed_files": 2,
"author": {
"login": "testuser"
},
"head_branch": "fabro/run/demo",
"base_branch": "main",
"timestamps": {
"created_at": "2026-04-05T12:00:00Z",
"updated_at": "2026-04-06T12:30:00Z"
}
}
},
"meta": {
"details_status": "available"
}
})
.to_string(),
);
});
let mut cmd = context.command();
cmd.args([
"pr",
"view",
"--server",
&server.base_url(),
"nightly-build",
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
#123 Map the constellations
State: merged
URL: https://github.com/fabro-sh/fabro/pull/123
Branch: fabro/run/demo -> main
Author: testuser
Changes: +10 -3 (2 files)
----- stderr -----
");
resolve_mock.assert();
detail_mock.assert();
}
#[test]
fn pr_view_renders_unavailable_details_reason() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let resolve_mock = mock_resolved_run(&server, "nightly-build", &run_id);
let detail_mock = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/pull_request"));
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": {
"link": {
"owner": "acme",
"repo": "widgets",
"number": 42,
"html_url": "https://github.com/acme/widgets/pull/42"
},
"details": null
},
"meta": {
"details_status": "unavailable",
"details_unavailable_reason": "fetch_failed"
}
})
.to_string(),
);
});
let mut cmd = context.command();
cmd.args([
"pr",
"view",
"--server",
&server.base_url(),
"nightly-build",
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
#42 Pull request
URL: https://github.com/acme/widgets/pull/42
Details: unavailable (fetch_failed)
----- stderr -----
");
resolve_mock.assert();
detail_mock.assert();
}