Skip to content
Merged
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
26 changes: 26 additions & 0 deletions docs/internal/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -2101,6 +2101,26 @@ These legacy events may appear in older run logs. Current CLI backend runs do no

## Pull request events

### `pull_request.creation_requested`

```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "pull_request.creation_requested",
"properties": {
"creation_id": "01KYYK70WTZT2E551P3H5P0059",
"model": "gpt-5.4",
"force": false
}
}
```

| Property | Type | Description |
|----------|------|-------------|
| `creation_id` | string | Stable identifier for this pull request creation request |
| `model` | string | Resolved model identifier used to generate the pull request content |
| `force` | boolean | Whether creation is allowed for a run without a successful conclusion |

### `pull_request.created`

```json
Expand Down Expand Up @@ -2173,15 +2193,21 @@ These legacy events may appear in older run logs. Current CLI backend runs do no
"id": "...", "ts": "...", "run_id": "...",
"event": "pull_request.failed",
"properties": {
"creation_id": "01KYYK70WTZT2E551P3H5P0059",
"error": "insufficient permissions"
}
}
```

| Property | Type | Description |
|----------|------|-------------|
| `creation_id` | string (optional) | Explicit pull request creation this failure resolves. Absent for publish-stage failures. |
| `error` | string | Error message |

When `creation_id` names the run's pending pull request creation, the run
projection marks that creation `failed`. A `pull_request.failed` event without
a `creation_id` (the workflow publish stage) does not change creation state.

## Artifact events

### `artifact.captured`
Expand Down
112 changes: 99 additions & 13 deletions docs/public/api-reference/fabro-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2575,7 +2575,15 @@ paths:
operationId: createRunPullRequest
tags: [Runs]
summary: Create Run Pull Request
description: Creates a pull request for a completed run on GitHub and persists the record on the server.
description: |
Durably requests creation of a pull request for a completed run. The
server generates the pull request content and creates the GitHub pull
request after this request returns. Poll the URL in the Location
response header until the creation succeeds or fails.

If a creation is already pending for the run, the response returns
that creation unchanged; any different `model` or `force` values in
the new request are ignored.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
Expand All @@ -2585,12 +2593,23 @@ paths:
schema:
$ref: "#/components/schemas/CreateRunPullRequestRequest"
responses:
"200":
description: Pull request created
"202":
description: Pull request creation was durably accepted
headers:
Location:
description: URL for the latest pull request creation on this run.
schema:
type: string
format: uri-reference
Retry-After:
description: Suggested number of seconds before polling the creation status.
schema:
type: integer
minimum: 0
content:
application/json:
schema:
$ref: "#/components/schemas/PullRequestLink"
$ref: "#/components/schemas/PullRequestCreation"
"400":
description: Pull request creation does not apply to this run
headers:
Expand Down Expand Up @@ -2620,15 +2639,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"502":
description: GitHub rejected the pull request creation request
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: GitHub integration is unavailable on the server
headers:
Expand Down Expand Up @@ -2723,6 +2733,31 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"

/api/v1/runs/{id}/pull_request/creation:
get:
operationId: getRunPullRequestCreation
tags: [Runs]
summary: Get Run Pull Request Creation
description: Returns the latest explicit pull request creation requested for this run.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: Latest pull request creation state
content:
application/json:
schema:
$ref: "#/components/schemas/PullRequestCreation"
"404":
description: Run or pull request creation not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"

/api/v1/runs/{id}/pull_request/merge:
post:
operationId: mergeRunPullRequest
Expand Down Expand Up @@ -11438,6 +11473,10 @@ components:
oneOf:
- $ref: "#/components/schemas/PullRequestLink"
- type: "null"
pull_request_creation:
oneOf:
- $ref: "#/components/schemas/PullRequestCreation"
- type: "null"
superseded_by:
type: ["string", "null"]
retried_from:
Expand Down Expand Up @@ -12244,6 +12283,53 @@ components:
description: Optional model override for generating the pull request description.
example: claude-sonnet-4-6

PullRequestCreationId:
description: Stable identifier for one explicit pull request creation request.
type: string
example: 01KYYK70WTZT2E551P3H5P0059

PullRequestCreationStatus:
description: Durable state of a pull request creation request.
type: string
enum:
- pending
- succeeded
- failed

PullRequestCreation:
description: Durable status for the latest explicit pull request creation requested for a run.
type: object
required:
- id
- status
- model
- force
- requested_at
- updated_at
properties:
id:
$ref: "#/components/schemas/PullRequestCreationId"
status:
$ref: "#/components/schemas/PullRequestCreationStatus"
model:
type: string
description: Resolved model identifier used to generate the pull request content.
force:
type: boolean
description: Whether creation was allowed for a run without a successful conclusion.
requested_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
pull_request:
oneOf:
- $ref: "#/components/schemas/PullRequestLink"
- type: "null"
error:
type: ["string", "null"]

LinkRunPullRequestRequest:
description: Request body for linking an existing GitHub pull request to a run.
type: object
Expand Down
3 changes: 2 additions & 1 deletion lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1422,7 +1422,8 @@ mod tests {
draft: true,
});
emit(&mut ui, Event::PullRequestFailed {
error: "auth token expired".into(),
creation_id: None,
error: "auth token expired".into(),
});

insta::assert_snapshot!(rendered(&buffer), @r"
Expand Down
62 changes: 54 additions & 8 deletions lib/apps/fabro-cli/tests/it/cmd/pr_create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,35 @@ fn pr_create_uses_server_endpoint_and_prints_url() {
.json_body(serde_json::json!({
"force": false
}));
then.status(202)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"id": "01KYYK70WTZT2E551P3H5P0059",
"status": "pending",
"model": "kimi-k3",
"force": false,
"requested_at": "2026-08-01T12:00:00Z",
"updated_at": "2026-08-01T12:00:00Z"
}));
});
let status_mock = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/pull_request/creation"));
then.status(200)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
"id": "01KYYK70WTZT2E551P3H5P0059",
"status": "succeeded",
"model": "kimi-k3",
"force": false,
"requested_at": "2026-08-01T12:00:00Z",
"updated_at": "2026-08-01T12:00:15Z",
"pull_request": {
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
}
}));
});

Expand All @@ -99,6 +121,7 @@ fn pr_create_uses_server_endpoint_and_prints_url() {

resolve_mock.assert();
create_mock.assert();
status_mock.assert();
}

#[test]
Expand All @@ -116,13 +139,35 @@ fn pr_create_passes_force_and_model_to_server() {
"force": true,
"model": "gpt-5.2"
}));
then.status(202)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"id": "01KYYK70WTZT2E551P3H5P0059",
"status": "pending",
"model": "gpt-5.2",
"force": true,
"requested_at": "2026-08-01T12:00:00Z",
"updated_at": "2026-08-01T12:00:00Z"
}));
});
let status_mock = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/pull_request/creation"));
then.status(200)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
"id": "01KYYK70WTZT2E551P3H5P0059",
"status": "succeeded",
"model": "gpt-5.2",
"force": true,
"requested_at": "2026-08-01T12:00:00Z",
"updated_at": "2026-08-01T12:00:15Z",
"pull_request": {
"owner": "fabro-sh",
"repo": "fabro",
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
}
}));
});

Expand Down Expand Up @@ -154,4 +199,5 @@ fn pr_create_passes_force_and_model_to_server() {

resolve_mock.assert();
create_mock.assert();
status_mock.assert();
}
4 changes: 4 additions & 0 deletions lib/apps/fabro-server/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ impl ApiError {
self.status
}

pub(crate) fn detail(&self) -> &str {
&self.detail
}

pub(crate) fn code(&self) -> Option<&str> {
self.code.as_deref()
}
Expand Down
10 changes: 9 additions & 1 deletion lib/apps/fabro-server/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use crate::interp::process_env_var;
use crate::server::{
self, AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
spawn_automation_scheduler, spawn_scheduler,
spawn_automation_scheduler, spawn_pull_request_creation_supervisor, spawn_scheduler,
};
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
use crate::startup::{migrate_startup_vault, resolve_startup, validate_startup_configuration};
Expand Down Expand Up @@ -826,6 +826,8 @@ where
}
spawn_scheduler(Arc::clone(&state));
spawn_automation_scheduler(Arc::clone(&state));
let pull_request_creation_supervisor =
spawn_pull_request_creation_supervisor(Arc::clone(&state));
let router = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions {
web_enabled,
#[cfg(debug_assertions)]
Expand Down Expand Up @@ -995,6 +997,12 @@ where
}
} else {
cleanup_handle.abort();
pull_request_creation_supervisor.abort();
}
if let Err(join_err) = pull_request_creation_supervisor.await {
if !join_err.is_cancelled() {
warn!(error = %join_err, "Pull request creation supervisor task panicked");
}
}

serve_result?;
Expand Down
Loading
Loading