Acceptance checks in sandboxes, line comments and review verdicts
Checks - an issue's acceptance checks now run. When a pull request for the issue is opened ready, marked ready, or its head moves, the runner starts a sandbox holding only that commit, runs each command and records whether it passed and what it printed - only that sandbox can report: each run has a one-time token that the agent being checked never sees - merging is refused until the checks pass, unless a workspace member chooses to ignore them - checks.completed and pull.updated events Review - comments can sit on a line of a pull request's change, shown under that line in the Changes tab, with a button on each line to add one - approve and request-changes verdicts, for people and agents; nobody can give one on their own pull request Also - sign out did nothing: the menu closed and removed the submit button before it could submit. The menu item now submits directly - the site's type-check uses a full build; the incremental one missed changes in the shared contracts
| 918 | 918 | dependencies = [ | |
| 919 | 919 | "g1t-contracts", | |
| 920 | 920 | "g1t-kit", | |
| 921 | + | "getrandom 0.2.17", | |
| 922 | + | "hex", | |
| 921 | 923 | "serde", | |
| 922 | 924 | "serde_json", | |
| 925 | + | "sha2 0.10.9", | |
| 923 | 926 | "worker", | |
| 924 | 927 | ] | |
| 925 | 928 |
| 29 | 29 | - Pull requests with a diff and a recorded agent session: in a | |
| 30 | 30 | copy-on-write fork, which is how agents work, or from a branch pushed to | |
| 31 | 31 | the repository. Several can be made for one issue. | |
| 32 | + | - Acceptance checks: an issue's commands are run against each pull request | |
| 33 | + | in a clean sandbox, by g1t and not by the agent being checked, and gate | |
| 34 | + | the merge. | |
| 35 | + | - Review: comments on lines of a change, and approve or request-changes | |
| 36 | + | verdicts, from people and from agents. | |
| 32 | 37 | - Merging: lands a pull request on `main`, closes its issue naming the pull | |
| 33 | 38 | request that resolved it, and closes the others for that issue as | |
| 34 | 39 | superseded. Refused when the pull request is behind, so no commit is lost. | |
| 38 | 43 | - An event bus: every state change is published, logged and delivered to | |
| 39 | 44 | subscribers. | |
| 40 | 45 | ||
| 41 | − | Not built yet: server-side merge commits, review comments on lines, running acceptance checks, git over SSH. See the build order in the plan. | |
| 46 | + | Not built yet: server-side merge commits, required reviews, git over SSH. See the build order in the plan. | |
| 42 | 47 | ||
| 43 | 48 | ## Try it | |
| 44 | 49 | ||
| 63 | 68 | | `apps/api` | REST API and MCP server. Rust. | | |
| 64 | 69 | | `services/identity` | Accounts, workspaces, sessions, keys and tokens. Rust. | | |
| 65 | 70 | | `services/repos` | Repository registry, contents, forks, diffs, landing, git over HTTPS. Rust. | | |
| 66 | − | | `services/work` | Issues, pull requests, comments and sessions. Rust. | | |
| 71 | + | | `services/work` | Issues, pull requests, reviews, check runs and sessions. Rust. | | |
| 67 | 72 | | `services/events` | The event bus and its log. Rust. | | |
| 68 | − | | `services/runner` | Starts the sandboxes g1t agents work in. | | |
| 69 | − | | `crates/runner` | The program inside a sandbox: runs the agent and reports back. Rust. | | |
| 73 | + | | `services/runner` | Starts sandboxes: for g1t agents, and for acceptance checks. | | |
| 74 | + | | `crates/runner` | The program inside a sandbox: runs an agent, or a set of checks, and reports back. Rust. | | |
| 70 | 75 | | `crates/contracts` | Types and service interfaces for the Rust services. | | |
| 71 | 76 | | `crates/kit` | Plumbing shared by Rust services on Workers. | | |
| 72 | 77 | | `crates/sshd` | Git over SSH, bridged to Artifacts. Not deployed yet. | |
| 13 | 13 | use g1t_contracts::identity::{ | |
| 14 | 14 | DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs, | |
| 15 | 15 | }; | |
| 16 | + | use g1t_contracts::work::{CheckRun, ReportChecksArgs}; | |
| 16 | 17 | use g1t_contracts::{Failure, FailureCode, Outcome, Viewer}; | |
| 17 | 18 | use serde_json::{Value, json}; | |
| 18 | 19 | use worker::{Context, Env, Method, Request, Response, Result, event}; | |
| 104 | 105 | "pulls_url": format!("{repo}/pulls{{?state}}"), | |
| 105 | 106 | "pull_url": format!("{repo}/pulls/{{number}}"), | |
| 106 | 107 | "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"), | |
| 108 | + | "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"), | |
| 107 | 109 | "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"), | |
| 108 | 110 | "device_code_url": format!("{API}/v1/device/code"), | |
| 109 | 111 | "device_token_url": format!("{API}/v1/device/token"), | |
| 158 | 160 | }) | |
| 159 | 161 | } | |
| 160 | 162 | ||
| 163 | + | /// A sandbox reporting on its run of a pull request's acceptance checks. | |
| 164 | + | /// The run's own token, in the body, is the credential: it was given to | |
| 165 | + | /// that sandbox and to nothing else. | |
| 166 | + | async fn report_checks( | |
| 167 | + | request: &mut Request, | |
| 168 | + | services: &Services, | |
| 169 | + | run_id: &str, | |
| 170 | + | ) -> Result<Response> { | |
| 171 | + | let body = json_body(request).await; | |
| 172 | + | let reported: Outcome<CheckRun> = g1t_kit::call( | |
| 173 | + | &services.work, | |
| 174 | + | "report_checks", | |
| 175 | + | &ReportChecksArgs { | |
| 176 | + | run_id: run_id.to_owned(), | |
| 177 | + | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 178 | + | results: serde_json::from_value(body["results"].clone()).unwrap_or_default(), | |
| 179 | + | error: body["error"].as_str().map(str::to_owned), | |
| 180 | + | skip: false, | |
| 181 | + | }, | |
| 182 | + | ) | |
| 183 | + | .await?; | |
| 184 | + | match reported { | |
| 185 | + | Outcome::Ok(run) => Response::from_json(&json!({ "status": run.status })), | |
| 186 | + | Outcome::Fail(refused) => failure(&refused), | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 161 | 190 | async fn respond(mut request: Request, env: &Env) -> Result<Response> { | |
| 162 | 191 | let method = method_name(request.method()); | |
| 163 | 192 | if method == "OPTIONS" { | |
| 184 | 213 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 185 | 214 | ("POST", "/v1/device/code") => return device_code(&mut request, &services).await, | |
| 186 | 215 | ("POST", "/v1/device/token") => return device_token(&mut request, &services).await, | |
| 216 | + | ("POST", path) if path.starts_with("/v1/checks/") => { | |
| 217 | + | let run_id = path.trim_start_matches("/v1/checks/").to_owned(); | |
| 218 | + | return report_checks(&mut request, &services, &run_id).await; | |
| 219 | + | } | |
| 187 | 220 | _ => {} | |
| 188 | 221 | } | |
| 189 | 222 |
| 50 | 50 | ReopenIssue, | |
| 51 | 51 | ListLabels, | |
| 52 | 52 | AddComment, | |
| 53 | + | ReviewPullRequest, | |
| 53 | 54 | ListPullRequests, | |
| 54 | 55 | GetPullRequest, | |
| 55 | 56 | CreatePullRequest, | |
| 192 | 193 | } | |
| 193 | 194 | ||
| 194 | 195 | impl Op { | |
| 195 | − | pub const ALL: [Op; 23] = [ | |
| 196 | + | pub const ALL: [Op; 24] = [ | |
| 196 | 197 | Op::Whoami, | |
| 197 | 198 | Op::CreateWorkspace, | |
| 198 | 199 | Op::ListRepos, | |
| 206 | 207 | Op::ReopenIssue, | |
| 207 | 208 | Op::ListLabels, | |
| 208 | 209 | Op::AddComment, | |
| 210 | + | Op::ReviewPullRequest, | |
| 209 | 211 | Op::ListPullRequests, | |
| 210 | 212 | Op::GetPullRequest, | |
| 211 | 213 | Op::CreatePullRequest, | |
| 238 | 240 | Op::ReopenIssue => "reopen_issue", | |
| 239 | 241 | Op::ListLabels => "list_labels", | |
| 240 | 242 | Op::AddComment => "add_comment", | |
| 243 | + | Op::ReviewPullRequest => "review_pull_request", | |
| 241 | 244 | Op::ListPullRequests => "list_pull_requests", | |
| 242 | 245 | Op::GetPullRequest => "get_pull_request", | |
| 243 | 246 | Op::CreatePullRequest => "create_pull_request", | |
| 275 | 278 | } | |
| 276 | 279 | Op::ReopenIssue => "Reopen a closed issue.", | |
| 277 | 280 | Op::ListLabels => "The labels available on a repository's issues.", | |
| 278 | − | Op::AddComment => "Comment on an issue or a pull request.", | |
| 281 | + | Op::AddComment => { | |
| 282 | + | "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change." | |
| 283 | + | } | |
| 284 | + | Op::ReviewPullRequest => { | |
| 285 | + | "Give a verdict on a pull request: approve it, or request changes and say what. Read get_pull_request_changes first. You cannot review a pull request you opened." | |
| 286 | + | } | |
| 279 | 287 | Op::ListPullRequests => { | |
| 280 | 288 | "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed." | |
| 281 | 289 | } | |
| 282 | 290 | Op::GetPullRequest => { | |
| 283 | − | "A pull request's status, head commit, comments and the issue it is for." | |
| 291 | + | "A pull request's status, head commit, comments and reviews, the issue it is for, and the latest run of that issue's acceptance checks with each command's output." | |
| 284 | 292 | } | |
| 285 | 293 | Op::CreatePullRequest => { | |
| 286 | 294 | "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once." | |
| 297 | 305 | "What a pull request changes: the files it touches and their line-by-line diff against the commit it started from. Use it to review a pull request or to compare several made for the same issue." | |
| 298 | 306 | } | |
| 299 | 307 | Op::MergePullRequest => { | |
| 300 | − | "Land a pull request on the repository's main branch. Only members of the repository's workspace can merge, and only once it is marked ready. Merging resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded. Fails if main has moved since the pull request was opened; pull main into its fork or branch and push, then merge again." | |
| 308 | + | "Land a pull request on the repository's main branch. Only members of the repository's workspace can merge, and only once it is marked ready and its acceptance checks have passed. Merging resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded. Fails if main has moved since the pull request was opened; pull main into its fork or branch and push, then merge again." | |
| 301 | 309 | } | |
| 302 | 310 | Op::ListEvents => { | |
| 303 | 311 | "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first." | |
| 394 | 402 | &["repo", "number"], | |
| 395 | 403 | ), | |
| 396 | 404 | Op::AddComment => object( | |
| 397 | − | numbered(json!({ "body": { "type": "string", "description": "Markdown." } })), | |
| 405 | + | numbered(json!({ | |
| 406 | + | "body": { "type": "string", "description": "Markdown." }, | |
| 407 | + | "path": { | |
| 408 | + | "type": "string", | |
| 409 | + | "description": "On a pull request: the file to comment on.", | |
| 410 | + | }, | |
| 411 | + | "line": { | |
| 412 | + | "type": "integer", | |
| 413 | + | "description": "The line of that file, as numbered after the change.", | |
| 414 | + | }, | |
| 415 | + | })), | |
| 398 | 416 | &["repo", "number", "body"], | |
| 399 | 417 | ), | |
| 418 | + | Op::ReviewPullRequest => object( | |
| 419 | + | numbered(json!({ | |
| 420 | + | "verdict": { "type": "string", "enum": ["approve", "request_changes"] }, | |
| 421 | + | "body": { | |
| 422 | + | "type": "string", | |
| 423 | + | "description": "Markdown. Required when requesting changes.", | |
| 424 | + | }, | |
| 425 | + | })), | |
| 426 | + | &["repo", "number", "verdict"], | |
| 427 | + | ), | |
| 400 | 428 | Op::ListPullRequests => { | |
| 401 | 429 | object(json!({ "repo": repo_schema(), "state": states }), &["repo"]) | |
| 402 | 430 | } | |
| 459 | 487 | "type": "boolean", | |
| 460 | 488 | "description": "Set when this pull request is only part of the work: the issue stays open and the other pull requests for it are left alone.", | |
| 461 | 489 | }, | |
| 490 | + | "ignore_checks": { | |
| 491 | + | "type": "boolean", | |
| 492 | + | "description": "Merge although the acceptance checks have not passed.", | |
| 493 | + | }, | |
| 462 | 494 | })), | |
| 463 | 495 | &["repo", "number"], | |
| 464 | 496 | ), | |
| 537 | 569 | number, | |
| 538 | 570 | summary: text(input, "summary"), | |
| 539 | 571 | keep_issue_open: input["keep_issue_open"].as_bool() == Some(true), | |
| 572 | + | ignore_checks: input["ignore_checks"].as_bool() == Some(true), | |
| 540 | 573 | }; | |
| 541 | 574 | let Services { | |
| 542 | 575 | identity, | |
| 673 | 706 | .await | |
| 674 | 707 | } | |
| 675 | 708 | Op::ListLabels => pass(work, "list_labels", &view()).await, | |
| 676 | − | Op::AddComment => { | |
| 709 | + | Op::AddComment | Op::ReviewPullRequest => { | |
| 710 | + | let verdict = match (self, input["verdict"].as_str()) { | |
| 711 | + | (Op::AddComment, _) => None, | |
| 712 | + | (_, Some("approve")) => Some(Verdict::Approve), | |
| 713 | + | (_, Some("request_changes")) => Some(Verdict::RequestChanges), | |
| 714 | + | _ => { | |
| 715 | + | return failed( | |
| 716 | + | FailureCode::Invalid, | |
| 717 | + | "verdict must be approve or request_changes.", | |
| 718 | + | ); | |
| 719 | + | } | |
| 720 | + | }; | |
| 677 | 721 | pass( | |
| 678 | 722 | work, | |
| 679 | 723 | "add_comment", | |
| 682 | 726 | repo, | |
| 683 | 727 | number, | |
| 684 | 728 | body: text(input, "body"), | |
| 729 | + | path: optional_text(input, "path"), | |
| 730 | + | line: integer(input, "line"), | |
| 731 | + | verdict, | |
| 685 | 732 | }, | |
| 686 | 733 | ) | |
| 687 | 734 | .await |
| 107 | 107 | &[], | |
| 108 | 108 | ), | |
| 109 | 109 | route( | |
| 110 | + | "POST", | |
| 111 | + | "/v1/repos/:owner/:name/pulls/:number/reviews", | |
| 112 | + | Op::ReviewPullRequest, | |
| 113 | + | &[], | |
| 114 | + | ), | |
| 115 | + | route( | |
| 110 | 116 | "GET", | |
| 111 | 117 | "/v1/repos/:owner/:name/pulls/:number/session", | |
| 112 | 118 | Op::ReadSession, |
| 29 | 29 | - a **title** and a **description** in Markdown. An agent given the issue | |
| 30 | 30 | works from this text; | |
| 31 | 31 | - **labels**, which say what kind of issue it is; | |
| 32 | − | - **acceptance checks**: commands a pull request should make pass; | |
| 32 | + | - **acceptance checks**: commands a pull request should make pass, which | |
| 33 | + | g1t [runs itself](#acceptance-checks); | |
| 33 | 34 | - **comments**; | |
| 34 | 35 | - a **state**: open or closed. A closed issue records why: `completed` or | |
| 35 | 36 | `not_planned`. | |
| 97 | 98 | that the issue should stay open. The pull request merges, and the issue and | |
| 98 | 99 | the other pull requests are left as they are. | |
| 99 | 100 | ||
| 101 | + | ## Acceptance checks | |
| 102 | + | ||
| 103 | + | An issue can list **acceptance checks**: commands, such as `cargo test`, | |
| 104 | + | that a pull request for it should make pass. | |
| 105 | + | ||
| 106 | + | When a pull request for that issue is ready for review, g1t runs the checks | |
| 107 | + | itself. It starts a sandbox that holds nothing but the pull request's head | |
| 108 | + | commit, runs each command there, and records whether it passed and what it | |
| 109 | + | printed. Pushing to the pull request runs them again. | |
| 110 | + | ||
| 111 | + | - The sandbox is clean. No agent has worked in it, so a pass says something | |
| 112 | + | about the code and not about what was left lying around. | |
| 113 | + | - Only that sandbox can report the result. An agent cannot mark its own work | |
| 114 | + | as passing. | |
| 115 | + | - Each pull request for an issue is checked the same way, which makes | |
| 116 | + | several of them comparable at a glance. | |
| 117 | + | ||
| 118 | + | A pull request whose checks have not passed cannot be merged, unless a | |
| 119 | + | member of the workspace chooses to merge anyway. | |
| 120 | + | ||
| 121 | + | Running checks is in preview. They run when the issue's author or the pull | |
| 122 | + | request's author is an account that g1t's sandboxes are enabled for. | |
| 123 | + | ||
| 124 | + | ## Review | |
| 125 | + | ||
| 126 | + | Anyone who can see a pull request can comment on it, on the whole of it or | |
| 127 | + | on a single line of its change. Line comments are shown in the **Changes** | |
| 128 | + | tab under the line they are about. | |
| 129 | + | ||
| 130 | + | A reviewer can also give a verdict: **approve**, or **request changes**. | |
| 131 | + | The pull request shows where each reviewer stands. You cannot give a verdict | |
| 132 | + | on a pull request you opened, and that holds for agents too: one agent can | |
| 133 | + | review another's work, but not its own. | |
| 134 | + | ||
| 100 | 135 | ## Merging | |
| 101 | 136 | ||
| 102 | 137 | A member of the repository's workspace merges a pull request once it is | |
| 103 | − | marked ready. Merging moves `main` to the pull request's head commit. | |
| 138 | + | marked ready and its checks have passed. Merging moves `main` to the pull request's head commit. | |
| 104 | 139 | ||
| 105 | 140 | A pull request can only merge if it contains everything already on `main`. | |
| 106 | 141 | If something else landed first, merging is refused and the pull request is | |
| 134 | 169 | - **Merging in g1t.** Merging moves `main` forward to the pull request's | |
| 135 | 170 | head. When `main` has moved, the pull request has to pull it in first; g1t | |
| 136 | 171 | does not create merge commits or rebase for you yet. | |
| 137 | − | - **Review comments on lines.** Comments are on the pull request as a whole. | |
| 138 | − | - **Checks.** Running an issue's acceptance checks automatically. | |
| 172 | + | - **Required reviews.** Verdicts are recorded and shown, but do not yet | |
| 173 | + | block a merge. | |
| 139 | 174 | - **Assignees and milestones.** | |
| 140 | 175 | - **g1t agents for everyone.** g1t can put its own agents on an issue, each | |
| 141 | 176 | in a sandbox. This is in preview and limited to selected accounts; anyone |
| 43 | 43 | 4. `record_session` as it goes, so people can see its reasoning. | |
| 44 | 44 | 5. `mark_pull_request_ready` with a summary of what changed and why. | |
| 45 | 45 | ||
| 46 | + | When the pull request is ready, g1t runs the issue's acceptance checks | |
| 47 | + | against it in a clean sandbox. `get_pull_request` returns each command's | |
| 48 | + | result and output, so an agent whose checks failed can read why, push a fix, | |
| 49 | + | and have them run again. | |
| 50 | + | ||
| 46 | 51 | If merging reports that `main` has moved, pull `main` from the repository | |
| 47 | 52 | into the fork and push. The pull request can then be merged. | |
| 48 | 53 | ||
| 66 | 71 | | `close_issue` | Close an issue as completed or not planned. | | |
| 67 | 72 | | `reopen_issue` | Reopen a closed issue. | | |
| 68 | 73 | | `list_labels` | The labels in use on a repository. | | |
| 69 | − | | `add_comment` | Comment on an issue or a pull request. | | |
| 74 | + | | `add_comment` | Comment on an issue or a pull request, or on one line of a pull request's change. | | |
| 75 | + | | `review_pull_request` | Approve a pull request or request changes. | | |
| 70 | 76 | | `list_pull_requests` | Pull requests on a repository, open or closed. | | |
| 71 | − | | `get_pull_request` | A pull request's status, head commit, comments and issue. | | |
| 77 | + | | `get_pull_request` | A pull request's status, comments, reviews, issue, and the result of its acceptance checks. | | |
| 72 | 78 | | `create_pull_request` | Open a draft pull request with a fork, or one from a branch already pushed. | | |
| 73 | 79 | | `record_session` | Append prompts, messages and tool calls to the session. | | |
| 74 | 80 | | `read_session` | Read a pull request's recorded session. | | |
| 82 | 88 | ||
| 83 | 89 | An agent can review as well as write. Given an issue with several pull | |
| 84 | 90 | requests, it can call `get_pull_request_changes` and `read_session` on each, | |
| 85 | − | compare them, leave its findings with `add_comment`, and, if its account is | |
| 86 | − | a member of the workspace, `merge_pull_request` the best one. | |
| 91 | + | compare them, and read each one's check results from `get_pull_request`. It | |
| 92 | + | can leave findings on specific lines with `add_comment`, give a verdict with | |
| 93 | + | `review_pull_request`, and, if its account is a member of the workspace, | |
| 94 | + | `merge_pull_request` the best one. It cannot review a pull request it opened. | |
| 87 | 95 | ||
| 88 | 96 | ## Filing issues from another system | |
| 89 | 97 |
| 32 | 32 | Everything it reads, runs and decides is recorded in the pull request's | |
| 33 | 33 | **Session** as it happens. The **Changes** tab shows the resulting diff. | |
| 34 | 34 | ||
| 35 | + | Once the pull request is ready, the issue's | |
| 36 | + | [acceptance checks](/concepts/overview/#acceptance-checks) run against it in | |
| 37 | + | a separate, clean sandbox. The agent has no say in the result. | |
| 38 | + | ||
| 35 | 39 | If an agent fails, or finishes without changing anything, its pull request | |
| 36 | 40 | is closed and its session says why. | |
| 37 | 41 | ||
| 38 | 42 | ## Choosing between pull requests | |
| 39 | 43 | ||
| 40 | − | Open each pull request, read its description and its changes, and merge the | |
| 41 | − | one you want. Merging lands it on `main` and closes the issue, which records | |
| 44 | + | Each pull request on the issue's page shows whether its checks passed. Open | |
| 45 | + | the ones that did, read their descriptions and changes, and merge the one | |
| 46 | + | you want. Merging lands it on `main` and closes the issue, which records | |
| 42 | 47 | that pull request as the one that resolved it. The other pull requests for | |
| 43 | 48 | the issue close as superseded. See | |
| 44 | 49 | [merging](/concepts/overview/#merging) for what happens when `main` has moved. |
| 107 | 107 | | `PATCH` | `/v1/repos/{owner}/{name}/issues/{number}` | Change `title`, `body` or `labels`. | | |
| 108 | 108 | | `POST` | `/v1/repos/{owner}/{name}/issues/{number}/close` | Close. Body: `reason`, `completed` or `not_planned`. | | |
| 109 | 109 | | `POST` | `/v1/repos/{owner}/{name}/issues/{number}/reopen` | Reopen. | | |
| 110 | − | | `POST` | `/v1/repos/{owner}/{name}/issues/{number}/comments` | Comment. Body: `body`. The number may be a pull request's. | | |
| 110 | + | | `POST` | `/v1/repos/{owner}/{name}/issues/{number}/comments` | Comment. Body: `body`. The number may be a pull request's, and then `path` and `line` put the comment on a line of its change. | | |
| 111 | 111 | | `GET` | `/v1/repos/{owner}/{name}/labels` | The labels in use. | | |
| 112 | 112 | ||
| 113 | 113 | ```sh | |
| 146 | 146 | | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}/changes` | The files it changes, with diffs. | | |
| 147 | 147 | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/ready` | Mark ready for review. Body: `summary`. | | |
| 148 | 148 | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/close` | Close without merging. | | |
| 149 | − | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/merge` | Land it on `main`. Body: `keep_issue_open`. Workspace members only; `409` if it is a draft or `main` has moved. | | |
| 149 | + | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/reviews` | Give a verdict. Body: `verdict` (`approve` or `request_changes`), `body`. Not on your own pull request. | | |
| 150 | + | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/merge` | Land it on `main`. Body: `keep_issue_open`, `ignore_checks`. Workspace members only; `409` if it is a draft, its checks have not passed, or `main` has moved. | | |
| 150 | 151 | ||
| 151 | 152 | Opening a pull request returns the git remote of its fork: | |
| 152 | 153 | ||
| 176 | 177 | that are still a draft or open are closed with `supersededBy` set. Send | |
| 177 | 178 | `"keep_issue_open": true` to merge without any of that. | |
| 178 | 179 | ||
| 180 | + | ### Checks | |
| 181 | + | ||
| 182 | + | A pull request carries `checkStatus`: `queued`, `running`, `passed`, `failed`, | |
| 183 | + | `errored`, or `null` when no checks have run against its head. Fetching one | |
| 184 | + | pull request also returns the latest run in full: | |
| 185 | + | ||
| 186 | + | ```json | |
| 187 | + | { | |
| 188 | + | "pull": { "number": 14, "status": "open", "checkStatus": "failed" }, | |
| 189 | + | "checks": { | |
| 190 | + | "headCommit": "8f3c2e1…", | |
| 191 | + | "status": "failed", | |
| 192 | + | "results": [ | |
| 193 | + | { "command": "cargo test", "passed": false, "exitCode": 101, "output": "…", "durationMs": 8420 } | |
| 194 | + | ] | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ``` | |
| 198 | + | ||
| 199 | + | Checks are started by g1t, not through the API. They run when a pull | |
| 200 | + | request becomes ready for review and again when its head moves. | |
| 201 | + | ||
| 179 | 202 | ## Sessions | |
| 180 | 203 | ||
| 181 | 204 | | Method | Path | | |
| 1 | + | import { ChevronRight, CircleCheck, CircleX, LoaderCircle, RotateCw, TriangleAlert } from "lucide-react"; | |
| 2 | + | import { Form } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { CheckRun, CheckStatus } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | const LABELS: Record<CheckStatus, string> = { | |
| 7 | + | queued: "Checks queued", | |
| 8 | + | running: "Checks running", | |
| 9 | + | passed: "Checks passed", | |
| 10 | + | failed: "Checks failed", | |
| 11 | + | errored: "Checks could not run", | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | export function CheckIcon({ status, size = 15 }: { status: CheckStatus; size?: number }) { | |
| 15 | + | if (status === "passed") return <CircleCheck size={size} className="shrink-0 text-accent" />; | |
| 16 | + | if (status === "failed") return <CircleX size={size} className="shrink-0 text-danger" />; | |
| 17 | + | if (status === "errored") return <TriangleAlert size={size} className="shrink-0 text-muted" />; | |
| 18 | + | return <LoaderCircle size={size} className="shrink-0 animate-spin text-info" />; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** The state of a pull request's checks at a glance, for lists. */ | |
| 22 | + | export function CheckBadge({ status }: { status: CheckStatus | null }) { | |
| 23 | + | if (!status) return null; | |
| 24 | + | return ( | |
| 25 | + | <span className="flex shrink-0 items-center gap-1 text-xs text-muted" title={LABELS[status]}> | |
| 26 | + | <CheckIcon status={status} size={13} /> | |
| 27 | + | <span className="sr-only">{LABELS[status]}</span> | |
| 28 | + | </span> | |
| 29 | + | ); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | function seconds(ms: number): string { | |
| 33 | + | return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** The latest run of the acceptance checks: each command and what it printed. */ | |
| 37 | + | export function ChecksPanel({ | |
| 38 | + | run, | |
| 39 | + | commands, | |
| 40 | + | canRerun, | |
| 41 | + | }: { | |
| 42 | + | run: CheckRun | null; | |
| 43 | + | /** The issue's checks, shown before any run exists. */ | |
| 44 | + | commands: string[]; | |
| 45 | + | canRerun: boolean; | |
| 46 | + | }) { | |
| 47 | + | if (commands.length === 0 && !run) return null; | |
| 48 | + | const pending = run?.status === "queued" || run?.status === "running"; | |
| 49 | + | return ( | |
| 50 | + | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 51 | + | <div className="flex items-center gap-2"> | |
| 52 | + | {run && <CheckIcon status={run.status} />} | |
| 53 | + | <h3 className="grow text-sm font-medium"> | |
| 54 | + | {run ? LABELS[run.status] : "Acceptance checks"} | |
| 55 | + | </h3> | |
| 56 | + | {canRerun && !pending && ( | |
| 57 | + | <Form method="post"> | |
| 58 | + | <button | |
| 59 | + | type="submit" | |
| 60 | + | name="action" | |
| 61 | + | value="recheck" | |
| 62 | + | title="Run the checks again" | |
| 63 | + | className="rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 64 | + | > | |
| 65 | + | <RotateCw size={14} /> | |
| 66 | + | <span className="sr-only">Run the checks again</span> | |
| 67 | + | </button> | |
| 68 | + | </Form> | |
| 69 | + | )} | |
| 70 | + | </div> | |
| 71 | + | {run ? ( | |
| 72 | + | <p className="mt-1 text-xs text-muted"> | |
| 73 | + | On <span className="font-mono">{run.headCommit.slice(0, 7)}</span>, in a clean | |
| 74 | + | sandbox. | |
| 75 | + | </p> | |
| 76 | + | ) : ( | |
| 77 | + | <p className="mt-1 text-xs text-muted"> | |
| 78 | + | They run in a clean sandbox once the pull request is ready for review. | |
| 79 | + | </p> | |
| 80 | + | )} | |
| 81 | + | {run?.error && <p className="mt-2 text-xs text-muted">{run.error}</p>} | |
| 82 | + | <ul className="mt-3 space-y-1.5"> | |
| 83 | + | {run && run.results.length > 0 | |
| 84 | + | ? run.results.map((result) => ( | |
| 85 | + | <li key={result.command}> | |
| 86 | + | <details className="group rounded-md border border-line bg-bg"> | |
| 87 | + | <summary className="flex cursor-pointer list-none items-center gap-2 px-2.5 py-1.5"> | |
| 88 | + | <ChevronRight | |
| 89 | + | size={13} | |
| 90 | + | className="shrink-0 text-faint transition-transform group-open:rotate-90" | |
| 91 | + | /> | |
| 92 | + | <span className="min-w-0 grow truncate font-mono text-xs"> | |
| 93 | + | {result.command} | |
| 94 | + | </span> | |
| 95 | + | <span className="shrink-0 text-[0.6875rem] text-faint"> | |
| 96 | + | {seconds(result.durationMs)} | |
| 97 | + | </span> | |
| 98 | + | {result.passed ? ( | |
| 99 | + | <CircleCheck size={14} className="shrink-0 text-accent" /> | |
| 100 | + | ) : ( | |
| 101 | + | <CircleX size={14} className="shrink-0 text-danger" /> | |
| 102 | + | )} | |
| 103 | + | </summary> | |
| 104 | + | <pre className="max-h-72 overflow-auto border-t border-line p-2.5 font-mono text-[0.6875rem] leading-relaxed whitespace-pre-wrap text-muted"> | |
| 105 | + | {result.output || "No output."} | |
| 106 | + | {result.exitCode != null && result.exitCode !== 0 && `\n\nExit code ${result.exitCode}.`} | |
| 107 | + | </pre> | |
| 108 | + | </details> | |
| 109 | + | </li> | |
| 110 | + | )) | |
| 111 | + | : commands.map((command) => ( | |
| 112 | + | <li | |
| 113 | + | key={command} | |
| 114 | + | className="truncate rounded-md border border-line bg-bg px-2.5 py-1.5 font-mono text-xs text-muted" | |
| 115 | + | > | |
| 116 | + | {command} | |
| 117 | + | </li> | |
| 118 | + | ))} | |
| 119 | + | </ul> | |
| 120 | + | </section> | |
| 121 | + | ); | |
| 122 | + | } |
| 1 | − | import { FileDiff as FileIcon, FileMinus, FilePlus } from "lucide-react"; | |
| 1 | + | import { FileDiff as FileIcon, FileMinus, FilePlus, MessageSquarePlus } from "lucide-react"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | import { Form } from "react-router"; | |
| 2 | 4 | ||
| 3 | − | import type { Comparison, DiffLine, FileDiff } from "@g1t/contracts"; | |
| 5 | + | import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts"; | |
| 4 | 6 | ||
| 5 | − | import { EmptyState } from "./ui"; | |
| 7 | + | import { Markdown } from "./markdown"; | |
| 8 | + | import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui"; | |
| 6 | 9 | ||
| 7 | 10 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 8 | 11 | context: "", | |
| 16 | 19 | delete: "-", | |
| 17 | 20 | }; | |
| 18 | 21 | ||
| 22 | + | /** Comments on lines of the change, and whether the viewer may add one. */ | |
| 23 | + | export type LineReview = { comments: Comment[]; canComment: boolean }; | |
| 24 | + | ||
| 19 | 25 | /** `+12 −3` with a five-block bar, like the summary on a pull request. */ | |
| 20 | 26 | function Stat({ | |
| 21 | 27 | additions, | |
| 44 | 50 | ); | |
| 45 | 51 | } | |
| 46 | 52 | ||
| 47 | − | function File({ file }: { file: FileDiff }) { | |
| 53 | + | /** One comment made on a line, shown under that line. */ | |
| 54 | + | function LineComment({ comment }: { comment: Comment }) { | |
| 55 | + | return ( | |
| 56 | + | <div className="rounded-lg border border-line bg-bg font-sans"> | |
| 57 | + | <p className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs text-muted"> | |
| 58 | + | <Avatar name={comment.author.username} size={16} /> | |
| 59 | + | <span className="font-medium text-fg">{comment.author.username}</span> | |
| 60 | + | <TimeAgo at={comment.createdAt} /> | |
| 61 | + | </p> | |
| 62 | + | <div className="px-3 py-2 text-sm"> | |
| 63 | + | <Markdown source={comment.body} /> | |
| 64 | + | </div> | |
| 65 | + | </div> | |
| 66 | + | ); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | function File({ file, review }: { file: FileDiff; review?: LineReview }) { | |
| 48 | 70 | const Icon = | |
| 49 | 71 | file.status === "added" | |
| 50 | 72 | ? FilePlus | |
| 51 | 73 | : file.status === "deleted" | |
| 52 | 74 | ? FileMinus | |
| 53 | 75 | : FileIcon; | |
| 76 | + | const comments = review?.comments.filter((comment) => comment.path === file.path) ?? []; | |
| 77 | + | const shown = new Set( | |
| 78 | + | file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new)), | |
| 79 | + | ); | |
| 80 | + | // Made against an earlier version of the change, on a line no longer in it. | |
| 81 | + | const outdated = comments.filter( | |
| 82 | + | (comment) => comment.line == null || !shown.has(comment.line), | |
| 83 | + | ); | |
| 54 | 84 | return ( | |
| 55 | 85 | <section | |
| 56 | 86 | id={`file-${file.path}`} | |
| 72 | 102 | </span> | |
| 73 | 103 | <Stat additions={file.additions} deletions={file.deletions} /> | |
| 74 | 104 | </header> | |
| 105 | + | {outdated.length > 0 && ( | |
| 106 | + | <div className="space-y-2 border-b border-line bg-surface/50 p-3"> | |
| 107 | + | <p className="text-xs text-faint">On lines that have since changed</p> | |
| 108 | + | {outdated.map((comment) => ( | |
| 109 | + | <LineComment key={comment.id} comment={comment} /> | |
| 110 | + | ))} | |
| 111 | + | </div> | |
| 112 | + | )} | |
| 75 | 113 | {file.binary ? ( | |
| 76 | 114 | <p className="px-4 py-6 text-sm text-muted"> | |
| 77 | 115 | Binary or large file; its contents are not shown. | |
| 83 | 121 | <table className="w-full border-collapse font-mono text-xs leading-5"> | |
| 84 | 122 | <tbody> | |
| 85 | 123 | {file.hunks.map((hunk, index) => ( | |
| 86 | − | <HunkRows key={index} lines={hunk.lines} first={index === 0} /> | |
| 124 | + | <HunkRows | |
| 125 | + | key={index} | |
| 126 | + | path={file.path} | |
| 127 | + | lines={hunk.lines} | |
| 128 | + | first={index === 0} | |
| 129 | + | comments={comments} | |
| 130 | + | canComment={review?.canComment ?? false} | |
| 131 | + | /> | |
| 87 | 132 | ))} | |
| 88 | 133 | </tbody> | |
| 89 | 134 | </table> | |
| 93 | 138 | ); | |
| 94 | 139 | } | |
| 95 | 140 | ||
| 96 | − | function HunkRows({ lines, first }: { lines: DiffLine[]; first: boolean }) { | |
| 141 | + | function HunkRows({ | |
| 142 | + | path, | |
| 143 | + | lines, | |
| 144 | + | first, | |
| 145 | + | comments, | |
| 146 | + | canComment, | |
| 147 | + | }: { | |
| 148 | + | path: string; | |
| 149 | + | lines: DiffLine[]; | |
| 150 | + | first: boolean; | |
| 151 | + | comments: Comment[]; | |
| 152 | + | canComment: boolean; | |
| 153 | + | }) { | |
| 154 | + | // The line whose comment box is open, if any. | |
| 155 | + | const [writing, setWriting] = useState<number | null>(null); | |
| 97 | 156 | return ( | |
| 98 | 157 | <> | |
| 99 | 158 | {!first && ( | |
| 106 | 165 | </td> | |
| 107 | 166 | </tr> | |
| 108 | 167 | )} | |
| 109 | − | {lines.map((line, index) => ( | |
| 110 | − | <tr key={index} className={ROW_STYLES[line.kind]}> | |
| 111 | − | <td className="w-10 px-2 text-right text-faint select-none"> | |
| 112 | − | {line.old} | |
| 113 | − | </td> | |
| 114 | − | <td className="w-10 px-2 text-right text-faint select-none"> | |
| 115 | − | {line.new} | |
| 116 | − | </td> | |
| 117 | − | <td | |
| 118 | − | className={`w-5 text-center select-none ${ | |
| 119 | − | line.kind === "add" | |
| 120 | − | ? "text-accent" | |
| 121 | − | : line.kind === "delete" | |
| 122 | − | ? "text-danger" | |
| 123 | − | : "text-faint" | |
| 124 | − | }`} | |
| 125 | − | > | |
| 126 | − | {MARKERS[line.kind]} | |
| 127 | − | </td> | |
| 128 | − | <td className="pr-4 whitespace-pre">{line.text}</td> | |
| 129 | − | </tr> | |
| 130 | − | ))} | |
| 168 | + | {lines.map((line, index) => { | |
| 169 | + | const here = comments.filter( | |
| 170 | + | (comment) => line.new != null && comment.line === line.new, | |
| 171 | + | ); | |
| 172 | + | const open = line.new != null && writing === line.new; | |
| 173 | + | return ( | |
| 174 | + | <Rows key={index}> | |
| 175 | + | <tr className={`group ${ROW_STYLES[line.kind]}`}> | |
| 176 | + | <td className="w-10 px-2 text-right text-faint select-none">{line.old}</td> | |
| 177 | + | <td className="relative w-10 px-2 text-right text-faint select-none"> | |
| 178 | + | {line.new} | |
| 179 | + | {/* Lines that exist after the change can be commented on. */} | |
| 180 | + | {canComment && line.new != null && ( | |
| 181 | + | <button | |
| 182 | + | type="button" | |
| 183 | + | aria-label={`Comment on line ${line.new}`} | |
| 184 | + | onClick={() => setWriting(open ? null : line.new)} | |
| 185 | + | className="absolute top-0 -right-2.5 z-10 hidden size-5 items-center justify-center rounded bg-accent text-bg group-hover:flex focus-visible:flex" | |
| 186 | + | > | |
| 187 | + | <MessageSquarePlus size={12} /> | |
| 188 | + | </button> | |
| 189 | + | )} | |
| 190 | + | </td> | |
| 191 | + | <td | |
| 192 | + | className={`w-5 text-center select-none ${ | |
| 193 | + | line.kind === "add" | |
| 194 | + | ? "text-accent" | |
| 195 | + | : line.kind === "delete" | |
| 196 | + | ? "text-danger" | |
| 197 | + | : "text-faint" | |
| 198 | + | }`} | |
| 199 | + | > | |
| 200 | + | {MARKERS[line.kind]} | |
| 201 | + | </td> | |
| 202 | + | <td className="pr-4 whitespace-pre">{line.text}</td> | |
| 203 | + | </tr> | |
| 204 | + | {(here.length > 0 || open) && ( | |
| 205 | + | <tr> | |
| 206 | + | <td colSpan={4} className="border-y border-line bg-surface p-3"> | |
| 207 | + | <div className="max-w-2xl space-y-2"> | |
| 208 | + | {here.map((comment) => ( | |
| 209 | + | <LineComment key={comment.id} comment={comment} /> | |
| 210 | + | ))} | |
| 211 | + | {open && ( | |
| 212 | + | <Form | |
| 213 | + | method="post" | |
| 214 | + | className="space-y-2 font-sans" | |
| 215 | + | onSubmit={() => setWriting(null)} | |
| 216 | + | > | |
| 217 | + | <input type="hidden" name="action" value="comment" /> | |
| 218 | + | <input type="hidden" name="path" value={path} /> | |
| 219 | + | <input type="hidden" name="line" value={line.new ?? ""} /> | |
| 220 | + | <Textarea | |
| 221 | + | name="body" | |
| 222 | + | rows={3} | |
| 223 | + | required | |
| 224 | + | autoFocus | |
| 225 | + | placeholder={`Comment on line ${line.new}`} | |
| 226 | + | /> | |
| 227 | + | <div className="flex gap-2"> | |
| 228 | + | <Button type="submit">Comment</Button> | |
| 229 | + | <Button variant="quiet" type="button" onClick={() => setWriting(null)}> | |
| 230 | + | Cancel | |
| 231 | + | </Button> | |
| 232 | + | </div> | |
| 233 | + | </Form> | |
| 234 | + | )} | |
| 235 | + | </div> | |
| 236 | + | </td> | |
| 237 | + | </tr> | |
| 238 | + | )} | |
| 239 | + | </Rows> | |
| 240 | + | ); | |
| 241 | + | })} | |
| 131 | 242 | </> | |
| 132 | 243 | ); | |
| 133 | 244 | } | |
| 134 | 245 | ||
| 135 | − | export function DiffView({ comparison }: { comparison: Comparison }) { | |
| 246 | + | /** Groups a line's rows without adding an element to the table. */ | |
| 247 | + | function Rows({ children }: { children: React.ReactNode }) { | |
| 248 | + | return <>{children}</>; | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | export function DiffView({ | |
| 252 | + | comparison, | |
| 253 | + | review, | |
| 254 | + | }: { | |
| 255 | + | comparison: Comparison; | |
| 256 | + | review?: LineReview; | |
| 257 | + | }) { | |
| 136 | 258 | const { files, truncated } = comparison; | |
| 137 | 259 | if (files.length === 0) { | |
| 138 | 260 | return ( | |
| 139 | 261 | <EmptyState title="No changes yet"> | |
| 140 | − | Nothing has been pushed to this pull request's fork, or it matches the | |
| 141 | − | repository it came from. | |
| 262 | + | Nothing has been pushed to this pull request yet, or it matches the | |
| 263 | + | branch it would merge into. | |
| 142 | 264 | </EmptyState> | |
| 143 | 265 | ); | |
| 144 | 266 | } | |
| 152 | 274 | {files.length === 1 ? "file" : "files"} changed | |
| 153 | 275 | </span> | |
| 154 | 276 | <Stat additions={additions} deletions={deletions} /> | |
| 277 | + | {review?.canComment && ( | |
| 278 | + | <span className="ml-auto text-xs text-faint"> | |
| 279 | + | Hover a line and press the button beside its number to comment on it. | |
| 280 | + | </span> | |
| 281 | + | )} | |
| 155 | 282 | </div> | |
| 156 | 283 | {files.length > 1 && ( | |
| 157 | 284 | <ul className="rounded-xl border border-line bg-surface p-2 text-sm"> | |
| 171 | 298 | </ul> | |
| 172 | 299 | )} | |
| 173 | 300 | {files.map((file) => ( | |
| 174 | − | <File key={file.path} file={file} /> | |
| 301 | + | <File key={file.path} file={file} review={review} /> | |
| 175 | 302 | ))} | |
| 176 | 303 | {truncated && ( | |
| 177 | 304 | <p className="text-center text-sm text-muted"> |
| 155 | 155 | ); | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | − | /** Comments in order, then the box to add one. */ | |
| 158 | + | const VERDICTS = { | |
| 159 | + | approve: { label: "approved these changes", style: "text-accent" }, | |
| 160 | + | request_changes: { label: "requested changes", style: "text-danger" }, | |
| 161 | + | } as const; | |
| 162 | + | ||
| 163 | + | /** | |
| 164 | + | * Comments in order, then the box to add one. On a pull request, `review` | |
| 165 | + | * says where its changes are shown and whether the viewer may give a | |
| 166 | + | * verdict. | |
| 167 | + | */ | |
| 159 | 168 | export function Comments({ | |
| 160 | 169 | comments, | |
| 161 | 170 | canComment, | |
| 171 | + | review, | |
| 162 | 172 | }: { | |
| 163 | 173 | comments: Comment[]; | |
| 164 | 174 | canComment: boolean; | |
| 175 | + | review?: { changesUrl: string; canJudge: boolean }; | |
| 165 | 176 | }) { | |
| 166 | 177 | return ( | |
| 167 | 178 | <div className="space-y-4"> | |
| 168 | − | {comments.map((comment) => ( | |
| 169 | − | <article key={comment.id} className="rounded-xl border border-line bg-surface"> | |
| 170 | − | <header className="flex items-center gap-2 border-b border-line px-4 py-2 text-sm text-muted"> | |
| 171 | − | <Avatar name={comment.author.username} size={18} /> | |
| 172 | − | <span className="font-medium text-fg">{comment.author.username}</span> | |
| 173 | − | <span> | |
| 174 | − | commented <TimeAgo at={comment.createdAt} /> | |
| 175 | − | </span> | |
| 176 | − | </header> | |
| 177 | − | <div className="px-4 py-3"> | |
| 178 | − | <Markdown source={comment.body} /> | |
| 179 | − | </div> | |
| 180 | − | </article> | |
| 181 | − | ))} | |
| 179 | + | {comments.map((comment) => { | |
| 180 | + | const verdict = comment.verdict && VERDICTS[comment.verdict]; | |
| 181 | + | return ( | |
| 182 | + | <article key={comment.id} className="rounded-xl border border-line bg-surface"> | |
| 183 | + | <header className="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2 text-sm text-muted"> | |
| 184 | + | <Avatar name={comment.author.username} size={18} /> | |
| 185 | + | <span className="font-medium text-fg">{comment.author.username}</span> | |
| 186 | + | {verdict ? ( | |
| 187 | + | <span className={`flex items-center gap-1 font-medium ${verdict.style}`}> | |
| 188 | + | {comment.verdict === "approve" ? ( | |
| 189 | + | <CircleCheck size={14} /> | |
| 190 | + | ) : ( | |
| 191 | + | <CircleSlash size={14} /> | |
| 192 | + | )} | |
| 193 | + | {verdict.label} | |
| 194 | + | </span> | |
| 195 | + | ) : ( | |
| 196 | + | <span>commented</span> | |
| 197 | + | )} | |
| 198 | + | <TimeAgo at={comment.createdAt} /> | |
| 199 | + | {comment.path && review && ( | |
| 200 | + | <Link | |
| 201 | + | to={`${review.changesUrl}#file-${comment.path}`} | |
| 202 | + | className="ml-auto truncate font-mono text-xs text-faint hover:text-fg" | |
| 203 | + | > | |
| 204 | + | {comment.path} | |
| 205 | + | {comment.line != null && `:${comment.line}`} | |
| 206 | + | </Link> | |
| 207 | + | )} | |
| 208 | + | </header> | |
| 209 | + | {comment.body && ( | |
| 210 | + | <div className="px-4 py-3"> | |
| 211 | + | <Markdown source={comment.body} /> | |
| 212 | + | </div> | |
| 213 | + | )} | |
| 214 | + | </article> | |
| 215 | + | ); | |
| 216 | + | })} | |
| 182 | 217 | {canComment ? ( | |
| 183 | 218 | <Form method="post" className="space-y-2" key={comments.length}> | |
| 184 | 219 | <input type="hidden" name="action" value="comment" /> | |
| 185 | − | <Textarea name="body" rows={3} required placeholder="Leave a comment. Markdown works." /> | |
| 186 | − | <Button type="submit">Comment</Button> | |
| 220 | + | <Textarea | |
| 221 | + | name="body" | |
| 222 | + | rows={3} | |
| 223 | + | placeholder={ | |
| 224 | + | review?.canJudge | |
| 225 | + | ? "Leave a comment, or a review. Markdown works." | |
| 226 | + | : "Leave a comment. Markdown works." | |
| 227 | + | } | |
| 228 | + | /> | |
| 229 | + | <div className="flex flex-wrap gap-2"> | |
| 230 | + | <Button type="submit">Comment</Button> | |
| 231 | + | {review?.canJudge && ( | |
| 232 | + | <> | |
| 233 | + | <Button variant="quiet" type="submit" name="verdict" value="approve"> | |
| 234 | + | <CircleCheck size={14} className="text-accent" /> | |
| 235 | + | Approve | |
| 236 | + | </Button> | |
| 237 | + | <Button variant="quiet" type="submit" name="verdict" value="request_changes"> | |
| 238 | + | <CircleSlash size={14} className="text-danger" /> | |
| 239 | + | Request changes | |
| 240 | + | </Button> | |
| 241 | + | </> | |
| 242 | + | )} | |
| 243 | + | </div> | |
| 187 | 244 | </Form> | |
| 188 | 245 | ) : ( | |
| 189 | 246 | <p className="text-sm text-muted"> | |
| 196 | 253 | </div> | |
| 197 | 254 | ); | |
| 198 | 255 | } | |
| 256 | + | ||
| 257 | + | /** Where each reviewer stands: their most recent verdict. */ | |
| 258 | + | export function verdicts(comments: Comment[]): { reviewer: string; verdict: NonNullable<Comment["verdict"]> }[] { | |
| 259 | + | const latest = new Map<string, NonNullable<Comment["verdict"]>>(); | |
| 260 | + | for (const comment of comments) { | |
| 261 | + | if (comment.verdict) latest.set(comment.author.username, comment.verdict); | |
| 262 | + | } | |
| 263 | + | return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict })); | |
| 264 | + | } |
| 19 | 19 | Scripts, | |
| 20 | 20 | ScrollRestoration, | |
| 21 | 21 | useRouteLoaderData, | |
| 22 | + | useSubmit, | |
| 22 | 23 | } from "react-router"; | |
| 23 | 24 | ||
| 24 | 25 | import type { User } from "@g1t/contracts"; | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | 76 | function Header({ user }: { user: User | null | undefined }) { | |
| 77 | + | const submit = useSubmit(); | |
| 76 | 78 | return ( | |
| 77 | 79 | <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur"> | |
| 78 | 80 | <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4"> | |
| 162 | 164 | </Link> | |
| 163 | 165 | </DropdownMenuItem> | |
| 164 | 166 | <DropdownMenuSeparator /> | |
| 165 | − | <DropdownMenuItem asChild> | |
| 166 | − | <button type="submit" form="sign-out" className="w-full"> | |
| 167 | − | <LogOut /> | |
| 168 | − | Sign out | |
| 169 | − | </button> | |
| 167 | + | {/* Submitted from here: the menu closes on select, and a button | |
| 168 | + | that has left the page cannot submit a form. */} | |
| 169 | + | <DropdownMenuItem | |
| 170 | + | onSelect={() => submit(null, { method: "post", action: "/logout" })} | |
| 171 | + | > | |
| 172 | + | <LogOut /> | |
| 173 | + | Sign out | |
| 170 | 174 | </DropdownMenuItem> | |
| 171 | 175 | </DropdownMenuContent> | |
| 172 | 176 | </DropdownMenu> | |
| 173 | − | <Form method="post" action="/logout" id="sign-out" hidden /> | |
| 174 | 177 | </> | |
| 175 | 178 | ) : ( | |
| 176 | 179 | <> |
| 17 | 17 | Textarea, | |
| 18 | 18 | TimeAgo, | |
| 19 | 19 | } from "../../components/ui"; | |
| 20 | + | import { CheckBadge } from "../../components/checks"; | |
| 20 | 21 | import { Comments, IssueState, Label, PullIcon } from "../../components/work"; | |
| 21 | 22 | import { work } from "../../lib/services.server"; | |
| 22 | 23 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 83 | 84 | throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`); | |
| 84 | 85 | } | |
| 85 | 86 | case "comment": { | |
| 86 | − | const result = await work.addComment(user, path, number, String(form.get("body") ?? "")); | |
| 87 | + | const result = await work.addComment(user, path, number, { | |
| 88 | + | body: String(form.get("body") ?? ""), | |
| 89 | + | }); | |
| 87 | 90 | return result.ok ? null : { error: result.error.message }; | |
| 88 | 91 | } | |
| 89 | 92 | case "labels": { | |
| 141 | 144 | <span className="size-1.5 animate-pulse rounded-full bg-accent" /> | |
| 142 | 145 | )} | |
| 143 | 146 | <span className="ml-auto flex items-center gap-3 text-xs text-faint"> | |
| 147 | + | <CheckBadge status={pull.checkStatus} /> | |
| 144 | 148 | <span className="flex items-center gap-1 font-mono"> | |
| 145 | 149 | <GitCommitHorizontal size={13} /> | |
| 146 | 150 | {pull.headCommit?.slice(0, 7) ?? "no commits"} | |
| 170 | 174 | // Follow agents at work without a manual reload. | |
| 171 | 175 | const revalidator = useRevalidator(); | |
| 172 | 176 | const navigation = useNavigation(); | |
| 173 | − | const running = pulls.some((pull) => pull.status === "draft"); | |
| 177 | + | const running = pulls.some( | |
| 178 | + | (pull) => | |
| 179 | + | pull.status === "draft" || | |
| 180 | + | pull.checkStatus === "queued" || | |
| 181 | + | pull.checkStatus === "running", | |
| 182 | + | ); | |
| 174 | 183 | useEffect(() => { | |
| 175 | 184 | if (!running) return; | |
| 176 | 185 | const timer = setInterval(() => { |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 1 | 2 | import { | |
| 2 | 3 | Bot, | |
| 3 | 4 | ChevronRight, | |
| 4 | 5 | FileDiff, | |
| 5 | 6 | GitBranch, | |
| 6 | 7 | GitCommitHorizontal, | |
| 8 | + | CircleCheck, | |
| 9 | + | CircleSlash, | |
| 7 | 10 | GitMerge, | |
| 8 | 11 | MessageSquare, | |
| 9 | 12 | MessagesSquare, | |
| 28 | 31 | Textarea, | |
| 29 | 32 | TimeAgo, | |
| 30 | 33 | } from "../../components/ui"; | |
| 31 | − | import { Comments, IssueIcon, PullState } from "../../components/work"; | |
| 34 | + | import { ChecksPanel } from "../../components/checks"; | |
| 35 | + | import { Comments, IssueIcon, PullState, verdicts } from "../../components/work"; | |
| 32 | 36 | import { repos, work } from "../../lib/services.server"; | |
| 33 | 37 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 34 | 38 | ||
| 93 | 97 | const path = { namespace: params.owner, name: params.repo }; | |
| 94 | 98 | const number = Number(params.number); | |
| 95 | 99 | const action = form.get("action"); | |
| 100 | + | const verdict = form.get("verdict"); | |
| 101 | + | const line = Number(form.get("line")); | |
| 96 | 102 | const result = | |
| 97 | 103 | action === "merge" | |
| 98 | − | ? await work.mergePull(user, path, number, form.get("keepIssueOpen") === "on") | |
| 104 | + | ? await work.mergePull(user, path, number, { | |
| 105 | + | keepIssueOpen: form.get("keepIssueOpen") === "on", | |
| 106 | + | ignoreChecks: form.get("ignoreChecks") === "on", | |
| 107 | + | }) | |
| 99 | 108 | : action === "close" | |
| 100 | 109 | ? await work.closePull(user, path, number) | |
| 101 | − | : action === "comment" | |
| 102 | − | ? await work.addComment(user, path, number, String(form.get("body") ?? "")) | |
| 103 | − | : await work.readyPull(user, path, number, String(form.get("summary") ?? "")); | |
| 110 | + | : action === "recheck" | |
| 111 | + | ? await env.RUNNER.recheck(user, path, number) | |
| 112 | + | : action === "comment" | |
| 113 | + | ? await work.addComment(user, path, number, { | |
| 114 | + | body: String(form.get("body") ?? ""), | |
| 115 | + | path: String(form.get("path") ?? "") || undefined, | |
| 116 | + | line: line > 0 ? line : undefined, | |
| 117 | + | verdict: | |
| 118 | + | verdict === "approve" || verdict === "request_changes" ? verdict : undefined, | |
| 119 | + | }) | |
| 120 | + | : await work.readyPull(user, path, number, String(form.get("summary") ?? "")); | |
| 104 | 121 | return result.ok ? null : { error: result.error.message, action }; | |
| 105 | 122 | } | |
| 106 | 123 | ||
| 209 | 226 | pull, | |
| 210 | 227 | issue, | |
| 211 | 228 | comments, | |
| 229 | + | checks, | |
| 212 | 230 | tab, | |
| 213 | 231 | session, | |
| 214 | 232 | comparison, | |
| 224 | 242 | : `https://g1t.sh/${params.owner}/${params.repo}.git`; | |
| 225 | 243 | const active = pull.status === "draft" || pull.status === "open"; | |
| 226 | 244 | ||
| 227 | − | // Follow an agent at work without a manual reload. | |
| 245 | + | // Follow an agent at work, or checks in progress, without a manual reload. | |
| 228 | 246 | const revalidator = useRevalidator(); | |
| 229 | 247 | const working = pull.status === "draft"; | |
| 248 | + | const checking = checks?.status === "queued" || checks?.status === "running"; | |
| 249 | + | const reviews = verdicts(comments); | |
| 250 | + | // What stands between this pull request and a merge, if anything. | |
| 251 | + | const unchecked = checks && checks.status !== "passed"; | |
| 230 | 252 | useEffect(() => { | |
| 231 | − | if (!working) return; | |
| 253 | + | if (!working && !checking) return; | |
| 232 | 254 | const timer = setInterval(() => { | |
| 233 | 255 | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 234 | 256 | }, REFRESH_MS); | |
| 235 | 257 | return () => clearInterval(timer); | |
| 236 | − | }, [working, revalidator]); | |
| 258 | + | }, [working, checking, revalidator]); | |
| 237 | 259 | ||
| 238 | 260 | return ( | |
| 239 | 261 | <div className="grid gap-8 lg:grid-cols-[1fr_19rem]"> | |
| 267 | 289 | )} | |
| 268 | 290 | </div> | |
| 269 | 291 | ||
| 292 | + | {reviews.length > 0 && ( | |
| 293 | + | <p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"> | |
| 294 | + | {reviews.map(({ reviewer, verdict }) => ( | |
| 295 | + | <span | |
| 296 | + | key={reviewer} | |
| 297 | + | className={`flex items-center gap-1.5 ${ | |
| 298 | + | verdict === "approve" ? "text-accent" : "text-danger" | |
| 299 | + | }`} | |
| 300 | + | > | |
| 301 | + | {verdict === "approve" ? <CircleCheck size={15} /> : <CircleSlash size={15} />} | |
| 302 | + | {verdict === "approve" ? "Approved by" : "Changes requested by"}{" "} | |
| 303 | + | <span className="font-medium">{reviewer}</span> | |
| 304 | + | </span> | |
| 305 | + | ))} | |
| 306 | + | </p> | |
| 307 | + | )} | |
| 308 | + | ||
| 270 | 309 | {issue && ( | |
| 271 | 310 | <Link | |
| 272 | 311 | to={`${base}/issues/${issue.number}`} | |
| 330 | 369 | </nav> | |
| 331 | 370 | <div className="mt-5"> | |
| 332 | 371 | {comparison ? ( | |
| 333 | − | <DiffView comparison={comparison} /> | |
| 372 | + | <DiffView | |
| 373 | + | comparison={comparison} | |
| 374 | + | review={{ | |
| 375 | + | comments: comments.filter((comment) => comment.path), | |
| 376 | + | canComment: Boolean(viewer), | |
| 377 | + | }} | |
| 378 | + | /> | |
| 334 | 379 | ) : tab === "session" ? ( | |
| 335 | 380 | session.length === 0 ? ( | |
| 336 | 381 | <EmptyState title="Nothing recorded yet"> | |
| 357 | 402 | : "No description."} | |
| 358 | 403 | </p> | |
| 359 | 404 | )} | |
| 360 | − | <Comments comments={comments} canComment={Boolean(viewer)} /> | |
| 361 | − | {actionData?.action === "comment" && <ErrorText>{actionData.error}</ErrorText>} | |
| 405 | + | <Comments | |
| 406 | + | comments={comments} | |
| 407 | + | canComment={Boolean(viewer)} | |
| 408 | + | review={{ | |
| 409 | + | changesUrl: here + "?tab=changes", | |
| 410 | + | // Nobody reviews their own pull request. | |
| 411 | + | canJudge: active && viewer != null && viewer.id !== pull.author.id, | |
| 412 | + | }} | |
| 413 | + | /> | |
| 362 | 414 | </div> | |
| 363 | 415 | )} | |
| 416 | + | {actionData?.action === "comment" && ( | |
| 417 | + | <div className="mt-2"> | |
| 418 | + | <ErrorText>{actionData.error}</ErrorText> | |
| 419 | + | </div> | |
| 420 | + | )} | |
| 364 | 421 | </div> | |
| 365 | 422 | </div> | |
| 366 | 423 | ||
| 367 | 424 | <aside className="space-y-6"> | |
| 425 | + | <ChecksPanel | |
| 426 | + | run={checks} | |
| 427 | + | commands={issue?.checks ?? []} | |
| 428 | + | canRerun={canManage && pull.status === "open"} | |
| 429 | + | /> | |
| 430 | + | {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>} | |
| 431 | + | ||
| 368 | 432 | {canMerge && pull.status === "open" && ( | |
| 369 | 433 | <section className="rounded-xl border border-accent/30 bg-accent/5 p-4"> | |
| 370 | 434 | <h3 className="text-sm font-medium">Merge this pull request</h3> | |
| 382 | 446 | </span> | |
| 383 | 447 | </label> | |
| 384 | 448 | )} | |
| 449 | + | {unchecked && ( | |
| 450 | + | <label className="flex items-start gap-2 text-xs text-muted"> | |
| 451 | + | <input type="checkbox" name="ignoreChecks" className="mt-0.5 accent-accent" /> | |
| 452 | + | <span> | |
| 453 | + | Merge although the checks{" "} | |
| 454 | + | {checking ? "have not finished" : "did not pass"}. | |
| 455 | + | </span> | |
| 456 | + | </label> | |
| 457 | + | )} | |
| 385 | 458 | <div className="*:w-full"> | |
| 386 | 459 | <Button variant="accent" type="submit" name="action" value="merge"> | |
| 387 | 460 | <GitMerge size={15} /> | |
| 414 | 487 | </Button> | |
| 415 | 488 | </div> | |
| 416 | 489 | </Form> | |
| 417 | − | {actionData && actionData.action !== "merge" && actionData.action !== "comment" && ( | |
| 490 | + | {actionData && | |
| 491 | + | !["merge", "comment", "recheck"].includes(String(actionData.action)) && ( | |
| 418 | 492 | <ErrorText>{actionData.error}</ErrorText> | |
| 419 | 493 | )} | |
| 420 | 494 | </section> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/pulls"; | |
| 5 | 5 | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 6 | + | import { CheckBadge } from "../../components/checks"; | |
| 6 | 7 | import { PullIcon, StateTabs } from "../../components/work"; | |
| 7 | 8 | import { work } from "../../lib/services.server"; | |
| 8 | 9 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 72 | 73 | {pull.supersededBy != null && <> · superseded by #{pull.supersededBy}</>} | |
| 73 | 74 | </span> | |
| 74 | 75 | </span> | |
| 76 | + | <span className="mt-0.5"> | |
| 77 | + | <CheckBadge status={pull.checkStatus} /> | |
| 78 | + | </span> | |
| 75 | 79 | <span className="mt-0.5 flex shrink-0 items-center gap-1 font-mono text-xs text-muted"> | |
| 76 | 80 | {pull.branch ? <GitBranch size={13} /> : <Bot size={13} />} | |
| 77 | 81 | {pull.branch ?? pull.agent} |
| 5 | 5 | "scripts": { | |
| 6 | 6 | "build": "react-router build", | |
| 7 | 7 | "dev": "react-router dev", | |
| 8 | − | "typecheck": "wrangler types --include-env=false && react-router typegen && tsc -b", | |
| 8 | + | "typecheck": "wrangler types --include-env=false && react-router typegen && tsc -b --force", | |
| 9 | 9 | "deploy": "npm run build && wrangler deploy", | |
| 10 | 10 | "preview": "npm run build && vite preview", | |
| 11 | 11 | "cf-typegen": "wrangler types --include-env=false", |
| 112 | 112 | - **Mark it ready:** `POST {repo}/pulls/{number}/ready` with `summary`, which | |
| 113 | 113 | becomes the pull request's description. | |
| 114 | 114 | - **See what a pull request changes:** `GET {repo}/pulls/{number}/changes`. | |
| 115 | + | - **Checks:** once a pull request is ready, g1t runs the issue's `checks` | |
| 116 | + | against it in a clean sandbox. `GET {repo}/pulls/{number}` returns | |
| 117 | + | `checks.results`, each with `passed` and `output`. If they failed, push a | |
| 118 | + | fix and they run again. | |
| 115 | 119 | - **Comment** on an issue or a pull request: | |
| 116 | − | `POST {repo}/issues/{number}/comments` with `body`. | |
| 120 | + | `POST {repo}/issues/{number}/comments` with `body`. On a pull request, add | |
| 121 | + | `path` and `line` to comment on one line of the change. | |
| 122 | + | - **Review** someone else's pull request: | |
| 123 | + | `POST {repo}/pulls/{number}/reviews` with `verdict` (`approve` or | |
| 124 | + | `request_changes`) and `body`. | |
| 117 | 125 | - **Merge** (members of the repository's workspace): | |
| 118 | 126 | `POST {repo}/pulls/{number}/merge`. This closes the issue it was for and | |
| 119 | 127 | closes the other pull requests for that issue as superseded; send | |
| 126 | 134 | `list_labels`, `add_comment`, `list_pull_requests`, `get_pull_request`, | |
| 127 | 135 | `create_pull_request`, `record_session`, `read_session`, | |
| 128 | 136 | `mark_pull_request_ready`, `close_pull_request`, | |
| 129 | − | `get_pull_request_changes`, `merge_pull_request`, and `list_repos`, | |
| 137 | + | `get_pull_request_changes`, `review_pull_request`, `merge_pull_request`, and `list_repos`, | |
| 130 | 138 | `get_repo`, `create_repo`, `list_events`, `create_workspace`, `whoami`. | |
| 131 | 139 | MCP tools take the repository as `repo`, written `owner/name`. | |
| 132 | 140 | ||
| 147 | 155 | - OAuth 2.1 for applications: metadata at | |
| 148 | 156 | `https://api.g1t.sh/.well-known/oauth-authorization-server`; authorization | |
| 149 | 157 | code with PKCE (S256), public clients, dynamic registration. | |
| 150 | − | - Not available yet: merge commits made on the server, running checks | |
| 151 | − | automatically. | |
| 158 | + | - A pull request whose checks have not passed is refused a merge with | |
| 159 | + | `409`; a workspace member can send `{"ignore_checks": true}`. | |
| 160 | + | - Not available yet: merge commits made on the server. | |
| 152 | 161 | ||
| 153 | 162 | ## More | |
| 154 | 163 |
| 66 | 66 | pub resolved_by: Option<u32>, | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | − | /// The payload of `pull.opened`, `pull.ready`, `pull.closed` and | |
| 70 | − | /// `pull.merged`; each uses the fields that apply to it. | |
| 69 | + | /// The payload of `pull.opened`, `pull.ready`, `pull.updated` (its head | |
| 70 | + | /// moved), `pull.closed` and `pull.merged`; each uses the fields that apply to it. | |
| 71 | 71 | #[derive(Debug, Default, Serialize)] | |
| 72 | 72 | #[serde(rename_all = "camelCase")] | |
| 73 | 73 | pub struct PullEvent { | |
| 87 | 87 | pub superseded_by: Option<u32>, | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | /// `checks.completed`: a run of an issue's acceptance checks against a pull | |
| 91 | + | /// request finished. | |
| 92 | + | #[derive(Debug, Serialize)] | |
| 93 | + | #[serde(rename_all = "camelCase")] | |
| 94 | + | pub struct ChecksEvent { | |
| 95 | + | pub pull_id: String, | |
| 96 | + | pub repo_id: String, | |
| 97 | + | pub number: u32, | |
| 98 | + | /// `passed`, `failed` or `errored`. | |
| 99 | + | pub status: &'static str, | |
| 100 | + | /// The commit that was checked. | |
| 101 | + | pub commit: String, | |
| 102 | + | } | |
| 103 | + | ||
| 90 | 104 | /// `comment.created`. `number` is the issue or pull request commented on. | |
| 91 | 105 | #[derive(Debug, Serialize)] | |
| 92 | 106 | #[serde(rename_all = "camelCase")] |
| 148 | 148 | /// Set on a pull request closed because another one for the same issue | |
| 149 | 149 | /// was merged: that one's number. | |
| 150 | 150 | pub superseded_by: Option<u32>, | |
| 151 | + | /// Where the latest run of the issue's acceptance checks stands, if | |
| 152 | + | /// there has been one against the current head. | |
| 153 | + | pub check_status: Option<CheckStatus>, | |
| 151 | 154 | pub author: User, | |
| 152 | 155 | /// RFC 3339. | |
| 153 | 156 | pub created_at: String, | |
| 155 | 158 | pub updated_at: String, | |
| 156 | 159 | } | |
| 157 | 160 | ||
| 161 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 162 | + | #[serde(rename_all = "lowercase")] | |
| 163 | + | pub enum CheckStatus { | |
| 164 | + | /// Waiting for a sandbox. | |
| 165 | + | Queued, | |
| 166 | + | Running, | |
| 167 | + | Passed, | |
| 168 | + | Failed, | |
| 169 | + | /// The checks could not be run at all. | |
| 170 | + | Errored, | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | impl CheckStatus { | |
| 174 | + | pub fn as_str(self) -> &'static str { | |
| 175 | + | match self { | |
| 176 | + | CheckStatus::Queued => "queued", | |
| 177 | + | CheckStatus::Running => "running", | |
| 178 | + | CheckStatus::Passed => "passed", | |
| 179 | + | CheckStatus::Failed => "failed", | |
| 180 | + | CheckStatus::Errored => "errored", | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// How one acceptance check went. | |
| 186 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 187 | + | #[serde(rename_all = "camelCase")] | |
| 188 | + | pub struct CheckResult { | |
| 189 | + | pub command: String, | |
| 190 | + | pub passed: bool, | |
| 191 | + | /// Absent when the command was stopped for taking too long. | |
| 192 | + | #[serde(default)] | |
| 193 | + | pub exit_code: Option<i32>, | |
| 194 | + | /// What the command printed, standard output and error together. The | |
| 195 | + | /// end of it, when there was a lot. | |
| 196 | + | #[serde(default)] | |
| 197 | + | pub output: String, | |
| 198 | + | #[serde(default)] | |
| 199 | + | pub duration_ms: u64, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// One run of an issue's acceptance checks against a pull request's head, | |
| 203 | + | /// in a sandbox that holds nothing but that commit. | |
| 204 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 205 | + | #[serde(rename_all = "camelCase")] | |
| 206 | + | pub struct CheckRun { | |
| 207 | + | pub id: String, | |
| 208 | + | /// The commit that was checked. | |
| 209 | + | pub head_commit: String, | |
| 210 | + | pub status: CheckStatus, | |
| 211 | + | pub results: Vec<CheckResult>, | |
| 212 | + | /// Why the checks could not be run, when `status` is `errored`. | |
| 213 | + | pub error: Option<String>, | |
| 214 | + | /// RFC 3339. | |
| 215 | + | pub created_at: String, | |
| 216 | + | /// RFC 3339. | |
| 217 | + | pub finished_at: Option<String>, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// A reviewer's decision on a pull request. | |
| 221 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 222 | + | #[serde(rename_all = "snake_case")] | |
| 223 | + | pub enum Verdict { | |
| 224 | + | Approve, | |
| 225 | + | RequestChanges, | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | impl Verdict { | |
| 229 | + | pub fn as_str(self) -> &'static str { | |
| 230 | + | match self { | |
| 231 | + | Verdict::Approve => "approve", | |
| 232 | + | Verdict::RequestChanges => "request_changes", | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// A comment on an issue or a pull request. On a pull request it can sit | |
| 238 | + | /// on one line of the change, and it can carry a reviewer's verdict. | |
| 158 | 239 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 159 | 240 | #[serde(rename_all = "camelCase")] | |
| 160 | 241 | pub struct Comment { | |
| 162 | 243 | pub author: User, | |
| 163 | 244 | /// Markdown. | |
| 164 | 245 | pub body: String, | |
| 246 | + | /// The file commented on, for a comment on a line. | |
| 247 | + | pub path: Option<String>, | |
| 248 | + | /// The line of that file, as numbered after the change. | |
| 249 | + | pub line: Option<u32>, | |
| 250 | + | pub verdict: Option<Verdict>, | |
| 165 | 251 | /// RFC 3339. | |
| 166 | 252 | pub created_at: String, | |
| 167 | 253 | } | |
| 214 | 300 | /// The issue it is for, if any. | |
| 215 | 301 | pub issue: Option<Issue>, | |
| 216 | 302 | pub comments: Vec<Comment>, | |
| 303 | + | /// The latest run of the issue's acceptance checks. | |
| 304 | + | pub checks: Option<CheckRun>, | |
| 217 | 305 | } | |
| 218 | 306 | ||
| 219 | 307 | /// `open_issue`. Returns `Outcome<Issue>`. | |
| 300 | 388 | pub reason: Option<IssueReason>, | |
| 301 | 389 | } | |
| 302 | 390 | ||
| 303 | − | /// `add_comment`, on an issue or a pull request. Returns `Outcome<Comment>`. | |
| 391 | + | /// `add_comment`, on an issue or a pull request. On a pull request it may | |
| 392 | + | /// name a line of the change, and may carry a verdict; nobody can give a | |
| 393 | + | /// verdict on their own pull request. Returns `Outcome<Comment>`. | |
| 304 | 394 | #[derive(Debug, Serialize, Deserialize)] | |
| 305 | 395 | pub struct AddCommentArgs { | |
| 306 | 396 | pub actor: User, | |
| 307 | 397 | pub repo: RepoPath, | |
| 308 | 398 | pub number: u32, | |
| 399 | + | /// May be empty when approving. | |
| 400 | + | #[serde(default)] | |
| 309 | 401 | pub body: String, | |
| 402 | + | #[serde(default)] | |
| 403 | + | pub path: Option<String>, | |
| 404 | + | #[serde(default)] | |
| 405 | + | pub line: Option<u32>, | |
| 406 | + | #[serde(default)] | |
| 407 | + | pub verdict: Option<Verdict>, | |
| 310 | 408 | } | |
| 311 | 409 | ||
| 312 | 410 | /// `open_pull`. Without `branch`, forks the repo and returns a draft pull | |
| 347 | 445 | /// for it untouched, because this one is only part of the work. | |
| 348 | 446 | #[serde(default)] | |
| 349 | 447 | pub keep_issue_open: bool, | |
| 448 | + | /// For `merge_pull`: merge although the acceptance checks have not | |
| 449 | + | /// passed. | |
| 450 | + | #[serde(default)] | |
| 451 | + | pub ignore_checks: bool, | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | /// `start_checks`: begins a run of the acceptance checks for a pull request | |
| 455 | + | /// that is ready for review. Called by the runner service, which starts the | |
| 456 | + | /// sandbox. Returns `Outcome<CheckJob>`. | |
| 457 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 458 | + | #[serde(rename_all = "camelCase")] | |
| 459 | + | pub struct StartChecksArgs { | |
| 460 | + | pub pull_id: String, | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | /// What a sandbox needs to carry out a check run. | |
| 464 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 465 | + | #[serde(rename_all = "camelCase")] | |
| 466 | + | pub struct CheckJob { | |
| 467 | + | pub run_id: String, | |
| 468 | + | /// Lets the sandbox, and nothing else, report this run's results. | |
| 469 | + | pub token: String, | |
| 470 | + | pub commands: Vec<String>, | |
| 471 | + | /// The repository holding the commit: the fork, or the repository itself. | |
| 472 | + | pub source: RepoPath, | |
| 473 | + | pub commit: String, | |
| 474 | + | /// Who opened the pull request, and so can read its source. | |
| 475 | + | pub author: User, | |
| 476 | + | /// Username of whoever wrote the checks: the issue's author. | |
| 477 | + | pub requested_by: String, | |
| 478 | + | pub repo: RepoPath, | |
| 479 | + | pub number: u32, | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | /// `report_checks`: what a sandbox says about its run. With no results and | |
| 483 | + | /// no error it has started. `skip` forgets the run, for one that will not | |
| 484 | + | /// be carried out. Returns `Outcome<CheckRun>`. | |
| 485 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 486 | + | #[serde(rename_all = "camelCase")] | |
| 487 | + | pub struct ReportChecksArgs { | |
| 488 | + | pub run_id: String, | |
| 489 | + | pub token: String, | |
| 490 | + | #[serde(default)] | |
| 491 | + | pub results: Vec<CheckResult>, | |
| 492 | + | #[serde(default)] | |
| 493 | + | pub error: Option<String>, | |
| 494 | + | #[serde(default)] | |
| 495 | + | pub skip: bool, | |
| 350 | 496 | } | |
| 351 | 497 | ||
| 352 | 498 | /// A pull request in progress, with where it lives. |
| 1 | + | //! Runs an issue's acceptance checks against one commit and reports how | |
| 2 | + | //! each went. | |
| 3 | + | //! | |
| 4 | + | //! The sandbox holds nothing but that commit: no agent has run here, so a | |
| 5 | + | //! passing result says something about the code and not about what an | |
| 6 | + | //! agent left lying around. | |
| 7 | + | //! | |
| 8 | + | //! Configuration comes from the environment: | |
| 9 | + | //! | |
| 10 | + | //! - `G1T_API`, `CHECK_RUN`, `CHECK_TOKEN`: where and how to report. | |
| 11 | + | //! - `GIT_REMOTE`, `GIT_COMMIT`: what to check out. | |
| 12 | + | //! - `G1T_USER`, `G1T_TOKEN`: to read the repository, if it is private. | |
| 13 | + | //! - `CHECKS`: the commands, as a JSON array. | |
| 14 | + | ||
| 15 | + | use std::path::Path; | |
| 16 | + | use std::process::{Command, Stdio}; | |
| 17 | + | use std::time::Instant; | |
| 18 | + | ||
| 19 | + | use anyhow::{Context, Result, bail}; | |
| 20 | + | use serde::Serialize; | |
| 21 | + | ||
| 22 | + | use crate::{WORKDIR, auth_option, env, git}; | |
| 23 | + | ||
| 24 | + | /// The longest one command may run. | |
| 25 | + | const COMMAND_TIMEOUT_SECONDS: u32 = 10 * 60; | |
| 26 | + | /// How much of a command's output is kept: the end, where failures are. | |
| 27 | + | const MAX_OUTPUT_CHARS: usize = 12_000; | |
| 28 | + | /// What `timeout` exits with when it had to stop the command. | |
| 29 | + | const TIMED_OUT: i32 = 124; | |
| 30 | + | const KILLED: i32 = 137; | |
| 31 | + | ||
| 32 | + | #[derive(Debug, Serialize)] | |
| 33 | + | #[serde(rename_all = "camelCase")] | |
| 34 | + | struct CheckResult { | |
| 35 | + | command: String, | |
| 36 | + | passed: bool, | |
| 37 | + | exit_code: Option<i32>, | |
| 38 | + | output: String, | |
| 39 | + | duration_ms: u64, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The last `limit` characters of `text`, saying so if any were dropped. | |
| 43 | + | fn tail(text: &str, limit: usize) -> String { | |
| 44 | + | let length = text.chars().count(); | |
| 45 | + | if length <= limit { | |
| 46 | + | return text.to_owned(); | |
| 47 | + | } | |
| 48 | + | let kept: String = text.chars().skip(length - limit).collect(); | |
| 49 | + | format!("… (earlier output not shown)\n{kept}") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | fn redact(text: &str, secrets: &[String]) -> String { | |
| 53 | + | secrets.iter().fold(text.to_owned(), |text, secret| { | |
| 54 | + | text.replace(secret, "[redacted]") | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Runs one command in the checkout, without this process's credentials. | |
| 59 | + | fn run_command(command: &str, workdir: &Path, secrets: &[String]) -> CheckResult { | |
| 60 | + | let started = Instant::now(); | |
| 61 | + | let output = Command::new("timeout") | |
| 62 | + | .args([ | |
| 63 | + | "--signal=KILL", | |
| 64 | + | &COMMAND_TIMEOUT_SECONDS.to_string(), | |
| 65 | + | "sh", | |
| 66 | + | "-c", | |
| 67 | + | // One stream, in the order it was written. | |
| 68 | + | &format!("( {command}\n) 2>&1"), | |
| 69 | + | ]) | |
| 70 | + | .current_dir(workdir) | |
| 71 | + | .env_remove("G1T_TOKEN") | |
| 72 | + | .env_remove("CHECK_TOKEN") | |
| 73 | + | .stdin(Stdio::null()) | |
| 74 | + | .output(); | |
| 75 | + | let duration_ms = started.elapsed().as_millis() as u64; | |
| 76 | + | match output { | |
| 77 | + | Ok(output) => { | |
| 78 | + | let code = output.status.code(); | |
| 79 | + | let timed_out = matches!(code, Some(TIMED_OUT | KILLED) | None); | |
| 80 | + | let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); | |
| 81 | + | if timed_out { | |
| 82 | + | text.push_str(&format!( | |
| 83 | + | "\nStopped after {} minutes.", | |
| 84 | + | COMMAND_TIMEOUT_SECONDS / 60 | |
| 85 | + | )); | |
| 86 | + | } | |
| 87 | + | CheckResult { | |
| 88 | + | command: command.to_owned(), | |
| 89 | + | passed: output.status.success(), | |
| 90 | + | exit_code: code.filter(|_| !timed_out), | |
| 91 | + | output: redact(&tail(text.trim_end(), MAX_OUTPUT_CHARS), secrets), | |
| 92 | + | duration_ms, | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | Err(error) => CheckResult { | |
| 96 | + | command: command.to_owned(), | |
| 97 | + | passed: false, | |
| 98 | + | exit_code: None, | |
| 99 | + | output: format!("Could not start the command: {error}"), | |
| 100 | + | duration_ms, | |
| 101 | + | }, | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | struct Reporter { | |
| 106 | + | url: String, | |
| 107 | + | token: String, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | impl Reporter { | |
| 111 | + | fn send(&self, mut body: serde_json::Value) -> Result<()> { | |
| 112 | + | body["token"] = self.token.clone().into(); | |
| 113 | + | ureq::post(&self.url) | |
| 114 | + | .send_json(body) | |
| 115 | + | .context("could not report the check run")?; | |
| 116 | + | Ok(()) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | fn check_out(secrets: &[String]) -> Result<()> { | |
| 121 | + | let remote = env("GIT_REMOTE")?; | |
| 122 | + | let commit = env("GIT_COMMIT")?; | |
| 123 | + | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); | |
| 124 | + | std::fs::create_dir_all("/work")?; | |
| 125 | + | let cloned = git( | |
| 126 | + | Path::new("/work"), | |
| 127 | + | &["-c", &auth, "clone", "--quiet", &remote, WORKDIR], | |
| 128 | + | ) | |
| 129 | + | .and_then(|_| { | |
| 130 | + | git( | |
| 131 | + | Path::new(WORKDIR), | |
| 132 | + | &[ | |
| 133 | + | "-c", | |
| 134 | + | "advice.detachedHead=false", | |
| 135 | + | "checkout", | |
| 136 | + | "--quiet", | |
| 137 | + | &commit, | |
| 138 | + | ], | |
| 139 | + | ) | |
| 140 | + | }); | |
| 141 | + | if let Err(error) = cloned { | |
| 142 | + | bail!("{}", redact(&format!("{error:#}"), secrets)); | |
| 143 | + | } | |
| 144 | + | Ok(()) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | pub fn main() -> i32 { | |
| 148 | + | let reporter = match (env("G1T_API"), env("CHECK_RUN"), env("CHECK_TOKEN")) { | |
| 149 | + | (Ok(api), Ok(run), Ok(token)) => Reporter { | |
| 150 | + | url: format!("{api}/v1/checks/{run}"), | |
| 151 | + | token, | |
| 152 | + | }, | |
| 153 | + | _ => { | |
| 154 | + | eprintln!("g1t-runner: G1T_API, CHECK_RUN and CHECK_TOKEN must be set"); | |
| 155 | + | return 2; | |
| 156 | + | } | |
| 157 | + | }; | |
| 158 | + | let secrets: Vec<String> = ["G1T_TOKEN", "CHECK_TOKEN"] | |
| 159 | + | .iter() | |
| 160 | + | .filter_map(|name| std::env::var(name).ok()) | |
| 161 | + | .filter(|secret| !secret.is_empty()) | |
| 162 | + | .collect(); | |
| 163 | + | let commands: Vec<String> = std::env::var("CHECKS") | |
| 164 | + | .ok() | |
| 165 | + | .and_then(|json| serde_json::from_str(&json).ok()) | |
| 166 | + | .unwrap_or_default(); | |
| 167 | + | ||
| 168 | + | // Says the run has started. | |
| 169 | + | if let Err(error) = reporter.send(serde_json::json!({})) { | |
| 170 | + | eprintln!("g1t-runner: {error:#}"); | |
| 171 | + | return 1; | |
| 172 | + | } | |
| 173 | + | let report = match check_out(&secrets) { | |
| 174 | + | Err(error) => serde_json::json!({ | |
| 175 | + | "error": format!("The commit could not be checked out: {error:#}"), | |
| 176 | + | }), | |
| 177 | + | Ok(()) => { | |
| 178 | + | let results: Vec<CheckResult> = commands | |
| 179 | + | .iter() | |
| 180 | + | .map(|command| run_command(command, Path::new(WORKDIR), &secrets)) | |
| 181 | + | .collect(); | |
| 182 | + | serde_json::json!({ "results": results }) | |
| 183 | + | } | |
| 184 | + | }; | |
| 185 | + | match reporter.send(report) { | |
| 186 | + | Ok(()) => 0, | |
| 187 | + | Err(error) => { | |
| 188 | + | eprintln!("g1t-runner: {error:#}"); | |
| 189 | + | 1 | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[cfg(test)] | |
| 195 | + | mod tests { | |
| 196 | + | use super::*; | |
| 197 | + | ||
| 198 | + | #[test] | |
| 199 | + | fn long_output_keeps_its_end() { | |
| 200 | + | let text = format!("{}END", "x".repeat(50)); | |
| 201 | + | let kept = tail(&text, 10); | |
| 202 | + | assert!(kept.ends_with("xxxxxxxEND")); | |
| 203 | + | assert!(kept.starts_with("… (earlier output not shown)")); | |
| 204 | + | assert_eq!(tail("short", 10), "short"); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | #[test] | |
| 208 | + | fn secrets_do_not_reach_a_report() { | |
| 209 | + | let secrets = vec!["g1t_secret".to_owned()]; | |
| 210 | + | assert_eq!(redact("token=g1t_secret", &secrets), "token=[redacted]"); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[cfg(unix)] | |
| 214 | + | #[test] | |
| 215 | + | fn a_command_passes_or_fails_by_its_exit_code() { | |
| 216 | + | let here = std::env::temp_dir(); | |
| 217 | + | let passed = run_command("echo out; echo err >&2", &here, &[]); | |
| 218 | + | assert!(passed.passed); | |
| 219 | + | assert_eq!(passed.exit_code, Some(0)); | |
| 220 | + | assert_eq!(passed.output, "out\nerr"); | |
| 221 | + | let failed = run_command("echo nope; exit 3", &here, &[]); | |
| 222 | + | assert!(!failed.passed); | |
| 223 | + | assert_eq!(failed.exit_code, Some(3)); | |
| 224 | + | assert_eq!(failed.output, "nope"); | |
| 225 | + | } | |
| 226 | + | } |
| 6 | 6 | //! and marks the pull request ready for review. It talks to g1t only through the public API and git, exactly | |
| 7 | 7 | //! as an agent on someone's own machine would. | |
| 8 | 8 | //! | |
| 9 | + | //! With `MODE=checks` it runs acceptance checks instead; see `checks`. | |
| 10 | + | //! | |
| 9 | 11 | //! Configuration comes from the environment: | |
| 10 | 12 | //! | |
| 11 | 13 | //! - `G1T_API`, `G1T_TOKEN`, `G1T_USER`: where and who to report as. | |
| 14 | 16 | //! - `COMMIT_MESSAGE`: used if the agent leaves changes uncommitted. | |
| 15 | 17 | //! - `ANTHROPIC_API_KEY`: read by the harness itself. | |
| 16 | 18 | ||
| 19 | + | mod checks; | |
| 17 | 20 | mod harness; | |
| 18 | 21 | mod report; | |
| 19 | 22 | ||
| 26 | 29 | ||
| 27 | 30 | use report::{Entry, Reporter}; | |
| 28 | 31 | ||
| 29 | − | const WORKDIR: &str = "/work/repo"; | |
| 32 | + | pub(crate) const WORKDIR: &str = "/work/repo"; | |
| 30 | 33 | ||
| 31 | − | fn env(name: &str) -> Result<String> { | |
| 34 | + | pub(crate) fn env(name: &str) -> Result<String> { | |
| 32 | 35 | std::env::var(name).with_context(|| format!("{name} is not set")) | |
| 33 | 36 | } | |
| 34 | 37 | ||
| 35 | 38 | /// Runs git and returns its trimmed output, failing on a non-zero exit. | |
| 36 | − | fn git(dir: &Path, args: &[&str]) -> Result<String> { | |
| 39 | + | pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String> { | |
| 37 | 40 | let output = Command::new("git") | |
| 38 | 41 | .current_dir(dir) | |
| 39 | 42 | .args(args) | |
| 52 | 55 | /// A git option that authenticates one command. The credential is passed | |
| 53 | 56 | /// per command and never written to the clone's config or its remote URL, | |
| 54 | 57 | /// where the agent would find it. | |
| 55 | − | fn auth_option(user: &str, token: &str) -> String { | |
| 58 | + | pub(crate) fn auth_option(user: &str, token: &str) -> String { | |
| 56 | 59 | let credentials = STANDARD.encode(format!("{user}:{token}")); | |
| 57 | 60 | format!("http.extraHeader=Authorization: Basic {credentials}") | |
| 58 | 61 | } | |
| 112 | 115 | } | |
| 113 | 116 | ||
| 114 | 117 | fn main() { | |
| 118 | + | // The same image also runs acceptance checks, with no agent involved. | |
| 119 | + | if std::env::var("MODE").as_deref() == Ok("checks") { | |
| 120 | + | std::process::exit(checks::main()); | |
| 121 | + | } | |
| 115 | 122 | let mut reporter = match Reporter::from_env() { | |
| 116 | 123 | Ok(reporter) => reporter, | |
| 117 | 124 | Err(error) => { |
| 646 | 646 | and device sign-in; an OAuth 2.1 server, so MCP clients sign in through the | |
| 647 | 647 | browser with no token to paste; workspaces with members; issues with labels, checks and | |
| 648 | 648 | comments; pull requests in forks or from branches, with diffs and sessions, | |
| 649 | − | several per issue; merging with a behind check, which resolves the issue and supersedes | |
| 649 | + | several per issue; acceptance checks run in clean sandboxes, gating the | |
| 650 | + | merge; line comments and review verdicts; merging with a behind check, which resolves the issue and supersedes | |
| 650 | 651 | the rest; g1t agents in sandboxes with a choice of model; REST API, OpenAPI | |
| 651 | 652 | and MCP server; event bus. Every service and the API are in Rust. | |
| 652 | 653 | ||
| 655 | 656 | 3. Event storage per the design above: per-repo hot log, Iceberg on R2, | |
| 656 | 657 | hash-chained audit. | |
| 657 | 658 | 4. CLI with Claude Code hooks to record sessions automatically. | |
| 658 | − | 5. Acceptance checks run in sandboxes; review comments on lines. | |
| 659 | + | 5. Reviewer agents assigned automatically; required reviews; risk tiers. | |
| 659 | 660 | 6. Server-side merge and rebase; landing queue with speculative checks; | |
| 660 | 661 | resolve-on-move. | |
| 661 | − | 7. Compare view, proof bundles, reviewers, risk tiers; work registry, | |
| 662 | − | handoff. | |
| 662 | + | 7. Compare view, proof bundles; work registry, handoff. | |
| 663 | 663 | 8. Projects, mission control, steering; why-blame, digest, timeline. | |
| 664 | 664 | 9. Context hub, portfolio; automations and integrations (Sentry first). | |
| 665 | 665 | 10. SSH; bot protection; own keys, endpoints and runners. |
| 111 | 111 | reopenIssue: (actor, repo, number) => call("reopen_issue", { actor, repo, number }), | |
| 112 | 112 | listLabels: (repo, viewer) => call("list_labels", { repo, viewer }), | |
| 113 | 113 | counts: (repo, viewer) => call("counts", { repo, viewer }), | |
| 114 | − | addComment: (actor, repo, number, body) => | |
| 115 | − | call("add_comment", { actor, repo, number, body }), | |
| 114 | + | addComment: (actor, repo, number, comment) => | |
| 115 | + | call("add_comment", { actor, repo, number, ...comment }), | |
| 116 | + | startChecks: (pullId) => call("start_checks", { pullId }), | |
| 117 | + | reportChecks: (runId, token, report) => | |
| 118 | + | call("report_checks", { runId, token, ...report }), | |
| 116 | 119 | openPull: (actor, repo, input) => call("open_pull", { actor, repo, ...input }), | |
| 117 | 120 | listPulls: (repo, viewer, state) => call("list_pulls", { repo, viewer, state }), | |
| 118 | 121 | getPull: (repo, number, viewer) => call("get_pull", { repo, number, viewer }), | |
| 119 | 122 | readyPull: (actor, repo, number, summary) => | |
| 120 | 123 | call("ready_pull", { actor, repo, number, summary }), | |
| 121 | 124 | closePull: (actor, repo, number) => call("close_pull", { actor, repo, number }), | |
| 122 | − | mergePull: (actor, repo, number, keepIssueOpen = false) => | |
| 123 | − | call("merge_pull", { actor, repo, number, keepIssueOpen }), | |
| 125 | + | mergePull: (actor, repo, number, options = {}) => | |
| 126 | + | call("merge_pull", { actor, repo, number, ...options }), | |
| 124 | 127 | listActivePulls: (viewer) => call("list_active_pulls", { viewer }), | |
| 125 | 128 | appendSession: (actor, repo, number, entries) => | |
| 126 | 129 | call("append_session", { actor, repo, number, entries }), |
| 28 | 28 | /** `issue` is the number of the issue the pull request is for. */ | |
| 29 | 29 | "pull.opened": { pullId: string; repoId: string; number: number; issue?: number; agent: string }; | |
| 30 | 30 | "pull.ready": { pullId: string; repoId: string; number: number; issue?: number }; | |
| 31 | + | /** A push moved the head of a pull request that is ready for review. */ | |
| 32 | + | "pull.updated": { pullId: string; repoId: string; number: number; issue?: number; commit: string }; | |
| 31 | 33 | "pull.closed": { pullId: string; repoId: string; number: number; issue?: number }; | |
| 32 | 34 | "pull.merged": { pullId: string; repoId: string; number: number; issue?: number; commit: string }; | |
| 35 | + | /** A run of the acceptance checks finished. `commit` is what was checked. */ | |
| 36 | + | "checks.completed": { | |
| 37 | + | pullId: string; | |
| 38 | + | repoId: string; | |
| 39 | + | number: number; | |
| 40 | + | status: "passed" | "failed" | "errored"; | |
| 41 | + | commit: string; | |
| 42 | + | }; | |
| 33 | 43 | /** `number` is the issue or pull request commented on. */ | |
| 34 | 44 | "comment.created": { commentId: string; repoId: string; number: number }; | |
| 35 | 45 | "session.appended": { pullId: string; repoId: string; number: number; count: number }; |
| 22 | 22 | model?: string; | |
| 23 | 23 | }; | |
| 24 | 24 | ||
| 25 | − | /** Hosted agents: sandboxes on g1t that work on an issue. */ | |
| 25 | + | /** Sandboxes on g1t: agents that work on an issue, and acceptance checks. */ | |
| 26 | 26 | export interface RunnerApi { | |
| 27 | 27 | /** The models this viewer may run g1t agents on; empty if they may not. */ | |
| 28 | 28 | models(viewer: Viewer): Promise<AgentModel[]>; | |
| 32 | 32 | * progress shows up in each pull request's session. | |
| 33 | 33 | */ | |
| 34 | 34 | run(actor: User, repo: RepoPath, issue: number, input: RunHostedInput): Promise<Result<Pull[]>>; | |
| 35 | + | /** | |
| 36 | + | * Runs the acceptance checks of a pull request again. Whoever opened it, | |
| 37 | + | * or a member of the repository's workspace, may ask. | |
| 38 | + | */ | |
| 39 | + | recheck(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>>; | |
| 35 | 40 | } |
| 92 | 92 | * merged: that one's number. | |
| 93 | 93 | */ | |
| 94 | 94 | supersededBy: number | null; | |
| 95 | + | /** | |
| 96 | + | * Where the latest run of the issue's acceptance checks stands, if there | |
| 97 | + | * has been one against the current head. | |
| 98 | + | */ | |
| 99 | + | checkStatus: CheckStatus | null; | |
| 95 | 100 | author: User; | |
| 96 | 101 | /** RFC 3339. */ | |
| 97 | 102 | createdAt: string; | |
| 99 | 104 | updatedAt: string; | |
| 100 | 105 | }; | |
| 101 | 106 | ||
| 107 | + | /** `queued` waits for a sandbox; `errored` means the checks could not be run. */ | |
| 108 | + | export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored"; | |
| 109 | + | ||
| 110 | + | /** How one acceptance check went. */ | |
| 111 | + | export type CheckResult = { | |
| 112 | + | command: string; | |
| 113 | + | passed: boolean; | |
| 114 | + | /** Null when the command was stopped for taking too long. */ | |
| 115 | + | exitCode: number | null; | |
| 116 | + | /** What the command printed; the end of it, when there was a lot. */ | |
| 117 | + | output: string; | |
| 118 | + | durationMs: number; | |
| 119 | + | }; | |
| 120 | + | ||
| 121 | + | /** | |
| 122 | + | * One run of an issue's acceptance checks against a pull request's head, in | |
| 123 | + | * a sandbox that holds nothing but that commit. | |
| 124 | + | */ | |
| 125 | + | export type CheckRun = { | |
| 126 | + | id: string; | |
| 127 | + | /** The commit that was checked. */ | |
| 128 | + | headCommit: string; | |
| 129 | + | status: CheckStatus; | |
| 130 | + | results: CheckResult[]; | |
| 131 | + | /** Why the checks could not be run, when `status` is `errored`. */ | |
| 132 | + | error: string | null; | |
| 133 | + | /** RFC 3339. */ | |
| 134 | + | createdAt: string; | |
| 135 | + | /** RFC 3339. */ | |
| 136 | + | finishedAt: string | null; | |
| 137 | + | }; | |
| 138 | + | ||
| 139 | + | /** A reviewer's decision on a pull request. */ | |
| 140 | + | export type Verdict = "approve" | "request_changes"; | |
| 141 | + | ||
| 142 | + | /** | |
| 143 | + | * A comment on an issue or a pull request. On a pull request it can sit on | |
| 144 | + | * one line of the change, and it can carry a reviewer's verdict. | |
| 145 | + | */ | |
| 102 | 146 | export type Comment = { | |
| 103 | 147 | id: string; | |
| 104 | 148 | author: User; | |
| 105 | 149 | /** Markdown. */ | |
| 106 | 150 | body: string; | |
| 151 | + | /** The file commented on, for a comment on a line. */ | |
| 152 | + | path: string | null; | |
| 153 | + | /** The line of that file, as numbered after the change. */ | |
| 154 | + | line: number | null; | |
| 155 | + | verdict: Verdict | null; | |
| 107 | 156 | /** RFC 3339. */ | |
| 108 | 157 | createdAt: string; | |
| 109 | 158 | }; | |
| 110 | 159 | ||
| 160 | + | export type NewComment = { | |
| 161 | + | /** May be empty when approving. */ | |
| 162 | + | body: string; | |
| 163 | + | path?: string; | |
| 164 | + | line?: number; | |
| 165 | + | verdict?: Verdict; | |
| 166 | + | }; | |
| 167 | + | ||
| 168 | + | /** What a sandbox needs to carry out a check run. */ | |
| 169 | + | export type CheckJob = { | |
| 170 | + | runId: string; | |
| 171 | + | /** Lets the sandbox, and nothing else, report this run's results. */ | |
| 172 | + | token: string; | |
| 173 | + | commands: string[]; | |
| 174 | + | /** The repository holding the commit: the fork, or the repository itself. */ | |
| 175 | + | source: RepoPath; | |
| 176 | + | commit: string; | |
| 177 | + | /** Who opened the pull request, and so can read its source. */ | |
| 178 | + | author: User; | |
| 179 | + | /** Username of whoever wrote the checks: the issue's author. */ | |
| 180 | + | requestedBy: string; | |
| 181 | + | repo: RepoPath; | |
| 182 | + | number: number; | |
| 183 | + | }; | |
| 184 | + | ||
| 185 | + | export type CheckReport = { results?: CheckResult[]; error?: string; skip?: boolean }; | |
| 186 | + | ||
| 111 | 187 | export type SessionEntryKind = "prompt" | "message" | "tool_call" | "tool_result" | "note"; | |
| 112 | 188 | ||
| 113 | 189 | /** One step of an agent's session: the "why" behind a pull request's commits. */ | |
| 138 | 214 | /** The issue it is for, if any. */ | |
| 139 | 215 | issue: Issue | null; | |
| 140 | 216 | comments: Comment[]; | |
| 217 | + | /** The latest run of the issue's acceptance checks. */ | |
| 218 | + | checks: CheckRun | null; | |
| 141 | 219 | }; | |
| 142 | 220 | ||
| 143 | 221 | export type OpenIssueInput = { | |
| 184 | 262 | /** How many issues and pull requests are open. */ | |
| 185 | 263 | counts(repo: RepoPath, viewer: Viewer): Promise<Result<{ issues: number; pulls: number }>>; | |
| 186 | 264 | ||
| 187 | − | /** On an issue or a pull request. */ | |
| 188 | − | addComment(actor: User, repo: RepoPath, number: number, body: string): Promise<Result<Comment>>; | |
| 265 | + | /** | |
| 266 | + | * On an issue or a pull request. On a pull request it may name a line of | |
| 267 | + | * the change and carry a verdict; nobody can give a verdict on their own. | |
| 268 | + | */ | |
| 269 | + | addComment(actor: User, repo: RepoPath, number: number, comment: NewComment): Promise<Result<Comment>>; | |
| 270 | + | ||
| 271 | + | /** | |
| 272 | + | * Begins a run of the acceptance checks for a pull request that is ready | |
| 273 | + | * for review. For the runner service, which starts the sandbox. | |
| 274 | + | */ | |
| 275 | + | startChecks(pullId: string): Promise<Result<CheckJob>>; | |
| 276 | + | /** | |
| 277 | + | * What a sandbox says about its run. With no results and no error it has | |
| 278 | + | * started; `skip` forgets a run that will not be carried out. | |
| 279 | + | */ | |
| 280 | + | reportChecks(runId: string, token: string, report: CheckReport): Promise<Result<CheckRun>>; | |
| 189 | 281 | ||
| 190 | 282 | /** | |
| 191 | 283 | * Opens a pull request: a draft with a fork to push to, or, given a | |
| 204 | 296 | * naming this pull request, and the others still in progress for it close | |
| 205 | 297 | * as superseded. Only members of the repository's workspace may merge. | |
| 206 | 298 | */ | |
| 207 | − | mergePull(actor: User, repo: RepoPath, number: number, keepIssueOpen?: boolean): Promise<Result<Pull>>; | |
| 299 | + | mergePull( | |
| 300 | + | actor: User, | |
| 301 | + | repo: RepoPath, | |
| 302 | + | number: number, | |
| 303 | + | options?: { | |
| 304 | + | keepIssueOpen?: boolean; | |
| 305 | + | /** Merge although the acceptance checks have not passed. */ | |
| 306 | + | ignoreChecks?: boolean; | |
| 307 | + | }, | |
| 308 | + | ): Promise<Result<Pull>>; | |
| 208 | 309 | /** Drafts and open pull requests the viewer started, most recently active first. */ | |
| 209 | 310 | listActivePulls(viewer: Viewer): Promise<{ pull: Pull; issue: Issue | null }[]>; | |
| 210 | 311 |
| 20 | 20 | // One queue per subscribing service, so each consumes, retries | |
| 21 | 21 | // and scales on its own. Bindings named SUBSCRIBER_* receive | |
| 22 | 22 | // every event. | |
| 23 | − | { "binding": "SUBSCRIBER_WORK", "queue": "g1t-events-work" } | |
| 23 | + | { "binding": "SUBSCRIBER_WORK", "queue": "g1t-events-work" }, | |
| 24 | + | { "binding": "SUBSCRIBER_RUNNER", "queue": "g1t-events-runner" } | |
| 24 | 25 | ], | |
| 25 | 26 | "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1 }] | |
| 26 | 27 | }, |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | 5 | type AgentModel, | |
| 6 | + | type CheckJob, | |
| 7 | + | type G1tEvent, | |
| 6 | 8 | type Issue, | |
| 7 | 9 | type Pull, | |
| 8 | 10 | type RepoPath, | |
| 55 | 57 | /** How g1t's own agent is labelled. What runs behind it is g1t's choice. */ | |
| 56 | 58 | const AGENT = "g1t-agent"; | |
| 57 | 59 | ||
| 58 | − | /** Which pull request a sandbox is working on, and as whom. */ | |
| 59 | − | type Run = { actor: User; repo: RepoPath; number: number }; | |
| 60 | + | /** | |
| 61 | + | * What a sandbox is doing: an agent working on a pull request as someone, | |
| 62 | + | * or a run of acceptance checks. | |
| 63 | + | */ | |
| 64 | + | type Run = | |
| 65 | + | | { kind: "agent"; actor: User; repo: RepoPath; number: number } | |
| 66 | + | | { kind: "checks"; runId: string; token: string }; | |
| 60 | 67 | type RunRequest = Run & { envVars: Record<string, string> }; | |
| 61 | 68 | ||
| 69 | + | /** Long enough to clone, install and test; then the token stops working. */ | |
| 70 | + | const CHECKS_TOKEN_TTL_SECONDS = 45 * 60; | |
| 71 | + | ||
| 62 | 72 | /** | |
| 63 | − | * One sandbox, for one pull request. The image's entrypoint is the g1t runner, | |
| 64 | − | * which does the work and exits; this class only starts it and cleans up | |
| 65 | − | * if it dies without reporting. | |
| 73 | + | * One sandbox, for one agent or one run of checks. The image's entrypoint | |
| 74 | + | * is the g1t runner, which does the work and exits; this class only starts | |
| 75 | + | * it and cleans up if it dies without reporting. | |
| 66 | 76 | */ | |
| 67 | 77 | export class AttemptSandbox extends Container<RunnerEnv> { | |
| 68 | 78 | sleepAfter = "45m"; | |
| 75 | 85 | ||
| 76 | 86 | override async onStop({ exitCode }: StopParams): Promise<void> { | |
| 77 | 87 | if (exitCode === 0) return; | |
| 88 | + | const run = await this.ctx.storage.get<Run>("run"); | |
| 89 | + | if (!run) return; | |
| 90 | + | const work = workClient(this.env.WORK); | |
| 91 | + | if (run.kind === "checks") { | |
| 92 | + | // Refused harmlessly if the run did report before it stopped. | |
| 93 | + | await work.reportChecks(run.runId, run.token, { | |
| 94 | + | error: "The sandbox stopped before the checks finished.", | |
| 95 | + | }); | |
| 96 | + | return; | |
| 97 | + | } | |
| 78 | 98 | // The runner closes its own pull request when it fails. This covers a | |
| 79 | 99 | // sandbox that was killed before it could; closing twice is refused | |
| 80 | 100 | // harmlessly. | |
| 81 | − | const run = await this.ctx.storage.get<Run>("run"); | |
| 82 | − | if (run) await workClient(this.env.WORK).closePull(run.actor, run.repo, run.number); | |
| 101 | + | await work.closePull(run.actor, run.repo, run.number); | |
| 83 | 102 | } | |
| 84 | 103 | } | |
| 85 | 104 | ||
| 114 | 133 | return JSON.parse(this.env.AGENT_MODELS); | |
| 115 | 134 | } | |
| 116 | 135 | ||
| 136 | + | /** Whether sandboxes may be started on this person's say-so. */ | |
| 137 | + | private enabledFor(username: string): boolean { | |
| 138 | + | return this.env.HOSTED_AGENT_USERS.split(",") | |
| 139 | + | .map((name) => name.trim()) | |
| 140 | + | .includes(username); | |
| 141 | + | } | |
| 142 | + | ||
| 117 | 143 | private allowed(viewer: Viewer): boolean { | |
| 118 | 144 | if (!viewer || !this.env.ANTHROPIC_API_KEY) return false; | |
| 119 | − | return this.env.HOSTED_AGENT_USERS.split(",") | |
| 120 | − | .map((name) => name.trim()) | |
| 121 | − | .includes(viewer.username); | |
| 145 | + | return this.enabledFor(viewer.username); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /** Events from the bus: a pull request was opened, became ready, or moved. */ | |
| 149 | + | async queue(batch: MessageBatch<G1tEvent>): Promise<void> { | |
| 150 | + | for (const message of batch.messages) { | |
| 151 | + | const event = message.body; | |
| 152 | + | // A pull request opened from a branch is ready from the start; one | |
| 153 | + | // opened as a draft is refused below until it is marked ready. | |
| 154 | + | if ( | |
| 155 | + | event.type === "pull.opened" || | |
| 156 | + | event.type === "pull.ready" || | |
| 157 | + | event.type === "pull.updated" | |
| 158 | + | ) { | |
| 159 | + | await this.startChecks(event.data.pullId); | |
| 160 | + | } | |
| 161 | + | message.ack(); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /** | |
| 166 | + | * Runs a pull request's acceptance checks in a sandbox of its own. Does | |
| 167 | + | * nothing when there is nothing to run. | |
| 168 | + | */ | |
| 169 | + | private async startChecks(pullId: string): Promise<boolean> { | |
| 170 | + | const work = workClient(this.env.WORK); | |
| 171 | + | const started = await work.startChecks(pullId); | |
| 172 | + | if (!started.ok) return false; | |
| 173 | + | const job: CheckJob = started.value; | |
| 174 | + | // Checks are commands one person wrote, run against code another | |
| 175 | + | // pushed, on g1t's machines. In the preview they run only when one of | |
| 176 | + | // the two is someone sandboxes are enabled for. | |
| 177 | + | if (!this.enabledFor(job.requestedBy) && !this.enabledFor(job.author.username)) { | |
| 178 | + | await work.reportChecks(job.runId, job.token, { skip: true }); | |
| 179 | + | return false; | |
| 180 | + | } | |
| 181 | + | // To read the commit, which may be private, as the one who pushed it. | |
| 182 | + | const { token } = await identityClient(this.env.IDENTITY).createAccessToken( | |
| 183 | + | job.author, | |
| 184 | + | `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`, | |
| 185 | + | CHECKS_TOKEN_TTL_SECONDS, | |
| 186 | + | ); | |
| 187 | + | const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId)); | |
| 188 | + | await sandbox.run({ | |
| 189 | + | kind: "checks", | |
| 190 | + | runId: job.runId, | |
| 191 | + | token: job.token, | |
| 192 | + | envVars: { | |
| 193 | + | MODE: "checks", | |
| 194 | + | G1T_API: "https://api.g1t.sh", | |
| 195 | + | CHECK_RUN: job.runId, | |
| 196 | + | CHECK_TOKEN: job.token, | |
| 197 | + | G1T_USER: job.author.username, | |
| 198 | + | G1T_TOKEN: token, | |
| 199 | + | GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`, | |
| 200 | + | GIT_COMMIT: job.commit, | |
| 201 | + | CHECKS: JSON.stringify(job.commands), | |
| 202 | + | }, | |
| 203 | + | }); | |
| 204 | + | return true; | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | async recheck(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> { | |
| 208 | + | const found = await workClient(this.env.WORK).getPull(repo, number, actor); | |
| 209 | + | if (!found.ok) return found; | |
| 210 | + | const { pull } = found.value; | |
| 211 | + | const member = (actor.workspaces ?? []).some( | |
| 212 | + | (membership) => membership.slug === repo.namespace, | |
| 213 | + | ); | |
| 214 | + | if (!member && pull.author.id !== actor.id) { | |
| 215 | + | return fail( | |
| 216 | + | "forbidden", | |
| 217 | + | "Only whoever opened a pull request, or a member of the workspace, can run its checks.", | |
| 218 | + | ); | |
| 219 | + | } | |
| 220 | + | return (await this.startChecks(pull.id)) | |
| 221 | + | ? ok(true) | |
| 222 | + | : fail("conflict", "There are no checks to run for this pull request right now."); | |
| 122 | 223 | } | |
| 123 | 224 | ||
| 124 | 225 | async models(viewer: Viewer): Promise<AgentModel[]> { | |
| 177 | 278 | ); | |
| 178 | 279 | const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id)); | |
| 179 | 280 | await sandbox.run({ | |
| 281 | + | kind: "agent", | |
| 180 | 282 | actor, | |
| 181 | 283 | repo, | |
| 182 | 284 | number: pull.number, |
| 24 | 24 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 25 | 25 | { "binding": "WORK", "service": "g1t-work" } | |
| 26 | 26 | ], | |
| 27 | + | // Events it reacts to: a pull request ready for review, or its head moving. | |
| 28 | + | "queues": { | |
| 29 | + | "consumers": [{ "queue": "g1t-events-runner", "max_batch_size": 20, "max_batch_timeout": 1 }] | |
| 30 | + | }, | |
| 27 | 31 | "vars": { | |
| 28 | 32 | "HOSTED_AGENT_USERS": "syntaqx", | |
| 29 | 33 | // What a person can choose when starting g1t agents. The first is the |
| 3 | 3 | version = "0.1.0" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | − | description = "Intents, attempts and sessions." | |
| 6 | + | description = "Issues, pull requests, reviews, checks and sessions." | |
| 7 | 7 | ||
| 8 | 8 | [lib] | |
| 9 | 9 | crate-type = ["cdylib"] | |
| 14 | 14 | serde.workspace = true | |
| 15 | 15 | serde_json.workspace = true | |
| 16 | 16 | worker.workspace = true | |
| 17 | + | getrandom = { version = "0.2", features = ["js"] } | |
| 18 | + | hex = "0.4" | |
| 19 | + | sha2 = "0.10" |
| 56 | 56 | merged_at TEXT, | |
| 57 | 57 | -- The pull request merged instead of this one. | |
| 58 | 58 | superseded_by INTEGER, | |
| 59 | + | -- The latest run of the issue's acceptance checks, and where it stands. | |
| 60 | + | check_run_id TEXT, | |
| 61 | + | check_status TEXT, | |
| 59 | 62 | author_id TEXT NOT NULL, | |
| 60 | 63 | author_name TEXT NOT NULL, | |
| 61 | 64 | created_at TEXT NOT NULL, | |
| 74 | 77 | author_id TEXT NOT NULL, | |
| 75 | 78 | author_name TEXT NOT NULL, | |
| 76 | 79 | body TEXT NOT NULL, | |
| 80 | + | -- For a comment on one line of a pull request's change: the file, and | |
| 81 | + | -- the line as numbered after the change. | |
| 82 | + | path TEXT, | |
| 83 | + | line INTEGER, | |
| 84 | + | -- A reviewer's decision: approve or request_changes. | |
| 85 | + | verdict TEXT, | |
| 77 | 86 | created_at TEXT NOT NULL | |
| 78 | 87 | ); | |
| 79 | 88 | CREATE INDEX comments_by_subject ON comments (repo_id, number, id); | |
| 89 | 98 | at TEXT NOT NULL, | |
| 90 | 99 | PRIMARY KEY (pull_id, seq) | |
| 91 | 100 | ); | |
| 101 | + | ||
| 102 | + | -- Runs of an issue's acceptance checks against a pull request's head. | |
| 103 | + | CREATE TABLE check_runs ( | |
| 104 | + | id TEXT PRIMARY KEY, | |
| 105 | + | pull_id TEXT NOT NULL REFERENCES pulls (id), | |
| 106 | + | head_commit TEXT NOT NULL, | |
| 107 | + | -- queued, running, passed, failed or errored. | |
| 108 | + | status TEXT NOT NULL DEFAULT 'queued', | |
| 109 | + | -- JSON array of { command, passed, exitCode, output, durationMs }. | |
| 110 | + | results TEXT NOT NULL DEFAULT '[]', | |
| 111 | + | error TEXT, | |
| 112 | + | -- SHA-256 of the token the sandbox reports with. | |
| 113 | + | token_hash TEXT NOT NULL, | |
| 114 | + | created_at TEXT NOT NULL, | |
| 115 | + | finished_at TEXT | |
| 116 | + | ); | |
| 117 | + | CREATE INDEX check_runs_by_pull ON check_runs (pull_id, id); |
| 1 | + | //! Check runs: an issue's acceptance checks, run against a pull request's | |
| 2 | + | //! head in a clean sandbox. | |
| 3 | + | //! | |
| 4 | + | //! This service keeps the record. The runner service starts the sandbox: | |
| 5 | + | //! it asks for a job with `start_checks`, and the sandbox reports back | |
| 6 | + | //! through the API with the job's one-time token. Nothing else can write a | |
| 7 | + | //! result, including the agent whose work is being checked. | |
| 8 | + | ||
| 9 | + | use g1t_contracts::events::ChecksEvent; | |
| 10 | + | use g1t_contracts::repos::{GetByIdArgs, HeadArgs, Repo, RepoPath}; | |
| 11 | + | use g1t_contracts::time::rfc3339; | |
| 12 | + | use g1t_contracts::work::*; | |
| 13 | + | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 14 | + | use g1t_kit::now_ms; | |
| 15 | + | use serde::Deserialize; | |
| 16 | + | use sha2::{Digest, Sha256}; | |
| 17 | + | use worker::Result; | |
| 18 | + | use worker::wasm_bindgen::JsValue; | |
| 19 | + | ||
| 20 | + | use crate::rows::PullRow; | |
| 21 | + | use crate::{Work, optional}; | |
| 22 | + | ||
| 23 | + | const MAX_OUTPUT_CHARS: usize = 16_000; | |
| 24 | + | const MAX_RESULTS: usize = 20; | |
| 25 | + | ||
| 26 | + | #[derive(Deserialize)] | |
| 27 | + | struct RunRow { | |
| 28 | + | id: String, | |
| 29 | + | pull_id: String, | |
| 30 | + | head_commit: String, | |
| 31 | + | status: CheckStatus, | |
| 32 | + | /// JSON array of results. | |
| 33 | + | results: String, | |
| 34 | + | error: Option<String>, | |
| 35 | + | token_hash: String, | |
| 36 | + | created_at: String, | |
| 37 | + | finished_at: Option<String>, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl From<RunRow> for CheckRun { | |
| 41 | + | fn from(row: RunRow) -> Self { | |
| 42 | + | CheckRun { | |
| 43 | + | id: row.id, | |
| 44 | + | head_commit: row.head_commit, | |
| 45 | + | status: row.status, | |
| 46 | + | results: serde_json::from_str(&row.results).unwrap_or_default(), | |
| 47 | + | error: row.error, | |
| 48 | + | created_at: row.created_at, | |
| 49 | + | finished_at: row.finished_at, | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn hash(token: &str) -> String { | |
| 55 | + | hex::encode(Sha256::digest(token.as_bytes())) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn new_token() -> String { | |
| 59 | + | let mut bytes = [0u8; 32]; | |
| 60 | + | getrandom::getrandom(&mut bytes).expect("no source of randomness"); | |
| 61 | + | hex::encode(bytes) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn refused<T>(message: &str) -> Outcome<T> { | |
| 65 | + | Outcome::fail(FailureCode::Conflict, message) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | impl Work { | |
| 69 | + | /// The most recent check run of a pull request. | |
| 70 | + | pub(crate) async fn latest_checks(&self, pull_id: &str) -> Result<Option<CheckRun>> { | |
| 71 | + | Ok(self | |
| 72 | + | .db | |
| 73 | + | .prepare("SELECT * FROM check_runs WHERE pull_id = ? ORDER BY id DESC LIMIT 1") | |
| 74 | + | .bind(&[pull_id.into()])? | |
| 75 | + | .first::<RunRow>(None) | |
| 76 | + | .await? | |
| 77 | + | .map(CheckRun::from)) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Begins a check run for a pull request that is ready for review, and | |
| 81 | + | /// returns what a sandbox needs to carry it out. Any run still in | |
| 82 | + | /// progress for the pull request is abandoned. | |
| 83 | + | pub(crate) async fn start_checks(&self, a: StartChecksArgs) -> Result<Outcome<CheckJob>> { | |
| 84 | + | let pull = self | |
| 85 | + | .db | |
| 86 | + | .prepare("SELECT * FROM pulls WHERE id = ?") | |
| 87 | + | .bind(&[a.pull_id.as_str().into()])? | |
| 88 | + | .first::<PullRow>(None) | |
| 89 | + | .await? | |
| 90 | + | .map(Pull::from); | |
| 91 | + | let Some(pull) = pull else { | |
| 92 | + | return Ok(Outcome::fail( | |
| 93 | + | FailureCode::NotFound, | |
| 94 | + | "Pull request not found.", | |
| 95 | + | )); | |
| 96 | + | }; | |
| 97 | + | if pull.status != PullStatus::Open { | |
| 98 | + | return Ok(refused( | |
| 99 | + | "Checks run once a pull request is ready for review.", | |
| 100 | + | )); | |
| 101 | + | } | |
| 102 | + | let issue = match pull.issue { | |
| 103 | + | Some(number) => self.issue(&pull.repo_id, number).await?, | |
| 104 | + | None => None, | |
| 105 | + | }; | |
| 106 | + | let Some(issue) = issue.filter(|issue| !issue.checks.is_empty()) else { | |
| 107 | + | return Ok(refused( | |
| 108 | + | "This pull request's issue has no acceptance checks.", | |
| 109 | + | )); | |
| 110 | + | }; | |
| 111 | + | ||
| 112 | + | // The author can read both the repository and the pull request's source. | |
| 113 | + | let viewer = Some(pull.author.clone()); | |
| 114 | + | let repo: Outcome<Repo> = g1t_kit::call( | |
| 115 | + | &self.repos, | |
| 116 | + | "get_by_id", | |
| 117 | + | &GetByIdArgs { | |
| 118 | + | id: pull.repo_id.clone(), | |
| 119 | + | viewer, | |
| 120 | + | }, | |
| 121 | + | ) | |
| 122 | + | .await?; | |
| 123 | + | let Outcome::Ok(repo) = repo else { | |
| 124 | + | return Ok(Outcome::fail( | |
| 125 | + | FailureCode::NotFound, | |
| 126 | + | "Pull request not found.", | |
| 127 | + | )); | |
| 128 | + | }; | |
| 129 | + | // Asked of the store, since the recorded head can lag a push. | |
| 130 | + | let head: Option<String> = g1t_kit::call( | |
| 131 | + | &self.repos, | |
| 132 | + | "head", | |
| 133 | + | &HeadArgs { | |
| 134 | + | repo_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()), | |
| 135 | + | branch: pull | |
| 136 | + | .branch | |
| 137 | + | .clone() | |
| 138 | + | .unwrap_or_else(|| repo.default_branch.clone()), | |
| 139 | + | }, | |
| 140 | + | ) | |
| 141 | + | .await?; | |
| 142 | + | let Some(commit) = head else { | |
| 143 | + | return Ok(refused("This pull request has no commits to check.")); | |
| 144 | + | }; | |
| 145 | + | ||
| 146 | + | let now = now_ms(); | |
| 147 | + | let timestamp = rfc3339(now); | |
| 148 | + | let run_id = new_id("chk", now); | |
| 149 | + | let token = new_token(); | |
| 150 | + | self.db | |
| 151 | + | .batch(vec![ | |
| 152 | + | self.db | |
| 153 | + | .prepare( | |
| 154 | + | "UPDATE check_runs | |
| 155 | + | SET status = 'errored', error = 'Replaced by a newer run.', finished_at = ? | |
| 156 | + | WHERE pull_id = ? AND finished_at IS NULL", | |
| 157 | + | ) | |
| 158 | + | .bind(&[timestamp.as_str().into(), pull.id.as_str().into()])?, | |
| 159 | + | self.db | |
| 160 | + | .prepare( | |
| 161 | + | "INSERT INTO check_runs (id, pull_id, head_commit, token_hash, created_at) | |
| 162 | + | VALUES (?, ?, ?, ?, ?)", | |
| 163 | + | ) | |
| 164 | + | .bind(&[ | |
| 165 | + | run_id.as_str().into(), | |
| 166 | + | pull.id.as_str().into(), | |
| 167 | + | commit.as_str().into(), | |
| 168 | + | hash(&token).into(), | |
| 169 | + | timestamp.as_str().into(), | |
| 170 | + | ])?, | |
| 171 | + | self.db | |
| 172 | + | .prepare( | |
| 173 | + | "UPDATE pulls SET check_status = 'queued', check_run_id = ?, head_commit = ? | |
| 174 | + | WHERE id = ?", | |
| 175 | + | ) | |
| 176 | + | .bind(&[ | |
| 177 | + | run_id.as_str().into(), | |
| 178 | + | commit.as_str().into(), | |
| 179 | + | pull.id.as_str().into(), | |
| 180 | + | ])?, | |
| 181 | + | ]) | |
| 182 | + | .await?; | |
| 183 | + | ||
| 184 | + | Ok(Outcome::Ok(CheckJob { | |
| 185 | + | run_id, | |
| 186 | + | token, | |
| 187 | + | commands: issue.checks, | |
| 188 | + | source: pull.fork.clone().unwrap_or(RepoPath { | |
| 189 | + | namespace: repo.namespace.clone(), | |
| 190 | + | name: repo.name.clone(), | |
| 191 | + | }), | |
| 192 | + | commit, | |
| 193 | + | author: pull.author, | |
| 194 | + | requested_by: issue.author.username, | |
| 195 | + | repo: RepoPath { | |
| 196 | + | namespace: repo.namespace, | |
| 197 | + | name: repo.name, | |
| 198 | + | }, | |
| 199 | + | number: pull.number, | |
| 200 | + | })) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// Records what a sandbox reports for its run: that it has started, its | |
| 204 | + | /// results, that it could not run, or that the run should be forgotten. | |
| 205 | + | /// The run's token is the only credential, and a finished run accepts | |
| 206 | + | /// nothing more. | |
| 207 | + | pub(crate) async fn report_checks(&self, a: ReportChecksArgs) -> Result<Outcome<CheckRun>> { | |
| 208 | + | let run = self | |
| 209 | + | .db | |
| 210 | + | .prepare("SELECT * FROM check_runs WHERE id = ?") | |
| 211 | + | .bind(&[a.run_id.as_str().into()])? | |
| 212 | + | .first::<RunRow>(None) | |
| 213 | + | .await?; | |
| 214 | + | let Some(run) = run.filter(|run| run.token_hash == hash(&a.token)) else { | |
| 215 | + | return Ok(Outcome::fail(FailureCode::NotFound, "Check run not found.")); | |
| 216 | + | }; | |
| 217 | + | if run.finished_at.is_some() { | |
| 218 | + | return Ok(refused("This check run has already finished.")); | |
| 219 | + | } | |
| 220 | + | let latest = "id = ? AND check_run_id = ?"; | |
| 221 | + | let pull_keys = | |
| 222 | + | || -> [JsValue; 2] { [run.pull_id.as_str().into(), run.id.as_str().into()] }; | |
| 223 | + | ||
| 224 | + | if a.skip { | |
| 225 | + | self.db | |
| 226 | + | .batch(vec![ | |
| 227 | + | self.db | |
| 228 | + | .prepare("DELETE FROM check_runs WHERE id = ?") | |
| 229 | + | .bind(&[run.id.as_str().into()])?, | |
| 230 | + | self.db | |
| 231 | + | .prepare(format!( | |
| 232 | + | "UPDATE pulls SET check_status = NULL, check_run_id = NULL WHERE {latest}" | |
| 233 | + | )) | |
| 234 | + | .bind(&pull_keys())?, | |
| 235 | + | ]) | |
| 236 | + | .await?; | |
| 237 | + | return Ok(Outcome::Ok(run.into())); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | let results: Vec<CheckResult> = a | |
| 241 | + | .results | |
| 242 | + | .into_iter() | |
| 243 | + | .take(MAX_RESULTS) | |
| 244 | + | .map(|mut result| { | |
| 245 | + | // Keep the end of long output: that is where failures are. | |
| 246 | + | let length = result.output.chars().count(); | |
| 247 | + | if length > MAX_OUTPUT_CHARS { | |
| 248 | + | result.output = result | |
| 249 | + | .output | |
| 250 | + | .chars() | |
| 251 | + | .skip(length - MAX_OUTPUT_CHARS) | |
| 252 | + | .collect(); | |
| 253 | + | } | |
| 254 | + | result | |
| 255 | + | }) | |
| 256 | + | .collect(); | |
| 257 | + | let status = if a.error.is_some() { | |
| 258 | + | CheckStatus::Errored | |
| 259 | + | } else if results.is_empty() { | |
| 260 | + | CheckStatus::Running | |
| 261 | + | } else if results.iter().all(|result| result.passed) { | |
| 262 | + | CheckStatus::Passed | |
| 263 | + | } else { | |
| 264 | + | CheckStatus::Failed | |
| 265 | + | }; | |
| 266 | + | let finished = (status != CheckStatus::Running).then(|| rfc3339(now_ms())); | |
| 267 | + | self.db | |
| 268 | + | .batch(vec![ | |
| 269 | + | self.db | |
| 270 | + | .prepare( | |
| 271 | + | "UPDATE check_runs SET status = ?, results = ?, error = ?, finished_at = ? | |
| 272 | + | WHERE id = ?", | |
| 273 | + | ) | |
| 274 | + | .bind(&[ | |
| 275 | + | status.as_str().into(), | |
| 276 | + | serde_json::to_string(&results)?.into(), | |
| 277 | + | optional(&a.error), | |
| 278 | + | optional(&finished), | |
| 279 | + | run.id.as_str().into(), | |
| 280 | + | ])?, | |
| 281 | + | self.db | |
| 282 | + | .prepare(format!("UPDATE pulls SET check_status = ? WHERE {latest}")) | |
| 283 | + | .bind(&[ | |
| 284 | + | status.as_str().into(), | |
| 285 | + | run.pull_id.as_str().into(), | |
| 286 | + | run.id.as_str().into(), | |
| 287 | + | ])?, | |
| 288 | + | ]) | |
| 289 | + | .await?; | |
| 290 | + | ||
| 291 | + | if finished.is_some() { | |
| 292 | + | let pull = self | |
| 293 | + | .db | |
| 294 | + | .prepare("SELECT * FROM pulls WHERE id = ?") | |
| 295 | + | .bind(&[run.pull_id.as_str().into()])? | |
| 296 | + | .first::<PullRow>(None) | |
| 297 | + | .await? | |
| 298 | + | .map(Pull::from); | |
| 299 | + | if let Some(pull) = pull { | |
| 300 | + | self.publish_as( | |
| 301 | + | "checks.completed", | |
| 302 | + | &pull.repo_id, | |
| 303 | + | None, | |
| 304 | + | ChecksEvent { | |
| 305 | + | pull_id: pull.id.clone(), | |
| 306 | + | repo_id: pull.repo_id.clone(), | |
| 307 | + | number: pull.number, | |
| 308 | + | status: status.as_str(), | |
| 309 | + | commit: run.head_commit.clone(), | |
| 310 | + | }, | |
| 311 | + | ) | |
| 312 | + | .await?; | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | Ok(Outcome::Ok(CheckRun { | |
| 316 | + | status, | |
| 317 | + | results, | |
| 318 | + | error: a.error, | |
| 319 | + | finished_at: finished, | |
| 320 | + | ..run.into() | |
| 321 | + | })) | |
| 322 | + | } | |
| 323 | + | } |
| 4 | 4 | //! `g1t_contracts::work` for the methods and their arguments. It also | |
| 5 | 5 | //! consumes its queue of events from the bus. | |
| 6 | 6 | ||
| 7 | + | mod checks; | |
| 7 | 8 | mod rows; | |
| 8 | 9 | ||
| 9 | 10 | use g1t_contracts::events::{ | |
| 20 | 21 | Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event, | |
| 21 | 22 | }; | |
| 22 | 23 | ||
| 23 | − | use rows::{CommentRow, IssueRow, NumberRow, PullRow, SessionRow, ValueRow}; | |
| 24 | + | use rows::{CommentRow, IssueRow, MovedRow, NumberRow, PullRow, SessionRow, ValueRow}; | |
| 24 | 25 | ||
| 25 | 26 | const SOURCE: &str = "work"; | |
| 26 | 27 | const MAX_ENTRY_BATCH: usize = 200; | |
| 95 | 96 | actor: &User, | |
| 96 | 97 | data: T, | |
| 97 | 98 | ) -> Result<()> { | |
| 99 | + | self.publish_as(kind, repo_id, Some(actor.id.clone()), data) | |
| 100 | + | .await | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Publishes an event caused by `actor`, or by g1t itself. | |
| 104 | + | async fn publish_as<T: Serialize>( | |
| 105 | + | &self, | |
| 106 | + | kind: &'static str, | |
| 107 | + | repo_id: &str, | |
| 108 | + | actor: Option<String>, | |
| 109 | + | data: T, | |
| 110 | + | ) -> Result<()> { | |
| 98 | 111 | let event = NewEvent { | |
| 99 | 112 | kind, | |
| 100 | 113 | source: SOURCE, | |
| 101 | 114 | repo_id: Some(repo_id.to_owned()), | |
| 102 | − | actor: Some(actor.id.clone()), | |
| 115 | + | actor, | |
| 103 | 116 | data, | |
| 104 | 117 | }; | |
| 105 | 118 | g1t_kit::call( | |
| 528 | 541 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 529 | 542 | } | |
| 530 | 543 | let body = a.body.trim(); | |
| 531 | − | if body.is_empty() { | |
| 544 | + | // An approval speaks for itself; anything else has to say something. | |
| 545 | + | if body.is_empty() && a.verdict != Some(Verdict::Approve) { | |
| 532 | 546 | return Ok(Outcome::fail( | |
| 533 | 547 | FailureCode::Invalid, | |
| 534 | 548 | "A comment cannot be empty.", | |
| 535 | 549 | )); | |
| 536 | 550 | } | |
| 551 | + | let path = a | |
| 552 | + | .path | |
| 553 | + | .as_deref() | |
| 554 | + | .map(str::trim) | |
| 555 | + | .filter(|path| !path.is_empty()); | |
| 556 | + | let line = a.line.filter(|line| *line > 0 && path.is_some()); | |
| 537 | 557 | if body.chars().count() > MAX_ENTRY_CHARS { | |
| 538 | 558 | return Ok(Outcome::fail( | |
| 539 | 559 | FailureCode::Invalid, | |
| 543 | 563 | let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?); | |
| 544 | 564 | // The number names an issue or a pull request, never both. | |
| 545 | 565 | let table = if self.issue(&repo.id, a.number).await?.is_some() { | |
| 566 | + | if path.is_some() || a.verdict.is_some() { | |
| 567 | + | return Ok(Outcome::fail( | |
| 568 | + | FailureCode::Invalid, | |
| 569 | + | "Only a pull request can be reviewed or commented on by line.", | |
| 570 | + | )); | |
| 571 | + | } | |
| 546 | 572 | "issues" | |
| 547 | − | } else if self.pull(&repo.id, a.number).await?.is_some() { | |
| 573 | + | } else if let Some(pull) = self.pull(&repo.id, a.number).await? { | |
| 574 | + | if a.verdict.is_some() && pull.author.id == a.actor.id { | |
| 575 | + | return Ok(Outcome::fail( | |
| 576 | + | FailureCode::Forbidden, | |
| 577 | + | "You cannot approve or request changes on your own pull request.", | |
| 578 | + | )); | |
| 579 | + | } | |
| 548 | 580 | "pulls" | |
| 549 | 581 | } else { | |
| 550 | 582 | return Ok(Outcome::fail( | |
| 558 | 590 | id: new_id("cmt", now), | |
| 559 | 591 | author: a.actor.clone(), | |
| 560 | 592 | body: body.to_owned(), | |
| 593 | + | path: path.map(str::to_owned), | |
| 594 | + | line, | |
| 595 | + | verdict: a.verdict, | |
| 561 | 596 | created_at: rfc3339(now), | |
| 562 | 597 | }; | |
| 563 | 598 | self.db | |
| 565 | 600 | self.db | |
| 566 | 601 | .prepare( | |
| 567 | 602 | "INSERT INTO comments | |
| 568 | − | (id, repo_id, number, author_id, author_name, body, created_at) | |
| 569 | − | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 603 | + | (id, repo_id, number, author_id, author_name, body, path, line, | |
| 604 | + | verdict, created_at) | |
| 605 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 570 | 606 | ) | |
| 571 | 607 | .bind(&[ | |
| 572 | 608 | comment.id.as_str().into(), | |
| 575 | 611 | a.actor.id.as_str().into(), | |
| 576 | 612 | a.actor.username.as_str().into(), | |
| 577 | 613 | body.into(), | |
| 614 | + | optional(&comment.path), | |
| 615 | + | optional_number(line), | |
| 616 | + | a.verdict | |
| 617 | + | .map_or(JsValue::NULL, |verdict| verdict.as_str().into()), | |
| 578 | 618 | comment.created_at.as_str().into(), | |
| 579 | 619 | ])?, | |
| 580 | 620 | self.db | |
| 783 | 823 | }; | |
| 784 | 824 | Ok(Outcome::Ok(PullDetail { | |
| 785 | 825 | comments: self.comments(&repo.id, pull.number).await?, | |
| 826 | + | checks: self.latest_checks(&pull.id).await?, | |
| 786 | 827 | issue, | |
| 787 | 828 | pull, | |
| 788 | 829 | })) | |
| 887 | 928 | )); | |
| 888 | 929 | } | |
| 889 | 930 | } | |
| 931 | + | if !a.ignore_checks { | |
| 932 | + | let waiting = match pull.check_status { | |
| 933 | + | Some(CheckStatus::Queued | CheckStatus::Running) => { | |
| 934 | + | Some("The acceptance checks are still running.") | |
| 935 | + | } | |
| 936 | + | Some(CheckStatus::Failed) => Some("The acceptance checks did not pass."), | |
| 937 | + | Some(CheckStatus::Errored) => Some("The acceptance checks could not be run."), | |
| 938 | + | Some(CheckStatus::Passed) | None => None, | |
| 939 | + | }; | |
| 940 | + | if let Some(reason) = waiting { | |
| 941 | + | return Ok(Outcome::fail( | |
| 942 | + | FailureCode::Conflict, | |
| 943 | + | format!("{reason} Wait or fix them, or merge anyway by ignoring the checks."), | |
| 944 | + | )); | |
| 945 | + | } | |
| 946 | + | } | |
| 890 | 947 | let issue = match pull.issue { | |
| 891 | 948 | Some(number) if !a.keep_issue_open => self | |
| 892 | 949 | .issue(&repo.id, number) | |
| 1117 | 1174 | return Ok(()); | |
| 1118 | 1175 | }; | |
| 1119 | 1176 | let now = rfc3339(now_ms()); | |
| 1120 | − | let active = "status IN ('draft', 'open')"; | |
| 1121 | − | let mut statements = Vec::new(); | |
| 1177 | + | // The head moved, so whatever the checks said no longer applies. | |
| 1178 | + | let moved = "UPDATE pulls | |
| 1179 | + | SET head_commit = ?, updated_at = ?, check_status = NULL, check_run_id = NULL"; | |
| 1180 | + | let active = "status IN ('draft', 'open') AND head_commit IS NOT ?"; | |
| 1181 | + | let returning = "RETURNING id, repo_id, number, issue_number, status"; | |
| 1182 | + | let mut pulls: Vec<MovedRow> = Vec::new(); | |
| 1122 | 1183 | // A fork carries its pull request on its default branch. | |
| 1123 | 1184 | if event.data["defaultBranch"].as_bool() == Some(true) { | |
| 1124 | − | statements.push( | |
| 1185 | + | pulls.extend( | |
| 1125 | 1186 | self.db | |
| 1126 | 1187 | .prepare(format!( | |
| 1127 | − | "UPDATE pulls SET head_commit = ?, updated_at = ? | |
| 1128 | − | WHERE fork_repo_id = ? AND {active}" | |
| 1188 | + | "{moved} WHERE fork_repo_id = ? AND {active} {returning}" | |
| 1129 | 1189 | )) | |
| 1130 | − | .bind(&[after.into(), now.as_str().into(), repo_id.into()])?, | |
| 1190 | + | .bind(&[ | |
| 1191 | + | after.into(), | |
| 1192 | + | now.as_str().into(), | |
| 1193 | + | repo_id.into(), | |
| 1194 | + | after.into(), | |
| 1195 | + | ])? | |
| 1196 | + | .all() | |
| 1197 | + | .await? | |
| 1198 | + | .results::<MovedRow>()?, | |
| 1131 | 1199 | ); | |
| 1132 | 1200 | } | |
| 1133 | 1201 | if let Some(branch) = git_ref.strip_prefix("refs/heads/") { | |
| 1134 | − | statements.push( | |
| 1202 | + | pulls.extend( | |
| 1135 | 1203 | self.db | |
| 1136 | 1204 | .prepare(format!( | |
| 1137 | − | "UPDATE pulls SET head_commit = ?, updated_at = ? | |
| 1138 | − | WHERE repo_id = ? AND source_branch = ? AND {active}" | |
| 1205 | + | "{moved} WHERE repo_id = ? AND source_branch = ? AND {active} {returning}" | |
| 1139 | 1206 | )) | |
| 1140 | 1207 | .bind(&[ | |
| 1141 | 1208 | after.into(), | |
| 1142 | 1209 | now.as_str().into(), | |
| 1143 | 1210 | repo_id.into(), | |
| 1144 | 1211 | branch.into(), | |
| 1145 | − | ])?, | |
| 1212 | + | after.into(), | |
| 1213 | + | ])? | |
| 1214 | + | .all() | |
| 1215 | + | .await? | |
| 1216 | + | .results::<MovedRow>()?, | |
| 1146 | 1217 | ); | |
| 1147 | 1218 | } | |
| 1148 | − | self.db.batch(statements).await?; | |
| 1219 | + | // A draft is announced when it is marked ready instead. | |
| 1220 | + | for pull in pulls | |
| 1221 | + | .into_iter() | |
| 1222 | + | .filter(|pull| pull.status == PullStatus::Open) | |
| 1223 | + | { | |
| 1224 | + | self.publish_as( | |
| 1225 | + | "pull.updated", | |
| 1226 | + | &pull.repo_id, | |
| 1227 | + | event.actor.clone(), | |
| 1228 | + | PullEvent { | |
| 1229 | + | pull_id: pull.id, | |
| 1230 | + | repo_id: pull.repo_id.clone(), | |
| 1231 | + | number: pull.number, | |
| 1232 | + | issue: pull.issue_number, | |
| 1233 | + | commit: Some(after.to_owned()), | |
| 1234 | + | ..PullEvent::default() | |
| 1235 | + | }, | |
| 1236 | + | ) | |
| 1237 | + | .await?; | |
| 1238 | + | } | |
| 1149 | 1239 | Ok(()) | |
| 1150 | 1240 | } | |
| 1151 | 1241 | } | |
| 1176 | 1266 | "list_labels" => reply(&work.list_labels(args(body)?).await?), | |
| 1177 | 1267 | "counts" => reply(&work.counts(args(body)?).await?), | |
| 1178 | 1268 | "add_comment" => reply(&work.add_comment(args(body)?).await?), | |
| 1269 | + | "start_checks" => reply(&work.start_checks(args(body)?).await?), | |
| 1270 | + | "report_checks" => reply(&work.report_checks(args(body)?).await?), | |
| 1179 | 1271 | "open_pull" => reply(&work.open_pull(args(body)?).await?), | |
| 1180 | 1272 | "list_pulls" => reply(&work.list_pulls(args(body)?).await?), | |
| 1181 | 1273 | "get_pull" => reply(&work.get_pull(args(body)?).await?), |
| 3 | 3 | use g1t_contracts::User; | |
| 4 | 4 | use g1t_contracts::repos::RepoPath; | |
| 5 | 5 | use g1t_contracts::work::{ | |
| 6 | − | Comment, Issue, IssueReason, Pull, PullStatus, Runtime, SessionEntry, SessionEntryKind, State, | |
| 6 | + | CheckStatus, Comment, Issue, IssueReason, Pull, PullStatus, Runtime, SessionEntry, | |
| 7 | + | SessionEntryKind, State, Verdict, | |
| 7 | 8 | }; | |
| 8 | 9 | use serde::Deserialize; | |
| 9 | 10 | ||
| 81 | 82 | pub merged_by: Option<String>, | |
| 82 | 83 | pub merged_at: Option<String>, | |
| 83 | 84 | pub superseded_by: Option<u32>, | |
| 85 | + | pub check_status: Option<CheckStatus>, | |
| 84 | 86 | pub author_id: String, | |
| 85 | 87 | pub author_name: String, | |
| 86 | 88 | pub created_at: String, | |
| 110 | 112 | merged_by: row.merged_by, | |
| 111 | 113 | merged_at: row.merged_at, | |
| 112 | 114 | superseded_by: row.superseded_by, | |
| 115 | + | check_status: row.check_status, | |
| 113 | 116 | author: user(row.author_id, row.author_name), | |
| 114 | 117 | created_at: row.created_at, | |
| 115 | 118 | updated_at: row.updated_at, | |
| 123 | 126 | pub author_id: String, | |
| 124 | 127 | pub author_name: String, | |
| 125 | 128 | pub body: String, | |
| 129 | + | pub path: Option<String>, | |
| 130 | + | pub line: Option<u32>, | |
| 131 | + | pub verdict: Option<Verdict>, | |
| 126 | 132 | pub created_at: String, | |
| 127 | 133 | } | |
| 128 | 134 | ||
| 132 | 138 | id: row.id, | |
| 133 | 139 | author: user(row.author_id, row.author_name), | |
| 134 | 140 | body: row.body, | |
| 141 | + | path: row.path, | |
| 142 | + | line: row.line, | |
| 143 | + | verdict: row.verdict, | |
| 135 | 144 | created_at: row.created_at, | |
| 136 | 145 | } | |
| 137 | 146 | } | |
| 160 | 169 | } | |
| 161 | 170 | } | |
| 162 | 171 | ||
| 172 | + | /// A pull request whose head a push moved. | |
| 173 | + | #[derive(Deserialize)] | |
| 174 | + | pub struct MovedRow { | |
| 175 | + | pub id: String, | |
| 176 | + | pub repo_id: String, | |
| 177 | + | pub number: u32, | |
| 178 | + | pub issue_number: Option<u32>, | |
| 179 | + | pub status: PullStatus, | |
| 180 | + | } | |
| 181 | + | ||
| 163 | 182 | /// A single number selected as `n`. | |
| 164 | 183 | #[derive(Deserialize)] | |
| 165 | 184 | pub struct NumberRow { |