Agents as a team: lifecycle, merge queue, billing and a new shell
g1t now takes a pull request made by its agent all the way to main, and coordinates many agents at once: - Lifecycle: checks, an agent review, revision (including when a person asks for changes) and catching up, decided by a pure function and remembered so lists read it without recomputing. - Merge queue: pull requests are tested speculatively together with those ahead of them, against the checks of every issue that already landed, and main only moves to a state that passed. Failures go back to their agent, which revises from the current main. - Agents are told what else is in flight, given what people said on the pull request (a person outranks an agent's review), and get g1t's own tools through a token scoped to one repository. - Planner: an outcome becomes issues with dependencies, queued and started as they unblock. - Workspaces with their own access tokens; usage billed to the workspace from prepaid credit at cost plus 20%. - Repository settings: branch protection, required approvals, auto-merge, agent review, revisions and the merge queue. - Site: a sidebar app shell with a command palette, mission control, commit pages, a diff view with file tree and split view, why-blame from any line, GitHub-flavoured markdown with references and alerts, and much faster pages. - API without the /v1 prefix; camelCase request bodies accepted.
| 829 | 829 | ] | |
| 830 | 830 | ||
| 831 | 831 | [[package]] | |
| 832 | + | name = "g1t-billing" | |
| 833 | + | version = "0.1.0" | |
| 834 | + | dependencies = [ | |
| 835 | + | "g1t-contracts", | |
| 836 | + | "g1t-kit", | |
| 837 | + | "getrandom 0.2.17", | |
| 838 | + | "hex", | |
| 839 | + | "serde", | |
| 840 | + | "serde_json", | |
| 841 | + | "sha2 0.10.9", | |
| 842 | + | "worker", | |
| 843 | + | ] | |
| 844 | + | ||
| 845 | + | [[package]] | |
| 832 | 846 | name = "g1t-contracts" | |
| 833 | 847 | version = "0.1.0" | |
| 834 | 848 | dependencies = [ | |
| 878 | 892 | name = "g1t-repos" | |
| 879 | 893 | version = "0.1.0" | |
| 880 | 894 | dependencies = [ | |
| 895 | + | "futures-util", | |
| 881 | 896 | "g1t-contracts", | |
| 882 | 897 | "g1t-kit", | |
| 883 | 898 | "serde", | |
| 916 | 931 | name = "g1t-work" | |
| 917 | 932 | version = "0.1.0" | |
| 918 | 933 | dependencies = [ | |
| 934 | + | "futures-util", | |
| 919 | 935 | "g1t-contracts", | |
| 920 | 936 | "g1t-kit", | |
| 921 | 937 | "getrandom 0.2.17", |
| 1 | 1 | [workspace] | |
| 2 | 2 | resolver = "3" | |
| 3 | − | members = ["apps/api", "crates/*", "services/events", "services/identity", "services/repos", "services/work"] | |
| 3 | + | members = ["apps/api", "crates/*", "services/billing", "services/events", "services/identity", "services/repos", "services/work"] | |
| 4 | 4 | ||
| 5 | 5 | [workspace.package] | |
| 6 | 6 | edition = "2024" |
| 10 | 10 | issue records which pull request resolved it while the others close as | |
| 11 | 11 | superseded. | |
| 12 | 12 | ||
| 13 | + | ## Why this exists | |
| 14 | + | ||
| 15 | + | g1t is an entry in Cloudflare's **Build the Next-Gen Git Platform** | |
| 16 | + | competition, which asks what a git platform looks like when most of the | |
| 17 | + | people using it are agents. | |
| 18 | + | ||
| 19 | + | - The challenge: <https://blog.cloudflare.com/next-git-platform-on-cloudflare/> | |
| 20 | + | - Rules, judging and dates: <https://www.cloudflare.com/git-competition/> | |
| 21 | + | ||
| 22 | + | Submissions close on October 14, 2026. [docs/PLAN.md](docs/PLAN.md) says how | |
| 23 | + | g1t answers the brief and what is built so far. | |
| 24 | + | ||
| 25 | + | ## Where things are | |
| 26 | + | ||
| 13 | 27 | - Site: <https://g1t.sh> | |
| 14 | 28 | - Docs: <https://docs.g1t.sh> | |
| 15 | 29 | - API: <https://api.g1t.sh> · MCP: <https://mcp.g1t.sh> | |
| 16 | 30 | - Plan and design: [docs/PLAN.md](docs/PLAN.md) | |
| 31 | + | - Demo walk-through: [docs/DEMO.md](docs/DEMO.md) | |
| 17 | 32 | ||
| 18 | 33 | ## Status | |
| 19 | 34 | ||
| 22 | 37 | - Accounts with email verification and password reset. Applications sign | |
| 23 | 38 | in through the browser with OAuth 2.1, so connecting an MCP client needs | |
| 24 | 39 | no pasted token; tools without a browser use a device code. | |
| 25 | − | - Workspaces that own repositories, with members and roles. | |
| 40 | + | - Workspaces that own repositories, with members and roles. Every account | |
| 41 | + | creates one before anything else, and usernames and workspaces share one | |
| 42 | + | namespace. | |
| 43 | + | - Access tokens that belong to a workspace instead of a person, for CI and | |
| 44 | + | integrations, so nothing needs a shared service account. | |
| 26 | 45 | - Public and private repositories, and git over HTTPS, including creating a | |
| 27 | 46 | repository by pushing to it. | |
| 28 | 47 | - Issues with labels, acceptance checks and comments. | |
| 34 | 53 | the merge. | |
| 35 | 54 | - Review: comments on lines of a change, and approve or request-changes | |
| 36 | 55 | verdicts, from people and from agents. | |
| 56 | + | - Overlap: each pull request shows which others in progress change the | |
| 57 | + | same files, while the work is still going on. | |
| 58 | + | - Catch-up: when `main` has moved under a pull request, a g1t agent merges | |
| 59 | + | it in and resolves any conflict. | |
| 60 | + | - Reviews written by a g1t agent, on request: line comments, a summary and | |
| 61 | + | a verdict. | |
| 62 | + | - Importing a public repository from GitHub or any git host. | |
| 37 | 63 | - Merging: lands a pull request on `main`, closes its issue naming the pull | |
| 38 | 64 | request that resolved it, and closes the others for that issue as | |
| 39 | 65 | superseded. Refused when the pull request is behind, so no commit is lost. | |
| 43 | 69 | - An event bus: every state change is published, logged and delivered to | |
| 44 | 70 | subscribers. | |
| 45 | 71 | ||
| 46 | − | Not built yet: server-side merge commits, required reviews, git over SSH. See the build order in the plan. | |
| 72 | + | Not built yet: a landing queue, required reviews, git over SSH. See the build order in the plan. | |
| 47 | 73 | ||
| 48 | 74 | ## Try it | |
| 49 | 75 |
| 10 | 10 | mod operations; | |
| 11 | 11 | mod rest; | |
| 12 | 12 | ||
| 13 | + | use g1t_contracts::billing::FinishRunArgs; | |
| 13 | 14 | use g1t_contracts::identity::{ | |
| 14 | 15 | DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs, | |
| 15 | 16 | }; | |
| 16 | − | use g1t_contracts::work::{CheckRun, ReportChecksArgs}; | |
| 17 | − | use g1t_contracts::{Failure, FailureCode, Outcome, Viewer}; | |
| 17 | + | use g1t_contracts::work::{ | |
| 18 | + | CheckRun, QueueState, ReportChecksArgs, ReportPlanArgs, ReportQueueArgs, ReportReviewArgs, | |
| 19 | + | }; | |
| 20 | + | use g1t_contracts::identity::AgentScope; | |
| 21 | + | use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, Viewer}; | |
| 18 | 22 | use serde_json::{Value, json}; | |
| 19 | 23 | use worker::{Context, Env, Method, Request, Response, Result, event}; | |
| 20 | 24 | ||
| 88 | 92 | ||
| 89 | 93 | /// Where everything is, for someone or something exploring the API. | |
| 90 | 94 | fn index() -> Value { | |
| 91 | − | let repo = format!("{API}/v1/repos/{{owner}}/{{name}}"); | |
| 95 | + | let repo = format!("{API}/repos/{{owner}}/{{name}}"); | |
| 92 | 96 | json!({ | |
| 93 | 97 | "documentation_url": "https://docs.g1t.sh/api/reference/", | |
| 94 | 98 | "openapi_url": format!("{API}/openapi.json"), | |
| 95 | 99 | "mcp_url": "https://mcp.g1t.sh", | |
| 96 | − | "current_user_url": format!("{API}/v1/user"), | |
| 97 | − | "workspaces_url": format!("{API}/v1/workspaces"), | |
| 98 | − | "repositories_url": format!("{API}/v1/repos{{?q}}"), | |
| 100 | + | "current_user_url": format!("{API}/user"), | |
| 101 | + | "workspaces_url": format!("{API}/workspaces"), | |
| 102 | + | "repositories_url": format!("{API}/repos{{?q}}"), | |
| 99 | 103 | "repository_url": repo, | |
| 100 | 104 | "repository_events_url": format!("{repo}/events{{?before}}"), | |
| 101 | 105 | "labels_url": format!("{repo}/labels"), | |
| 107 | 111 | "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"), | |
| 108 | 112 | "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"), | |
| 109 | 113 | "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"), | |
| 110 | − | "device_code_url": format!("{API}/v1/device/code"), | |
| 111 | − | "device_token_url": format!("{API}/v1/device/token"), | |
| 114 | + | "device_code_url": format!("{API}/device/code"), | |
| 115 | + | "device_token_url": format!("{API}/device/token"), | |
| 112 | 116 | "oauth_metadata_url": format!("{API}/.well-known/oauth-authorization-server"), | |
| 113 | 117 | "git_url": "https://g1t.sh/{owner}/{name}.git", | |
| 114 | 118 | }) | |
| 187 | 191 | } | |
| 188 | 192 | } | |
| 189 | 193 | ||
| 194 | + | /// A sandbox reporting one tested state of a merge queue. As with checks, | |
| 195 | + | /// the entry's own token is the credential. | |
| 196 | + | async fn report_queue( | |
| 197 | + | request: &mut Request, | |
| 198 | + | services: &Services, | |
| 199 | + | entry_id: &str, | |
| 200 | + | ) -> Result<Response> { | |
| 201 | + | let body = json_body(request).await; | |
| 202 | + | let reported: Outcome<QueueState> = g1t_kit::call( | |
| 203 | + | &services.work, | |
| 204 | + | "report_queue", | |
| 205 | + | &ReportQueueArgs { | |
| 206 | + | entry_id: entry_id.to_owned(), | |
| 207 | + | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 208 | + | combined_commit: body["combinedCommit"].as_str().map(str::to_owned), | |
| 209 | + | results: serde_json::from_value(body["results"].clone()).unwrap_or_default(), | |
| 210 | + | error: body["error"].as_str().map(str::to_owned), | |
| 211 | + | conflict_with: body["conflictWith"].as_u64().map(|n| n as u32), | |
| 212 | + | }, | |
| 213 | + | ) | |
| 214 | + | .await?; | |
| 215 | + | match reported { | |
| 216 | + | Outcome::Ok(state) => Response::from_json(&json!({ "state": state })), | |
| 217 | + | Outcome::Fail(refused) => failure(&refused), | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// A sandbox reporting the review its agent wrote. As with checks, the | |
| 222 | + | /// run's own token is the credential. | |
| 223 | + | async fn report_review( | |
| 224 | + | request: &mut Request, | |
| 225 | + | services: &Services, | |
| 226 | + | run_id: &str, | |
| 227 | + | ) -> Result<Response> { | |
| 228 | + | let body = json_body(request).await; | |
| 229 | + | let reported: Outcome<bool> = g1t_kit::call( | |
| 230 | + | &services.work, | |
| 231 | + | "report_review", | |
| 232 | + | &ReportReviewArgs { | |
| 233 | + | run_id: run_id.to_owned(), | |
| 234 | + | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 235 | + | verdict: serde_json::from_value(body["verdict"].clone()).unwrap_or(None), | |
| 236 | + | body: body["body"].as_str().unwrap_or_default().to_owned(), | |
| 237 | + | comments: serde_json::from_value(body["comments"].clone()).unwrap_or_default(), | |
| 238 | + | model: body["model"].as_str().map(str::to_owned), | |
| 239 | + | error: body["error"].as_str().map(str::to_owned), | |
| 240 | + | }, | |
| 241 | + | ) | |
| 242 | + | .await?; | |
| 243 | + | match reported { | |
| 244 | + | Outcome::Ok(_) => Response::from_json(&json!({ "recorded": true })), | |
| 245 | + | Outcome::Fail(refused) => failure(&refused), | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /// A sandbox reporting the plan its agent wrote. As with checks, the | |
| 250 | + | /// plan's own token is the credential. | |
| 251 | + | async fn report_plan( | |
| 252 | + | request: &mut Request, | |
| 253 | + | services: &Services, | |
| 254 | + | plan_id: &str, | |
| 255 | + | ) -> Result<Response> { | |
| 256 | + | let body = json_body(request).await; | |
| 257 | + | let reported: Outcome<bool> = g1t_kit::call( | |
| 258 | + | &services.work, | |
| 259 | + | "report_plan", | |
| 260 | + | &ReportPlanArgs { | |
| 261 | + | plan_id: plan_id.to_owned(), | |
| 262 | + | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 263 | + | summary: body["summary"].as_str().unwrap_or_default().to_owned(), | |
| 264 | + | issues: serde_json::from_value(body["issues"].clone()).unwrap_or_default(), | |
| 265 | + | error: body["error"].as_str().map(str::to_owned), | |
| 266 | + | }, | |
| 267 | + | ) | |
| 268 | + | .await?; | |
| 269 | + | match reported { | |
| 270 | + | Outcome::Ok(_) => Response::from_json(&json!({ "recorded": true })), | |
| 271 | + | Outcome::Fail(refused) => failure(&refused), | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /// A sandbox reporting what its agent's run cost, so that the workspace | |
| 276 | + | /// it worked for is charged. As with checks, the run's own token is the | |
| 277 | + | /// credential. | |
| 278 | + | async fn report_usage( | |
| 279 | + | request: &mut Request, | |
| 280 | + | services: &Services, | |
| 281 | + | run_id: &str, | |
| 282 | + | ) -> Result<Response> { | |
| 283 | + | let body = json_body(request).await; | |
| 284 | + | let charged: Outcome<bool> = g1t_kit::call( | |
| 285 | + | &services.billing, | |
| 286 | + | "finish_run", | |
| 287 | + | &FinishRunArgs { | |
| 288 | + | run_id: run_id.to_owned(), | |
| 289 | + | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 290 | + | cost_usd: body["cost_usd"].as_f64().unwrap_or_default(), | |
| 291 | + | turns: body["turns"].as_u64().unwrap_or_default() as u32, | |
| 292 | + | }, | |
| 293 | + | ) | |
| 294 | + | .await?; | |
| 295 | + | match charged { | |
| 296 | + | Outcome::Ok(_) => Response::from_json(&json!({ "recorded": true })), | |
| 297 | + | Outcome::Fail(refused) => failure(&refused), | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 190 | 301 | async fn respond(mut request: Request, env: &Env) -> Result<Response> { | |
| 191 | 302 | let method = method_name(request.method()); | |
| 192 | 303 | if method == "OPTIONS" { | |
| 193 | 304 | return Ok(Response::empty()?.with_status(204)); | |
| 194 | 305 | } | |
| 195 | 306 | let url = request.url()?; | |
| 196 | − | let path = url.path().to_owned(); | |
| 307 | + | // Paths carry no version. An earlier form began with `/v1`, which is | |
| 308 | + | // still accepted so that nothing already written against it breaks. | |
| 309 | + | let path = match url.path().strip_prefix("/v1") { | |
| 310 | + | Some(rest) if rest.is_empty() || rest.starts_with('/') => rest.to_owned(), | |
| 311 | + | _ => url.path().to_owned(), | |
| 312 | + | }; | |
| 197 | 313 | let on_mcp = url.host_str().is_some_and(|host| host.starts_with("mcp.")); | |
| 198 | − | let services = Services::new(env)?; | |
| 314 | + | let mut services = Services::new(env)?; | |
| 199 | 315 | ||
| 200 | 316 | let viewer = match authenticate(&request, &services).await? { | |
| 201 | 317 | Ok(viewer) => viewer, | |
| 202 | 318 | Err(refused) => return Ok(refused), | |
| 203 | 319 | }; | |
| 320 | + | // An agent's token: what it may do comes with it. | |
| 321 | + | if viewer.as_ref().is_some_and(|viewer| viewer.kind == PrincipalKind::Agent) { | |
| 322 | + | let header = request.headers().get("authorization")?.unwrap_or_default(); | |
| 323 | + | let token = header.split_once(' ').map(|(_, token)| token.trim()).unwrap_or_default(); | |
| 324 | + | let scope: Option<AgentScope> = g1t_kit::call( | |
| 325 | + | &services.identity, | |
| 326 | + | "agent_scope", | |
| 327 | + | &TokenArgs { | |
| 328 | + | token: token.to_owned(), | |
| 329 | + | }, | |
| 330 | + | ) | |
| 331 | + | .await?; | |
| 332 | + | // A scope is what lets an agent's token do anything at all. | |
| 333 | + | let Some(scope) = scope else { | |
| 334 | + | return fail(FailureCode::Unauthenticated, "Invalid access token."); | |
| 335 | + | }; | |
| 336 | + | services.scope = Some(scope); | |
| 337 | + | } | |
| 204 | 338 | if let Some(response) = oauth::handle(&mut request, &services, method, &path).await? { | |
| 205 | 339 | return Ok(response); | |
| 206 | 340 | } | |
| 209 | 343 | } | |
| 210 | 344 | ||
| 211 | 345 | match (method, path.trim_end_matches('/')) { | |
| 212 | − | ("GET", "" | "/v1") => return Response::from_json(&index()), | |
| 346 | + | ("GET", "") => return Response::from_json(&index()), | |
| 213 | 347 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 214 | − | ("POST", "/v1/device/code") => return device_code(&mut request, &services).await, | |
| 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(); | |
| 348 | + | ("POST", "/device/code") => return device_code(&mut request, &services).await, | |
| 349 | + | ("POST", "/device/token") => return device_token(&mut request, &services).await, | |
| 350 | + | ("POST", path) if path.starts_with("/queue/") => { | |
| 351 | + | let entry_id = path.trim_start_matches("/queue/").to_owned(); | |
| 352 | + | return report_queue(&mut request, &services, &entry_id).await; | |
| 353 | + | } | |
| 354 | + | ("POST", path) if path.starts_with("/checks/") => { | |
| 355 | + | let run_id = path.trim_start_matches("/checks/").to_owned(); | |
| 218 | 356 | return report_checks(&mut request, &services, &run_id).await; | |
| 219 | 357 | } | |
| 358 | + | ("POST", path) if path.starts_with("/runs/") && path.ends_with("/usage") => { | |
| 359 | + | let run_id = path | |
| 360 | + | .trim_start_matches("/runs/") | |
| 361 | + | .trim_end_matches("/usage") | |
| 362 | + | .to_owned(); | |
| 363 | + | return report_usage(&mut request, &services, &run_id).await; | |
| 364 | + | } | |
| 365 | + | ("POST", path) if path.starts_with("/plans/") => { | |
| 366 | + | let plan_id = path.trim_start_matches("/plans/").to_owned(); | |
| 367 | + | return report_plan(&mut request, &services, &plan_id).await; | |
| 368 | + | } | |
| 369 | + | ("POST", path) if path.starts_with("/reviews/") => { | |
| 370 | + | let run_id = path.trim_start_matches("/reviews/").to_owned(); | |
| 371 | + | return report_review(&mut request, &services, &run_id).await; | |
| 372 | + | } | |
| 220 | 373 | _ => {} | |
| 221 | 374 | } | |
| 222 | 375 | ||
| 227 | 380 | let body = if method == "GET" { | |
| 228 | 381 | Value::Null | |
| 229 | 382 | } else { | |
| 230 | − | json_body(&mut request).await | |
| 383 | + | snake_case_keys(json_body(&mut request).await) | |
| 231 | 384 | }; | |
| 232 | 385 | let Some((route, input)) = rest::resolve(method, &path, &query, body) else { | |
| 233 | 386 | return fail(FailureCode::NotFound, "No such endpoint."); | |
| 238 | 391 | } | |
| 239 | 392 | } | |
| 240 | 393 | ||
| 394 | + | /// Request bodies take the same keys as the MCP tools, `snake_case`; the | |
| 395 | + | /// `camelCase` that responses use is accepted too, so a client can send | |
| 396 | + | /// back what it read. | |
| 397 | + | fn snake_case_keys(body: Value) -> Value { | |
| 398 | + | let Value::Object(fields) = body else { | |
| 399 | + | return body; | |
| 400 | + | }; | |
| 401 | + | let mut out = serde_json::Map::new(); | |
| 402 | + | for (key, value) in fields { | |
| 403 | + | let mut snake = String::with_capacity(key.len() + 4); | |
| 404 | + | for c in key.chars() { | |
| 405 | + | if c.is_ascii_uppercase() { | |
| 406 | + | snake.push('_'); | |
| 407 | + | snake.push(c.to_ascii_lowercase()); | |
| 408 | + | } else { | |
| 409 | + | snake.push(c); | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | // A key given in both spellings keeps the snake_case one. | |
| 413 | + | if snake != key && out.contains_key(&snake) { | |
| 414 | + | continue; | |
| 415 | + | } | |
| 416 | + | out.insert(snake, value); | |
| 417 | + | } | |
| 418 | + | Value::Object(out) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | #[cfg(test)] | |
| 422 | + | mod tests { | |
| 423 | + | use super::snake_case_keys; | |
| 424 | + | use serde_json::json; | |
| 425 | + | ||
| 426 | + | #[test] | |
| 427 | + | fn camel_case_keys_are_accepted() { | |
| 428 | + | assert_eq!( | |
| 429 | + | snake_case_keys(json!({ "countAgentApprovals": false, "title": "x" })), | |
| 430 | + | json!({ "count_agent_approvals": false, "title": "x" }) | |
| 431 | + | ); | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | #[test] | |
| 435 | + | fn snake_case_wins_when_both_are_given() { | |
| 436 | + | assert_eq!( | |
| 437 | + | snake_case_keys(json!({ "keep_issue_open": true, "keepIssueOpen": false })), | |
| 438 | + | json!({ "keep_issue_open": true }) | |
| 439 | + | ); | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 241 | 443 | // The API is called from browsers too: the reference's explorer, and apps | |
| 242 | 444 | // built on g1t. It carries no cookies, so any origin may call it. | |
| 243 | 445 | #[event(fetch)] |
| 12 | 12 | ||
| 13 | 13 | const INSTRUCTIONS: &str = "g1t is a git forge with issues and pull requests, built so that many agents can work on the same issue at once. | |
| 14 | 14 | To work on an issue: get_issue to read it and see the pull requests already made for it, then create_pull_request with the issue's number. You get a draft pull request with its own fork to clone and push to. Call record_session as you work so people can see your reasoning, push your commits, and call mark_pull_request_ready with a summary. | |
| 15 | + | Before going far, read `overlaps` on get_pull_request: other pull requests in progress that change the same files. One for a different issue will conflict with yours, so narrow your change or say so. `behind` means main has moved; pull it into your fork and push. Once your pull request is ready, the issue's acceptance checks are run for you in a clean sandbox; read their output from get_pull_request and push a fix if they fail. | |
| 15 | 16 | Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers."; | |
| 16 | 17 | ||
| 17 | 18 | fn result(id: &Value, value: Value) -> Value { | |
| 48 | 49 | } | |
| 49 | 50 | "ping" => result(id, json!({})), | |
| 50 | 51 | "tools/list" => { | |
| 52 | + | // An agent sees only the tools its token may use. | |
| 51 | 53 | let tools: Vec<Value> = Op::ALL | |
| 52 | 54 | .into_iter() | |
| 55 | + | .filter(|op| services.scope.as_ref().is_none_or(|scope| op.allowed_by(scope))) | |
| 53 | 56 | .map(|op| { | |
| 54 | 57 | json!({ | |
| 55 | 58 | "name": op.name(), |
| 37 | 37 | } | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | − | /// `/v1/repos/:owner/:name` as OpenAPI writes it: `/v1/repos/{owner}/{name}`. | |
| 40 | + | /// `/repos/:owner/:name` as OpenAPI writes it: `/repos/{owner}/{name}`. | |
| 41 | 41 | fn openapi_path(route: &Route) -> String { | |
| 42 | 42 | route | |
| 43 | 43 | .path | |
| 129 | 129 | /// Entries for device sign-in, which is not an operation. | |
| 130 | 130 | fn onboarding() -> Map<String, Value> { | |
| 131 | 131 | let paths = json!({ | |
| 132 | − | "/v1/device/code": { | |
| 132 | + | "/device/code": { | |
| 133 | 133 | "post": { | |
| 134 | 134 | "operationId": "device_code", | |
| 135 | 135 | "tags": ["Accounts"], | |
| 136 | 136 | "summary": "Start signing in", | |
| 137 | − | "description": "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/v1/device/token`.", | |
| 137 | + | "description": "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/device/token`.", | |
| 138 | 138 | "security": [], | |
| 139 | 139 | "requestBody": { | |
| 140 | 140 | "content": { "application/json": { "schema": { | |
| 152 | 152 | "content": { "application/json": { "schema": { | |
| 153 | 153 | "type": "object", | |
| 154 | 154 | "properties": { | |
| 155 | − | "device_code": { "type": "string", "description": "Secret. Send it to /v1/device/token." }, | |
| 155 | + | "device_code": { "type": "string", "description": "Secret. Send it to /device/token." }, | |
| 156 | 156 | "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." }, | |
| 157 | 157 | "verification_uri": { "type": "string" }, | |
| 158 | 158 | "verification_uri_complete": { | |
| 166 | 166 | } }, | |
| 167 | 167 | }, | |
| 168 | 168 | }, | |
| 169 | − | "/v1/device/token": { | |
| 169 | + | "/device/token": { | |
| 170 | 170 | "post": { | |
| 171 | 171 | "operationId": "device_token", | |
| 172 | 172 | "tags": ["Accounts"], | |
| 295 | 295 | #[test] | |
| 296 | 296 | fn path_and_query_inputs_are_not_repeated_in_the_body() { | |
| 297 | 297 | let document = document(); | |
| 298 | − | let merge = &document["paths"]["/v1/repos/{owner}/{name}/pulls/{number}/merge"]["post"]; | |
| 298 | + | let merge = &document["paths"]["/repos/{owner}/{name}/pulls/{number}/merge"]["post"]; | |
| 299 | 299 | let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"]; | |
| 300 | 300 | assert!(body.get("keep_issue_open").is_some()); | |
| 301 | 301 | assert!(body.get("repo").is_none() && body.get("number").is_none()); | |
| 302 | − | let list = &document["paths"]["/v1/repos"]["get"]; | |
| 302 | + | let list = &document["paths"]["/repos"]["get"]; | |
| 303 | 303 | assert_eq!(list["parameters"][0]["name"], "q"); | |
| 304 | 304 | assert!(list.get("requestBody").is_none()); | |
| 305 | 305 | } |
| 4 | 4 | //! [`Op`], so the surfaces cannot drift apart: adding a variant without | |
| 5 | 5 | //! describing it or running it does not compile. | |
| 6 | 6 | ||
| 7 | + | use g1t_contracts::identity::AgentScope; | |
| 7 | 8 | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs}; | |
| 8 | 9 | use g1t_contracts::identity::CreateWorkspaceArgs; | |
| 9 | − | use g1t_contracts::repos::{ | |
| 10 | − | CompareArgs, CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath, | |
| 11 | − | }; | |
| 10 | + | use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath}; | |
| 12 | 11 | use g1t_contracts::work::*; | |
| 13 | 12 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 14 | 13 | use serde::Serialize; | |
| 22 | 21 | pub repos: Fetcher, | |
| 23 | 22 | pub work: Fetcher, | |
| 24 | 23 | pub events: Fetcher, | |
| 24 | + | pub runner: Fetcher, | |
| 25 | + | pub billing: Fetcher, | |
| 26 | + | /// Set for a request made with an agent's token: all it may do. | |
| 27 | + | pub scope: Option<AgentScope>, | |
| 25 | 28 | } | |
| 26 | 29 | ||
| 27 | 30 | impl Services { | |
| 31 | 34 | repos: env.service("REPOS")?, | |
| 32 | 35 | work: env.service("WORK")?, | |
| 33 | 36 | events: env.service("EVENTS")?, | |
| 37 | + | runner: env.service("RUNNER")?, | |
| 38 | + | billing: env.service("BILLING")?, | |
| 39 | + | scope: None, | |
| 34 | 40 | }) | |
| 35 | 41 | } | |
| 36 | 42 | } | |
| 42 | 48 | ListRepos, | |
| 43 | 49 | GetRepo, | |
| 44 | 50 | CreateRepo, | |
| 51 | + | UpdateRepo, | |
| 52 | + | GetRepoSettings, | |
| 53 | + | UpdateRepoSettings, | |
| 54 | + | GetMergeQueue, | |
| 45 | 55 | ListIssues, | |
| 46 | 56 | GetIssue, | |
| 47 | 57 | CreateIssue, | |
| 48 | 58 | UpdateIssue, | |
| 49 | 59 | CloseIssue, | |
| 50 | 60 | ReopenIssue, | |
| 61 | + | AssignIssue, | |
| 62 | + | PlanWork, | |
| 63 | + | GetPlan, | |
| 64 | + | ApplyPlan, | |
| 51 | 65 | ListLabels, | |
| 52 | 66 | AddComment, | |
| 53 | 67 | ReviewPullRequest, | |
| 168 | 182 | "type": "string", | |
| 169 | 183 | "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".", | |
| 170 | 184 | }) | |
| 171 | − | } | |
| 172 | − | ||
| 173 | − | /// What `ReposApi.compare` needs to show what a pull request changes. | |
| 174 | − | /// | |
| 175 | − | /// A fork is compared as a whole. A branch is compared by name while the | |
| 176 | − | /// pull request is open, and by the commit it was merged or closed at | |
| 177 | − | /// afterwards, so later pushes to the branch do not change the record. | |
| 178 | − | fn comparison(pull: Pull, viewer: &Viewer) -> CompareArgs { | |
| 179 | − | let settled = matches!(pull.status, PullStatus::Merged | PullStatus::Closed); | |
| 180 | − | let (repo_id, head) = match pull.fork_repo_id { | |
| 181 | − | Some(fork) => (fork, None), | |
| 182 | − | None => ( | |
| 183 | − | pull.repo_id, | |
| 184 | − | pull.head_commit.filter(|_| settled).or(pull.branch), | |
| 185 | − | ), | |
| 186 | − | }; | |
| 187 | − | CompareArgs { | |
| 188 | − | repo_id, | |
| 189 | − | viewer: viewer.clone(), | |
| 190 | − | base: pull.merge_base, | |
| 191 | − | head, | |
| 192 | − | } | |
| 193 | 185 | } | |
| 194 | 186 | ||
| 195 | 187 | impl Op { | |
| 196 | − | pub const ALL: [Op; 24] = [ | |
| 188 | + | pub const ALL: [Op; 32] = [ | |
| 197 | 189 | Op::Whoami, | |
| 198 | 190 | Op::CreateWorkspace, | |
| 199 | 191 | Op::ListRepos, | |
| 200 | 192 | Op::GetRepo, | |
| 201 | 193 | Op::CreateRepo, | |
| 194 | + | Op::UpdateRepo, | |
| 195 | + | Op::GetRepoSettings, | |
| 196 | + | Op::UpdateRepoSettings, | |
| 197 | + | Op::GetMergeQueue, | |
| 202 | 198 | Op::ListIssues, | |
| 203 | 199 | Op::GetIssue, | |
| 204 | 200 | Op::CreateIssue, | |
| 205 | 201 | Op::UpdateIssue, | |
| 206 | 202 | Op::CloseIssue, | |
| 207 | 203 | Op::ReopenIssue, | |
| 204 | + | Op::AssignIssue, | |
| 205 | + | Op::PlanWork, | |
| 206 | + | Op::GetPlan, | |
| 207 | + | Op::ApplyPlan, | |
| 208 | 208 | Op::ListLabels, | |
| 209 | 209 | Op::AddComment, | |
| 210 | 210 | Op::ReviewPullRequest, | |
| 232 | 232 | Op::ListRepos => "list_repos", | |
| 233 | 233 | Op::GetRepo => "get_repo", | |
| 234 | 234 | Op::CreateRepo => "create_repo", | |
| 235 | + | Op::UpdateRepo => "update_repo", | |
| 236 | + | Op::GetRepoSettings => "get_repo_settings", | |
| 237 | + | Op::GetMergeQueue => "get_merge_queue", | |
| 238 | + | Op::UpdateRepoSettings => "update_repo_settings", | |
| 235 | 239 | Op::ListIssues => "list_issues", | |
| 236 | 240 | Op::GetIssue => "get_issue", | |
| 237 | 241 | Op::CreateIssue => "create_issue", | |
| 238 | 242 | Op::UpdateIssue => "update_issue", | |
| 239 | 243 | Op::CloseIssue => "close_issue", | |
| 240 | 244 | Op::ReopenIssue => "reopen_issue", | |
| 245 | + | Op::AssignIssue => "assign_issue", | |
| 246 | + | Op::PlanWork => "plan_work", | |
| 247 | + | Op::GetPlan => "get_plan", | |
| 248 | + | Op::ApplyPlan => "apply_plan", | |
| 241 | 249 | Op::ListLabels => "list_labels", | |
| 242 | 250 | Op::AddComment => "add_comment", | |
| 243 | 251 | Op::ReviewPullRequest => "review_pull_request", | |
| 256 | 264 | ||
| 257 | 265 | pub fn description(self) -> &'static str { | |
| 258 | 266 | match self { | |
| 259 | − | Op::Whoami => "The account the access token belongs to, and its workspaces.", | |
| 267 | + | Op::Whoami => { | |
| 268 | + | "Who the access token acts as, and the workspaces it can work in. `kind` is `user` for a person's token and `workspace` for a token that belongs to a workspace." | |
| 269 | + | } | |
| 260 | 270 | Op::CreateWorkspace => { | |
| 261 | 271 | "Create a workspace. A workspace owns repositories and is the first part of their address, g1t.sh/<workspace>/<repo>. The whoami tool lists the ones you already belong to." | |
| 262 | 272 | } | |
| 263 | 273 | Op::ListRepos => "Repositories you can see, optionally filtered by a search query.", | |
| 264 | 274 | Op::GetRepo => "One repository's details.", | |
| 265 | − | Op::CreateRepo => "Create a repository in one of your workspaces.", | |
| 275 | + | Op::UpdateRepo => { | |
| 276 | + | "Change a repository's description, whether it is private, and whether its default branch is protected. A protected branch refuses pushes and changes only by merging a pull request. Only the fields given are changed. Members of its workspace only." | |
| 277 | + | } | |
| 278 | + | Op::GetRepoSettings => { | |
| 279 | + | "How a repository handles pull requests: the approvals a merge needs, whether failed checks can be overridden, whether a pull request must be up to date, and how g1t's agents are reviewed, revised and merged." | |
| 280 | + | } | |
| 281 | + | Op::UpdateRepoSettings => { | |
| 282 | + | "Change how a repository handles pull requests. Only the fields given are changed. Members of its workspace only." | |
| 283 | + | } | |
| 284 | + | Op::GetMergeQueue => { | |
| 285 | + | "A repository's merge queue: the pull requests waiting to land, in order, each with the state it is being tested in (the default branch with the pull requests ahead of it merged in) and how that went; then those that recently landed or left. With the queue on, merging a pull request adds it here." | |
| 286 | + | } | |
| 287 | + | Op::CreateRepo => { | |
| 288 | + | "Create a repository in one of your workspaces, empty or as a copy of a public git repository elsewhere." | |
| 289 | + | } | |
| 266 | 290 | Op::ListIssues => { | |
| 267 | 291 | "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it." | |
| 268 | 292 | } | |
| 271 | 295 | } | |
| 272 | 296 | Op::CreateIssue => "Open an issue on a repository.", | |
| 273 | 297 | Op::UpdateIssue => { | |
| 274 | − | "Change an issue's title, body or labels. Only the fields given are changed; labels replaces the whole set." | |
| 298 | + | "Change an issue's title, body, labels or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set." | |
| 275 | 299 | } | |
| 276 | 300 | Op::CloseIssue => { | |
| 277 | 301 | "Close an issue without a pull request. Merging a pull request made for an issue closes it for you." | |
| 278 | 302 | } | |
| 279 | 303 | Op::ReopenIssue => "Reopen a closed issue.", | |
| 304 | + | Op::PlanWork => { | |
| 305 | + | "Turn an outcome into a plan. An agent reads the repository and proposes the issues that would get there: what each changes, the checks it must pass, the files it will touch, and which must merge before which. Returns the plan's id at once; the plan takes a minute or two to write, so read it with get_plan until its status is ready. Nothing is opened until apply_plan. Members of the repository's workspace only." | |
| 306 | + | } | |
| 307 | + | Op::GetPlan => { | |
| 308 | + | "A plan: the outcome asked for, its status (planning, ready, failed or applied), and the issues it proposes with their dependencies." | |
| 309 | + | } | |
| 310 | + | Op::ApplyPlan => { | |
| 311 | + | "Open a plan's issues, each blocked by the ones it depends on. With assign, g1t agents start at once on every issue that depends on nothing, working in parallel, and on the others as what they depend on merges. keep limits it to some of the proposed issues, by their positions counting from 1. A plan is applied once." | |
| 312 | + | } | |
| 313 | + | Op::AssignIssue => { | |
| 314 | + | "Assign an issue to the g1t agent. It opens a pull request for the issue in a sandbox of its own and sees it through: the issue's acceptance checks, a review by a second agent, revision if either finds something, and catching up when main moves. Returns the pull request at once; follow its progress with get_pull_request. There is no model or agent count to choose. To put many agents to work, assign many issues. In preview: only for accounts g1t agents are enabled for." | |
| 315 | + | } | |
| 280 | 316 | Op::ListLabels => "The labels available on a repository's issues.", | |
| 281 | 317 | Op::AddComment => { | |
| 282 | 318 | "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change." | |
| 288 | 324 | "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed." | |
| 289 | 325 | } | |
| 290 | 326 | Op::GetPullRequest => { | |
| 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." | |
| 327 | + | "A pull request's status, head commit, comments and reviews, the issue it is for, the latest run of that issue's acceptance checks with each command's output, whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files." | |
| 292 | 328 | } | |
| 293 | 329 | Op::CreatePullRequest => { | |
| 294 | 330 | "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." | |
| 337 | 373 | &[], | |
| 338 | 374 | ), | |
| 339 | 375 | Op::GetRepo | Op::ListLabels => repo_only(), | |
| 376 | + | Op::UpdateRepo => object( | |
| 377 | + | json!({ | |
| 378 | + | "repo": repo_schema(), | |
| 379 | + | "description": { "type": "string", "description": "An empty string clears it." }, | |
| 380 | + | "private": { "type": "boolean" }, | |
| 381 | + | "protected": { | |
| 382 | + | "type": "boolean", | |
| 383 | + | "description": "Refuse pushes to the default branch, so that it changes only by merging a pull request.", | |
| 384 | + | }, | |
| 385 | + | }), | |
| 386 | + | &["repo"], | |
| 387 | + | ), | |
| 388 | + | Op::GetRepoSettings => object(json!({ "repo": repo_schema() }), &["repo"]), | |
| 389 | + | Op::GetMergeQueue => object(json!({ "repo": repo_schema() }), &["repo"]), | |
| 390 | + | Op::UpdateRepoSettings => object( | |
| 391 | + | json!({ | |
| 392 | + | "repo": repo_schema(), | |
| 393 | + | "auto_merge": { | |
| 394 | + | "type": "boolean", | |
| 395 | + | "description": "Land a g1t agent's pull request without a person once every rule is met.", | |
| 396 | + | }, | |
| 397 | + | "require_up_to_date": { | |
| 398 | + | "type": "boolean", | |
| 399 | + | "description": "Refuse to merge a pull request that is behind the default branch. When false, merging brings it up to date first.", | |
| 400 | + | }, | |
| 401 | + | "required_approvals": { | |
| 402 | + | "type": "integer", | |
| 403 | + | "description": "How many approving reviews a merge needs.", | |
| 404 | + | }, | |
| 405 | + | "count_agent_approvals": { | |
| 406 | + | "type": "boolean", | |
| 407 | + | "description": "Whether a g1t agent's approval counts towards required_approvals.", | |
| 408 | + | }, | |
| 409 | + | "allow_ignoring_checks": { | |
| 410 | + | "type": "boolean", | |
| 411 | + | "description": "Whether a member may merge although the acceptance checks did not pass.", | |
| 412 | + | }, | |
| 413 | + | "agent_review": { | |
| 414 | + | "type": "boolean", | |
| 415 | + | "description": "Whether a second agent reviews a g1t agent's pull request unasked.", | |
| 416 | + | }, | |
| 417 | + | "merge_queue": { | |
| 418 | + | "type": "boolean", | |
| 419 | + | "description": "Merge through a queue: each pull request is tested together with those ahead of it, and only a combination that passed reaches the default branch.", | |
| 420 | + | }, | |
| 421 | + | "max_revisions": { | |
| 422 | + | "type": "integer", | |
| 423 | + | "description": "How many times a g1t agent is sent back before a person is asked.", | |
| 424 | + | }, | |
| 425 | + | }), | |
| 426 | + | &["repo"], | |
| 427 | + | ), | |
| 340 | 428 | Op::CreateRepo => object( | |
| 341 | 429 | json!({ | |
| 342 | 430 | "workspace": { | |
| 346 | 434 | "name": { "type": "string" }, | |
| 347 | 435 | "description": { "type": "string" }, | |
| 348 | 436 | "private": { "type": "boolean" }, | |
| 437 | + | "import_url": { | |
| 438 | + | "type": "string", | |
| 439 | + | "description": "Copy the default branch of a public git repository at this https address, e.g. https://github.com/owner/repo.", | |
| 440 | + | }, | |
| 349 | 441 | }), | |
| 350 | 442 | &["name"], | |
| 351 | 443 | ), | |
| 388 | 480 | "title": { "type": "string" }, | |
| 389 | 481 | "body": { "type": "string" }, | |
| 390 | 482 | "labels": { "type": "array", "items": { "type": "string" } }, | |
| 483 | + | "assignees": { | |
| 484 | + | "type": "array", | |
| 485 | + | "items": { "type": "string" }, | |
| 486 | + | "description": "Usernames of the people it is assigned to. Replaces the whole set; an empty list unassigns everyone. To assign it to the g1t agent, use assign_issue.", | |
| 487 | + | }, | |
| 488 | + | })), | |
| 489 | + | &["repo", "number"], | |
| 490 | + | ), | |
| 491 | + | Op::PlanWork => object( | |
| 492 | + | json!({ | |
| 493 | + | "repo": repo_schema(), | |
| 494 | + | "brief": { | |
| 495 | + | "type": "string", | |
| 496 | + | "description": "What should be true when the work is done, in plain words. Say what you want, not how to split it.", | |
| 497 | + | }, | |
| 498 | + | }), | |
| 499 | + | &["repo", "brief"], | |
| 500 | + | ), | |
| 501 | + | Op::GetPlan => object( | |
| 502 | + | json!({ | |
| 503 | + | "repo": repo_schema(), | |
| 504 | + | "plan": { "type": "string", "description": "The plan's id." }, | |
| 505 | + | }), | |
| 506 | + | &["repo", "plan"], | |
| 507 | + | ), | |
| 508 | + | Op::ApplyPlan => object( | |
| 509 | + | json!({ | |
| 510 | + | "repo": repo_schema(), | |
| 511 | + | "plan": { "type": "string", "description": "The plan's id." }, | |
| 512 | + | "assign": { | |
| 513 | + | "type": "boolean", | |
| 514 | + | "description": "Put g1t agents on the issues, in dependency order.", | |
| 515 | + | }, | |
| 516 | + | "keep": { | |
| 517 | + | "type": "array", | |
| 518 | + | "items": { "type": "integer" }, | |
| 519 | + | "description": "Positions, counting from 1, of the proposed issues to open. All of them if left out.", | |
| 520 | + | }, | |
| 521 | + | }), | |
| 522 | + | &["repo", "plan"], | |
| 523 | + | ), | |
| 524 | + | Op::AssignIssue => object( | |
| 525 | + | numbered(json!({ | |
| 526 | + | "instructions": { | |
| 527 | + | "type": "string", | |
| 528 | + | "description": "Extra guidance for this run, on top of the issue's description.", | |
| 529 | + | }, | |
| 391 | 530 | })), | |
| 392 | 531 | &["repo", "number"], | |
| 393 | 532 | ), | |
| 518 | 657 | | Op::ReadSession | |
| 519 | 658 | | Op::GetPullRequestChanges | |
| 520 | 659 | | Op::ListEvents | |
| 660 | + | | Op::GetRepoSettings | |
| 661 | + | | Op::GetMergeQueue | |
| 521 | 662 | ) | |
| 522 | 663 | } | |
| 523 | 664 | ||
| 665 | + | /// Whether an agent's token with `scope` may use the operation. | |
| 666 | + | pub fn allowed_by(self, scope: &AgentScope) -> bool { | |
| 667 | + | scope.operations.iter().any(|name| name == self.name()) | |
| 668 | + | } | |
| 669 | + | ||
| 524 | 670 | /// Whether the operation is about one repository, named by `repo`. | |
| 525 | 671 | fn needs_repo(self) -> bool { | |
| 526 | 672 | !matches!( | |
| 541 | 687 | "This needs a g1t access token.", | |
| 542 | 688 | ); | |
| 543 | 689 | } | |
| 690 | + | // An agent's token does only what its scope lists, in its repository. | |
| 691 | + | if let Some(scope) = &services.scope { | |
| 692 | + | if !self.allowed_by(scope) { | |
| 693 | + | return failed( | |
| 694 | + | FailureCode::Forbidden, | |
| 695 | + | &format!("A g1t agent's token cannot use {}.", self.name()), | |
| 696 | + | ); | |
| 697 | + | } | |
| 698 | + | let asked = repo_path(input); | |
| 699 | + | if self.needs_repo() | |
| 700 | + | && !asked.is_some_and(|asked| { | |
| 701 | + | asked.namespace.eq_ignore_ascii_case(&scope.repo.namespace) | |
| 702 | + | && asked.name.eq_ignore_ascii_case(&scope.repo.name) | |
| 703 | + | }) | |
| 704 | + | { | |
| 705 | + | return failed( | |
| 706 | + | FailureCode::Forbidden, | |
| 707 | + | &format!( | |
| 708 | + | "A g1t agent's token works in {}/{} only.", | |
| 709 | + | scope.repo.namespace, scope.repo.name | |
| 710 | + | ), | |
| 711 | + | ); | |
| 712 | + | } | |
| 713 | + | } | |
| 544 | 714 | // Checked above for every operation that uses it. | |
| 545 | 715 | let actor = || viewer.clone().unwrap_or_default(); | |
| 546 | 716 | let repo = match repo_path(input) { | |
| 576 | 746 | repos, | |
| 577 | 747 | work, | |
| 578 | 748 | events, | |
| 749 | + | runner, | |
| 750 | + | .. | |
| 579 | 751 | } = services; | |
| 580 | 752 | ||
| 581 | 753 | match self { | |
| 617 | 789 | ) | |
| 618 | 790 | .await | |
| 619 | 791 | } | |
| 792 | + | Op::UpdateRepo => { | |
| 793 | + | pass( | |
| 794 | + | repos, | |
| 795 | + | "update", | |
| 796 | + | &json!({ | |
| 797 | + | "actor": actor(), | |
| 798 | + | "path": repo, | |
| 799 | + | "description": input["description"].as_str(), | |
| 800 | + | "isPrivate": input["private"].as_bool(), | |
| 801 | + | "protected": input["protected"].as_bool(), | |
| 802 | + | }), | |
| 803 | + | ) | |
| 804 | + | .await | |
| 805 | + | } | |
| 806 | + | Op::GetRepoSettings => { | |
| 807 | + | pass( | |
| 808 | + | work, | |
| 809 | + | "get_settings", | |
| 810 | + | &json!({ "repo": repo, "viewer": viewer }), | |
| 811 | + | ) | |
| 812 | + | .await | |
| 813 | + | } | |
| 814 | + | Op::GetMergeQueue => { | |
| 815 | + | pass(work, "queue", &json!({ "repo": repo, "viewer": viewer })).await | |
| 816 | + | } | |
| 817 | + | Op::UpdateRepoSettings => { | |
| 818 | + | // What is not given stays as it is. | |
| 819 | + | let current: Outcome<RepoSettings> = g1t_kit::call( | |
| 820 | + | work, | |
| 821 | + | "get_settings", | |
| 822 | + | &json!({ "repo": repo, "viewer": viewer }), | |
| 823 | + | ) | |
| 824 | + | .await?; | |
| 825 | + | let current = match current { | |
| 826 | + | Outcome::Ok(settings) => settings, | |
| 827 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 828 | + | }; | |
| 829 | + | let flag = |key: &str, now: bool| input[key].as_bool().unwrap_or(now); | |
| 830 | + | let settings = RepoSettings { | |
| 831 | + | auto_merge: flag("auto_merge", current.auto_merge), | |
| 832 | + | require_up_to_date: flag("require_up_to_date", current.require_up_to_date), | |
| 833 | + | required_approvals: integer(input, "required_approvals") | |
| 834 | + | .unwrap_or(current.required_approvals), | |
| 835 | + | count_agent_approvals: flag( | |
| 836 | + | "count_agent_approvals", | |
| 837 | + | current.count_agent_approvals, | |
| 838 | + | ), | |
| 839 | + | allow_ignoring_checks: flag( | |
| 840 | + | "allow_ignoring_checks", | |
| 841 | + | current.allow_ignoring_checks, | |
| 842 | + | ), | |
| 843 | + | agent_review: flag("agent_review", current.agent_review), | |
| 844 | + | max_revisions: integer(input, "max_revisions").unwrap_or(current.max_revisions), | |
| 845 | + | merge_queue: flag("merge_queue", current.merge_queue), | |
| 846 | + | ..current | |
| 847 | + | }; | |
| 848 | + | pass( | |
| 849 | + | work, | |
| 850 | + | "update_settings", | |
| 851 | + | &UpdateSettingsArgs { | |
| 852 | + | actor: actor(), | |
| 853 | + | repo, | |
| 854 | + | settings, | |
| 855 | + | }, | |
| 856 | + | ) | |
| 857 | + | .await | |
| 858 | + | } | |
| 620 | 859 | Op::CreateRepo => { | |
| 621 | 860 | let owner = actor(); | |
| 622 | 861 | // Someone in exactly one workspace need not name it. | |
| 635 | 874 | name: text(input, "name"), | |
| 636 | 875 | description: optional_text(input, "description"), | |
| 637 | 876 | is_private: input["private"].as_bool() == Some(true), | |
| 877 | + | import_url: optional_text(input, "import_url"), | |
| 638 | 878 | }, | |
| 639 | 879 | ) | |
| 640 | 880 | .await | |
| 679 | 919 | title: input["title"].as_str().map(str::to_owned), | |
| 680 | 920 | body: input["body"].as_str().map(str::to_owned), | |
| 681 | 921 | labels: strings(input, "labels"), | |
| 922 | + | assignees: strings(input, "assignees"), | |
| 682 | 923 | }, | |
| 683 | 924 | ) | |
| 684 | 925 | .await | |
| 685 | 926 | } | |
| 927 | + | Op::PlanWork => { | |
| 928 | + | pass( | |
| 929 | + | runner, | |
| 930 | + | "plan", | |
| 931 | + | &json!({ "actor": actor(), "repo": repo, "brief": text(input, "brief") }), | |
| 932 | + | ) | |
| 933 | + | .await | |
| 934 | + | } | |
| 935 | + | Op::GetPlan => { | |
| 936 | + | pass( | |
| 937 | + | work, | |
| 938 | + | "get_plan", | |
| 939 | + | &PlanArgs { | |
| 940 | + | repo, | |
| 941 | + | viewer: viewer.clone(), | |
| 942 | + | id: text(input, "plan"), | |
| 943 | + | }, | |
| 944 | + | ) | |
| 945 | + | .await | |
| 946 | + | } | |
| 947 | + | Op::ApplyPlan => { | |
| 948 | + | pass( | |
| 949 | + | runner, | |
| 950 | + | "apply_plan", | |
| 951 | + | &json!({ | |
| 952 | + | "actor": actor(), | |
| 953 | + | "repo": repo, | |
| 954 | + | "planId": text(input, "plan"), | |
| 955 | + | "assign": input["assign"].as_bool() == Some(true), | |
| 956 | + | "keep": input["keep"].as_array(), | |
| 957 | + | }), | |
| 958 | + | ) | |
| 959 | + | .await | |
| 960 | + | } | |
| 961 | + | Op::AssignIssue => { | |
| 962 | + | pass( | |
| 963 | + | runner, | |
| 964 | + | "run", | |
| 965 | + | &json!({ | |
| 966 | + | "actor": actor(), | |
| 967 | + | "repo": repo, | |
| 968 | + | "issue": number, | |
| 969 | + | "instructions": text(input, "instructions"), | |
| 970 | + | }), | |
| 971 | + | ) | |
| 972 | + | .await | |
| 973 | + | } | |
| 686 | 974 | Op::CloseIssue | Op::ReopenIssue => { | |
| 687 | 975 | let reason = match input["reason"].as_str() { | |
| 688 | 976 | Some("not_planned") => IssueReason::NotPlanned, | |
| 807 | 1095 | let found: Outcome<PullDetail> = call(work, "get_pull", &view()).await?; | |
| 808 | 1096 | match found { | |
| 809 | 1097 | Outcome::Ok(detail) => { | |
| 810 | − | pass(repos, "compare", &comparison(detail.pull, viewer)).await | |
| 1098 | + | pass(repos, "compare", &detail.pull.comparison(viewer)).await | |
| 811 | 1099 | } | |
| 812 | 1100 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 813 | 1101 | } |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | pub const ROUTES: &[Route] = &[ | |
| 31 | − | route("GET", "/v1/user", Op::Whoami, &[]), | |
| 32 | − | route("POST", "/v1/workspaces", Op::CreateWorkspace, &[]), | |
| 33 | − | route("GET", "/v1/repos", Op::ListRepos, &[("q", "query")]), | |
| 34 | − | route("POST", "/v1/repos", Op::CreateRepo, &[]), | |
| 35 | − | route("GET", "/v1/repos/:owner/:name", Op::GetRepo, &[]), | |
| 31 | + | route("GET", "/user", Op::Whoami, &[]), | |
| 32 | + | route("POST", "/workspaces", Op::CreateWorkspace, &[]), | |
| 33 | + | route("GET", "/repos", Op::ListRepos, &[("q", "query")]), | |
| 34 | + | route("POST", "/repos", Op::CreateRepo, &[]), | |
| 35 | + | route("GET", "/repos/:owner/:name", Op::GetRepo, &[]), | |
| 36 | + | route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]), | |
| 37 | + | route( | |
| 38 | + | "GET", | |
| 39 | + | "/repos/:owner/:name/settings", | |
| 40 | + | Op::GetRepoSettings, | |
| 41 | + | &[], | |
| 42 | + | ), | |
| 43 | + | route( | |
| 44 | + | "PATCH", | |
| 45 | + | "/repos/:owner/:name/settings", | |
| 46 | + | Op::UpdateRepoSettings, | |
| 47 | + | &[], | |
| 48 | + | ), | |
| 49 | + | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 36 | 50 | route( | |
| 37 | 51 | "GET", | |
| 38 | − | "/v1/repos/:owner/:name/events", | |
| 52 | + | "/repos/:owner/:name/events", | |
| 39 | 53 | Op::ListEvents, | |
| 40 | 54 | &[("before", "before")], | |
| 41 | 55 | ), | |
| 42 | − | route("GET", "/v1/repos/:owner/:name/labels", Op::ListLabels, &[]), | |
| 56 | + | route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]), | |
| 43 | 57 | route( | |
| 44 | 58 | "GET", | |
| 45 | − | "/v1/repos/:owner/:name/issues", | |
| 59 | + | "/repos/:owner/:name/issues", | |
| 46 | 60 | Op::ListIssues, | |
| 47 | 61 | &[("state", "state"), ("label", "label")], | |
| 48 | 62 | ), | |
| 49 | − | route( | |
| 50 | − | "POST", | |
| 51 | − | "/v1/repos/:owner/:name/issues", | |
| 52 | − | Op::CreateIssue, | |
| 53 | − | &[], | |
| 54 | − | ), | |
| 63 | + | route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]), | |
| 55 | 64 | route( | |
| 56 | 65 | "GET", | |
| 57 | − | "/v1/repos/:owner/:name/issues/:number", | |
| 66 | + | "/repos/:owner/:name/issues/:number", | |
| 58 | 67 | Op::GetIssue, | |
| 59 | 68 | &[], | |
| 60 | 69 | ), | |
| 61 | 70 | route( | |
| 62 | 71 | "PATCH", | |
| 63 | − | "/v1/repos/:owner/:name/issues/:number", | |
| 72 | + | "/repos/:owner/:name/issues/:number", | |
| 64 | 73 | Op::UpdateIssue, | |
| 65 | 74 | &[], | |
| 66 | 75 | ), | |
| 67 | 76 | route( | |
| 68 | 77 | "POST", | |
| 69 | − | "/v1/repos/:owner/:name/issues/:number/close", | |
| 78 | + | "/repos/:owner/:name/issues/:number/close", | |
| 70 | 79 | Op::CloseIssue, | |
| 71 | 80 | &[], | |
| 72 | 81 | ), | |
| 73 | 82 | route( | |
| 74 | 83 | "POST", | |
| 75 | − | "/v1/repos/:owner/:name/issues/:number/reopen", | |
| 84 | + | "/repos/:owner/:name/issues/:number/reopen", | |
| 76 | 85 | Op::ReopenIssue, | |
| 77 | 86 | &[], | |
| 78 | 87 | ), | |
| 79 | 88 | route( | |
| 80 | 89 | "POST", | |
| 81 | − | "/v1/repos/:owner/:name/issues/:number/comments", | |
| 90 | + | "/repos/:owner/:name/issues/:number/assign", | |
| 91 | + | Op::AssignIssue, | |
| 92 | + | &[], | |
| 93 | + | ), | |
| 94 | + | route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]), | |
| 95 | + | route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]), | |
| 96 | + | route( | |
| 97 | + | "POST", | |
| 98 | + | "/repos/:owner/:name/plans/:plan/apply", | |
| 99 | + | Op::ApplyPlan, | |
| 100 | + | &[], | |
| 101 | + | ), | |
| 102 | + | route( | |
| 103 | + | "POST", | |
| 104 | + | "/repos/:owner/:name/issues/:number/comments", | |
| 82 | 105 | Op::AddComment, | |
| 83 | 106 | &[], | |
| 84 | 107 | ), | |
| 85 | 108 | route( | |
| 86 | 109 | "GET", | |
| 87 | − | "/v1/repos/:owner/:name/pulls", | |
| 110 | + | "/repos/:owner/:name/pulls", | |
| 88 | 111 | Op::ListPullRequests, | |
| 89 | 112 | &[("state", "state")], | |
| 90 | 113 | ), | |
| 91 | 114 | route( | |
| 92 | 115 | "POST", | |
| 93 | − | "/v1/repos/:owner/:name/pulls", | |
| 116 | + | "/repos/:owner/:name/pulls", | |
| 94 | 117 | Op::CreatePullRequest, | |
| 95 | 118 | &[], | |
| 96 | 119 | ), | |
| 97 | 120 | route( | |
| 98 | 121 | "GET", | |
| 99 | − | "/v1/repos/:owner/:name/pulls/:number", | |
| 122 | + | "/repos/:owner/:name/pulls/:number", | |
| 100 | 123 | Op::GetPullRequest, | |
| 101 | 124 | &[], | |
| 102 | 125 | ), | |
| 103 | 126 | route( | |
| 104 | 127 | "GET", | |
| 105 | − | "/v1/repos/:owner/:name/pulls/:number/changes", | |
| 128 | + | "/repos/:owner/:name/pulls/:number/changes", | |
| 106 | 129 | Op::GetPullRequestChanges, | |
| 107 | 130 | &[], | |
| 108 | 131 | ), | |
| 109 | 132 | route( | |
| 110 | 133 | "POST", | |
| 111 | − | "/v1/repos/:owner/:name/pulls/:number/reviews", | |
| 134 | + | "/repos/:owner/:name/pulls/:number/reviews", | |
| 112 | 135 | Op::ReviewPullRequest, | |
| 113 | 136 | &[], | |
| 114 | 137 | ), | |
| 115 | 138 | route( | |
| 116 | 139 | "GET", | |
| 117 | − | "/v1/repos/:owner/:name/pulls/:number/session", | |
| 140 | + | "/repos/:owner/:name/pulls/:number/session", | |
| 118 | 141 | Op::ReadSession, | |
| 119 | 142 | &[("after", "after")], | |
| 120 | 143 | ), | |
| 121 | 144 | route( | |
| 122 | 145 | "POST", | |
| 123 | − | "/v1/repos/:owner/:name/pulls/:number/session", | |
| 146 | + | "/repos/:owner/:name/pulls/:number/session", | |
| 124 | 147 | Op::RecordSession, | |
| 125 | 148 | &[], | |
| 126 | 149 | ), | |
| 127 | 150 | route( | |
| 128 | 151 | "POST", | |
| 129 | − | "/v1/repos/:owner/:name/pulls/:number/ready", | |
| 152 | + | "/repos/:owner/:name/pulls/:number/ready", | |
| 130 | 153 | Op::MarkPullRequestReady, | |
| 131 | 154 | &[], | |
| 132 | 155 | ), | |
| 133 | 156 | route( | |
| 134 | 157 | "POST", | |
| 135 | − | "/v1/repos/:owner/:name/pulls/:number/close", | |
| 158 | + | "/repos/:owner/:name/pulls/:number/close", | |
| 136 | 159 | Op::ClosePullRequest, | |
| 137 | 160 | &[], | |
| 138 | 161 | ), | |
| 139 | 162 | route( | |
| 140 | 163 | "POST", | |
| 141 | − | "/v1/repos/:owner/:name/pulls/:number/merge", | |
| 164 | + | "/repos/:owner/:name/pulls/:number/merge", | |
| 142 | 165 | Op::MergePullRequest, | |
| 143 | 166 | &[], | |
| 144 | 167 | ), | |
| 203 | 226 | if let (Some(owner), Some(name)) = (param("owner"), param("name")) { | |
| 204 | 227 | input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}"))); | |
| 205 | 228 | } | |
| 229 | + | if let Some(plan) = param("plan") { | |
| 230 | + | input.insert("plan".to_owned(), Value::String(plan.to_owned())); | |
| 231 | + | } | |
| 206 | 232 | if let Some(number) = param("number") { | |
| 207 | 233 | // Not a number: zero, which no issue or pull request has. | |
| 208 | 234 | input.insert( | |
| 223 | 249 | fn a_path_resolves_to_its_operation_and_input() { | |
| 224 | 250 | let (route, input) = resolve( | |
| 225 | 251 | "POST", | |
| 226 | − | "/v1/repos/syntaqx/hello/pulls/14/merge", | |
| 252 | + | "/repos/syntaqx/hello/pulls/14/merge", | |
| 227 | 253 | &[], | |
| 228 | 254 | json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }), | |
| 229 | 255 | ) | |
| 242 | 268 | ("q".to_owned(), "parser".to_owned()), | |
| 243 | 269 | ("x".to_owned(), "y".to_owned()), | |
| 244 | 270 | ]; | |
| 245 | − | let (route, input) = resolve("GET", "/v1/repos", &query, Value::Null).unwrap(); | |
| 271 | + | let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap(); | |
| 246 | 272 | assert_eq!(route.op, Op::ListRepos); | |
| 247 | 273 | assert_eq!(input, json!({ "query": "parser" })); | |
| 248 | 274 | } | |
| 249 | 275 | ||
| 250 | 276 | #[test] | |
| 251 | 277 | fn method_and_shape_must_match() { | |
| 252 | − | assert!(resolve("GET", "/v1/repos/a/b/issues/1/close", &[], Value::Null).is_none()); | |
| 253 | − | assert!(resolve("GET", "/v1/repos/a", &[], Value::Null).is_none()); | |
| 254 | − | assert!(resolve("GET", "/v1/repos/a/b/issues/1/extra", &[], Value::Null).is_none()); | |
| 255 | − | assert!(resolve("GET", "/v1/repos/a/b/", &[], Value::Null).is_some()); | |
| 278 | + | assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none()); | |
| 279 | + | assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none()); | |
| 280 | + | assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none()); | |
| 281 | + | assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some()); | |
| 256 | 282 | } | |
| 257 | 283 | ||
| 258 | 284 | #[test] |
| 16 | 16 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 17 | 17 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 18 | 18 | { "binding": "WORK", "service": "g1t-work" }, | |
| 19 | − | { "binding": "EVENTS", "service": "g1t-events" } | |
| 19 | + | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 20 | + | { "binding": "RUNNER", "service": "g1t-runner" }, | |
| 21 | + | { "binding": "BILLING", "service": "g1t-billing" } | |
| 20 | 22 | ], | |
| 21 | 23 | "observability": { "enabled": true } | |
| 22 | 24 | } |
| 86 | 86 | ||
| 87 | 87 | ```sh | |
| 88 | 88 | git push origin my-change | |
| 89 | − | curl -X POST https://api.g1t.sh/v1/repos/<workspace>/<repo>/pulls \ | |
| 89 | + | curl -X POST https://api.g1t.sh/repos/<workspace>/<repo>/pulls \ | |
| 90 | 90 | -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \ | |
| 91 | 91 | -d '{"branch": "my-change", "title": "My change", "issue": 12}' | |
| 92 | 92 | ``` |
| 77 | 77 | A pull request is normally opened **for an issue**. It can also stand alone, | |
| 78 | 78 | with its own title, for a change nobody filed an issue about. | |
| 79 | 79 | ||
| 80 | + | ## Assignees and reviewers | |
| 81 | + | ||
| 82 | + | An issue is assigned to people, to the g1t agent, or to both. A pull | |
| 83 | + | request has assignees too, and reviewers: the people, or the g1t agent, | |
| 84 | + | whose review was asked for. Each shows beside the conversation, with where | |
| 85 | + | every reviewer stands. | |
| 86 | + | ||
| 87 | + | Whatever happens is told in the conversation, in order, between the | |
| 88 | + | comments: who assigned whom, whose review was asked for, when it was marked | |
| 89 | + | ready, merged or closed, and each step g1t took by itself, such as sending | |
| 90 | + | an agent back to address a review. | |
| 91 | + | ||
| 80 | 92 | ## Several pull requests for one issue | |
| 81 | 93 | ||
| 82 | − | Put five agents on an issue and you get five pull requests, each in its own | |
| 83 | − | fork, each with its own session and its own diff. The issue's page lists | |
| 84 | − | them all with their status. | |
| 94 | + | An issue can have more than one pull request: a second attempt after the | |
| 95 | + | first fell short, or your own agent's alongside g1t's. Each is in its own | |
| 96 | + | fork, with its own session and its own diff. The issue's page lists them | |
| 97 | + | all with their status. | |
| 85 | 98 | ||
| 86 | 99 | When you merge one: | |
| 87 | 100 | ||
| 127 | 140 | on a single line of its change. Line comments are shown in the **Changes** | |
| 128 | 141 | tab under the line they are about. | |
| 129 | 142 | ||
| 143 | + | You can also ask a **g1t agent** to review. It reads the change in a sandbox | |
| 144 | + | of its own and posts comments on lines, a summary and a verdict, as | |
| 145 | + | `g1t-agent`. | |
| 146 | + | ||
| 130 | 147 | A reviewer can also give a verdict: **approve**, or **request changes**. | |
| 131 | 148 | The pull request shows where each reviewer stands. You cannot give a verdict | |
| 132 | 149 | on a pull request you opened, and that holds for agents too: one agent can | |
| 133 | 150 | review another's work, but not its own. | |
| 134 | 151 | ||
| 152 | + | ## Overlap | |
| 153 | + | ||
| 154 | + | When many changes are in flight, some touch the same files. g1t keeps track | |
| 155 | + | of which files each pull request changes, from every push, and shows on a | |
| 156 | + | pull request which others in progress change the same ones. | |
| 157 | + | ||
| 158 | + | Two pull requests for the *same* issue are expected to overlap: they are | |
| 159 | + | alternatives, and one will be merged. Two for *different* issues are heading | |
| 160 | + | for a conflict, and g1t says so while the work is still going on rather than | |
| 161 | + | when the second one tries to merge. Agents get the same list from | |
| 162 | + | `get_pull_request`, as `overlaps`. | |
| 163 | + | ||
| 164 | + | A g1t agent is told about the other work before it starts. Its instructions | |
| 165 | + | list every pull request in progress in the repository, what each is for and | |
| 166 | + | which files it changes, and ask it to keep its edits small and local where | |
| 167 | + | it has to touch the same files. It is told again when it is sent back to | |
| 168 | + | revise. The first entry in its session records what it was told, so you can | |
| 169 | + | see what it knew. | |
| 170 | + | ||
| 135 | 171 | ## Merging | |
| 136 | 172 | ||
| 137 | 173 | A member of the repository's workspace merges a pull request once it is | |
| 139 | 175 | ||
| 140 | 176 | A pull request can only merge if it contains everything already on `main`. | |
| 141 | 177 | If something else landed first, merging is refused and the pull request is | |
| 142 | − | **behind**. Its author pulls `main` into the fork or the branch, resolves any | |
| 143 | − | conflict, pushes, and merges again. `main` never loses a commit this way, however many | |
| 178 | + | **behind**. Its page says so before you try. | |
| 179 | + | ||
| 180 | + | **Catch up with main** fixes that. A g1t agent merges `main` into the pull | |
| 181 | + | request in a sandbox. If the merge is clean, it is pushed as it is. If it | |
| 182 | + | conflicts, the agent is given the conflicted files and what the pull request | |
| 183 | + | is for, resolves them, and pushes the result. Either way the session records | |
| 184 | + | what was done, and the checks run again on the result. You can also do it by | |
| 185 | + | hand: pull `main` into the fork or the branch, resolve, and push. `main` never loses a commit this way, however many | |
| 144 | 186 | pull requests are in flight. | |
| 145 | 187 | ||
| 188 | + | ## The merge queue | |
| 189 | + | ||
| 190 | + | Merging one pull request at a time, each caught up with `main`, keeps every | |
| 191 | + | merge clean as text. It does not prove the result works: two changes can | |
| 192 | + | merge without a conflict and still break each other. A repository that | |
| 193 | + | turns on **Merge through a queue** closes that gap. | |
| 194 | + | ||
| 195 | + | With the queue on, merging adds a pull request to the queue instead of | |
| 196 | + | changing `main`. g1t then tests up to four at a time, speculatively, each in | |
| 197 | + | its own sandbox and all at once: | |
| 198 | + | ||
| 199 | + | | Entry | Tested as | | |
| 200 | + | | --- | --- | | |
| 201 | + | | 1st | `main` + #41 | | |
| 202 | + | | 2nd | `main` + #41 + #44 | | |
| 203 | + | | 3rd | `main` + #41 + #44 + #46 | | |
| 204 | + | ||
| 205 | + | Each tested state runs the acceptance checks of every pull request in it, | |
| 206 | + | and the checks of the issues already completed: once an issue lands, its | |
| 207 | + | checks become part of what `main` promises, and every later change is held | |
| 208 | + | to them. A change that breaks something that landed before it is caught | |
| 209 | + | here, even when it merges without a conflict. A check that was already | |
| 210 | + | failing on `main` before the change is run on `main` alone to tell, and is | |
| 211 | + | not held against it. Entries land in | |
| 212 | + | order: `main` moves to an entry's tested state once it passed and | |
| 213 | + | everything ahead of it has landed. `main` only ever holds a state whose | |
| 214 | + | checks passed. | |
| 215 | + | ||
| 216 | + | An entry that fails, or does not merge cleanly with what is ahead of it, | |
| 217 | + | leaves the queue. Its pull request gets a failed check run showing the | |
| 218 | + | combination it failed in. A g1t agent's pull request is then sent back | |
| 219 | + | automatically, starting from the `main` it will land on, and joins the | |
| 220 | + | queue again once it passes. The entries behind it are tested again without | |
| 221 | + | it. | |
| 222 | + | ||
| 223 | + | The **Merge queue** page shows each entry, what it is being tested | |
| 224 | + | together with, and how that went. Agents read it through | |
| 225 | + | `get_merge_queue`. | |
| 226 | + | ||
| 146 | 227 | ## Sessions | |
| 147 | 228 | ||
| 148 | 229 | A session is the record of how a pull request was made: the prompt the agent | |
| 155 | 236 | Agents record their own session through the | |
| 156 | 237 | [`record_session`](/guides/bring-your-own-agent/) tool or the API. | |
| 157 | 238 | ||
| 239 | + | ## Why a line is the way it is | |
| 240 | + | ||
| 241 | + | Every file can be shown with **Blame**: beside each run of lines, the commit | |
| 242 | + | that last changed it. Pick a line and g1t shows why it is the way it is: | |
| 243 | + | ||
| 244 | + | - the commit that last changed it; | |
| 245 | + | - the pull request it arrived in, and who or what wrote it; | |
| 246 | + | - the issue that asked for it; | |
| 247 | + | - when an agent wrote it, the agent's own account of the change and the | |
| 248 | + | commands it ran, taken from its session. | |
| 249 | + | ||
| 250 | + | Blame follows every parent of a merge, so a line that came into a pull | |
| 251 | + | request when it caught up with `main` is credited to whoever wrote it on | |
| 252 | + | `main`, not to the merge. | |
| 253 | + | ||
| 254 | + | Every commit has a page of its own, `/<workspace>/<repo>/commit/<hash>`, | |
| 255 | + | with its diff, its parents and the pull request it arrived in. | |
| 256 | + | ||
| 158 | 257 | ## Events | |
| 159 | 258 | ||
| 160 | 259 | Every state change in g1t is published as an event: a push, an issue being | |
| 166 | 265 | ||
| 167 | 266 | g1t is under active development. These are designed but not available yet: | |
| 168 | 267 | ||
| 169 | − | - **Merging in g1t.** Merging moves `main` forward to the pull request's | |
| 170 | − | head. When `main` has moved, the pull request has to pull it in first; g1t | |
| 171 | − | does not create merge commits or rebase for you yet. | |
| 172 | − | - **Required reviews.** Verdicts are recorded and shown, but do not yet | |
| 173 | − | block a merge. | |
| 174 | − | - **Assignees and milestones.** | |
| 268 | + | - **Milestones.** | |
| 175 | 269 | - **g1t agents for everyone.** g1t can put its own agents on an issue, each | |
| 176 | 270 | in a sandbox. This is in preview and limited to selected accounts; anyone | |
| 177 | 271 | can bring their own agent today. |
| 29 | 29 | there is no separate notion of an organization. | |
| 30 | 30 | ||
| 31 | 31 | Your account does not own repositories itself. After confirming your email | |
| 32 | − | you create a workspace, which can have the same name as your username, and | |
| 33 | − | repositories go in it. You can belong to up to ten. | |
| 32 | + | the first thing you do is create a workspace, and repositories go in it. | |
| 33 | + | You can belong to up to ten. | |
| 34 | 34 | ||
| 35 | + | Usernames and workspaces share one set of names, so a name means the same | |
| 36 | + | thing wherever it appears. Your username is reserved for you: only you can | |
| 37 | + | create a workspace with that name, and nobody can register a username that | |
| 38 | + | is already a workspace. | |
| 39 | + | ||
| 35 | 40 | | Role | Can | | |
| 36 | 41 | | --- | --- | | |
| 37 | 42 | | Member | Create repositories, push, manage issues, merge pull requests. | | |
| 38 | − | | Owner | Everything a member can, and add or remove members. | | |
| 43 | + | | Owner | Everything a member can, and manage members, the workspace's access tokens and its details. | | |
| 39 | 44 | ||
| 40 | − | Manage members on the workspace's page, `g1t.sh/<workspace>`. | |
| 45 | + | A workspace's page, `g1t.sh/<workspace>`, shows its repositories and the | |
| 46 | + | pull requests in progress across them. Members also see **People** and | |
| 47 | + | **Access tokens** there, and owners **Settings**. | |
| 41 | 48 | ||
| 42 | 49 | ## Access tokens | |
| 43 | 50 | ||
| 56 | 63 | ||
| 57 | 64 | A token has the full rights of your account. Scoped tokens are planned. | |
| 58 | 65 | ||
| 66 | + | ### Workspace access tokens | |
| 67 | + | ||
| 68 | + | A workspace has access tokens of its own, for CI, integrations and agents | |
| 69 | + | that work for a team. They replace the shared "service account" other | |
| 70 | + | forges need: there is no extra account to create, pay for or lose the | |
| 71 | + | password to. | |
| 72 | + | ||
| 73 | + | | | Personal token | Workspace token | | |
| 74 | + | | --- | --- | --- | | |
| 75 | + | | Belongs to | You | The workspace | | |
| 76 | + | | Acts as | You | The workspace: its name is the author of what it does | | |
| 77 | + | | Can reach | Every workspace you belong to | That workspace only | | |
| 78 | + | | Can do | Everything you can | What a member can; it cannot manage people, tokens or workspaces | | |
| 79 | + | | When its creator leaves | Stops working | Keeps working | | |
| 80 | + | | Created by | You, in Settings | An owner, under **Access tokens** on the workspace's page | | |
| 81 | + | ||
| 82 | + | They are the same kind of token and are sent the same way. With git, any | |
| 83 | + | username works; the token is the password. `GET /user` answers with | |
| 84 | + | `"kind": "workspace"` for one, and `"kind": "user"` for a personal token. | |
| 85 | + | ||
| 86 | + | Every member can see a workspace's tokens: the name, who created each and | |
| 87 | + | when it was last used. Only owners can create or delete them. | |
| 88 | + | ||
| 59 | 89 | ## Signing in with OAuth | |
| 60 | 90 | ||
| 61 | 91 | Applications that can open your browser, such as an agent connecting to the | |
| 102 | 132 | ||
| 103 | 133 | ```sh | |
| 104 | 134 | # 1. The tool starts a sign-in. | |
| 105 | − | curl -X POST https://api.g1t.sh/v1/device/code -H "Content-Type: application/json" -d '{"client_name": "my-tool"}' | |
| 135 | + | curl -X POST https://api.g1t.sh/device/code -H "Content-Type: application/json" -d '{"client_name": "my-tool"}' | |
| 106 | 136 | ||
| 107 | 137 | # 2. You open verification_uri_complete from the response and approve. | |
| 108 | 138 | ||
| 109 | 139 | # 3. The tool polls, no faster than "interval" seconds, until it is approved. | |
| 110 | − | curl -X POST https://api.g1t.sh/v1/device/token -H "Content-Type: application/json" -d '{"device_code": "…"}' | |
| 140 | + | curl -X POST https://api.g1t.sh/device/token -H "Content-Type: application/json" -d '{"device_code": "…"}' | |
| 111 | 141 | ``` | |
| 112 | 142 | ||
| 113 | 143 | The poll answers with a `status` of `pending`, `approved`, `denied` or |
| 64 | 64 | | `list_repos` | Repositories you can see, optionally filtered by a query. | | |
| 65 | 65 | | `get_repo` | One repository's details. | | |
| 66 | 66 | | `create_repo` | Create a repository in one of your workspaces. | | |
| 67 | + | | `update_repo` | Change its description or visibility, or protect its default branch. | | |
| 68 | + | | `get_repo_settings` | How a repository handles pull requests. | | |
| 69 | + | | `update_repo_settings` | Change the approvals a merge needs and how g1t's agents are reviewed and merged. | | |
| 67 | 70 | | `list_issues` | Issues on a repository, by state and label. | | |
| 68 | 71 | | `get_issue` | An issue with its comments and every pull request made for it. | | |
| 69 | 72 | | `create_issue` | Open an issue, with labels and acceptance checks. | | |
| 70 | 73 | | `update_issue` | Change an issue's title, description or labels. | | |
| 71 | 74 | | `close_issue` | Close an issue as completed or not planned. | | |
| 72 | 75 | | `reopen_issue` | Reopen a closed issue. | | |
| 76 | + | | `plan_work` | Have an agent read the repository and turn an outcome into issues with their dependencies. | | |
| 77 | + | | `get_plan` | Read a plan and what it proposes. | | |
| 78 | + | | `apply_plan` | Open a plan's issues and, optionally, put g1t agents on them in dependency order. | | |
| 79 | + | | `assign_issue` | Assign an issue to the g1t agent, which opens a pull request and sees it through. | | |
| 73 | 80 | | `list_labels` | The labels in use on a repository. | | |
| 74 | 81 | | `add_comment` | Comment on an issue or a pull request, or on one line of a pull request's change. | | |
| 75 | 82 | | `review_pull_request` | Approve a pull request or request changes. | | |
| 84 | 91 | | `merge_pull_request` | Land a pull request on `main` and resolve its issue. Workspace members only. | | |
| 85 | 92 | | `list_events` | A repository's timeline, newest first. | | |
| 86 | 93 | ||
| 94 | + | ## Staying out of each other's way | |
| 95 | + | ||
| 96 | + | `get_pull_request` returns `overlaps`: other pull requests in progress that | |
| 97 | + | change files this one changes, with the paths. An agent should look before | |
| 98 | + | it goes far. An overlap with a pull request for a different issue will | |
| 99 | + | become a conflict for whichever merges second, so it is worth narrowing the | |
| 100 | + | change, or saying so in the pull request. | |
| 101 | + | ||
| 102 | + | It also returns `behind`: whether `main` has moved since the pull request | |
| 103 | + | was made. If it has, pull `main` into the fork and push before asking for a | |
| 104 | + | merge. | |
| 105 | + | ||
| 87 | 106 | ## Reviewing as an agent | |
| 88 | 107 | ||
| 89 | 108 | An agent can review as well as write. Given an issue with several pull | |
| 97 | 116 | ||
| 98 | 117 | Anything that holds an access token can open issues: an error tracker, a | |
| 99 | 118 | monitor, a script. Call `create_issue`, or `POST | |
| 100 | − | /v1/repos/{owner}/{name}/issues`, with a title, a description and labels | |
| 119 | + | /repos/{owner}/{name}/issues`, with a title, a description and labels | |
| 101 | 120 | such as `bug`. The issue is attributed to the account the token belongs to. | |
| 102 | 121 | ||
| 103 | 122 | ## Session entries |
| 3 | 3 | description: Have g1t's own agents work on an issue. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | g1t can do the work itself. On an open issue, **Assign g1t agents** starts | |
| 7 | − | up to five agents at once. Each opens its own pull request for the issue, | |
| 8 | − | works in its own sandbox and its own fork, and reports back as it goes. | |
| 6 | + | g1t can do the work itself. Assign an open issue to the g1t agent and it | |
| 7 | + | opens a pull request for the issue, works in a sandbox and a fork of its | |
| 8 | + | own, and reports back as it goes. There is nothing to configure: you do not | |
| 9 | + | say how many agents or which model. Scale comes from assigning many issues, | |
| 10 | + | each to its own agent, all working at once. | |
| 9 | 11 | ||
| 10 | − | This is in preview and limited to selected accounts. Everyone can | |
| 11 | − | [bring their own agent](/guides/bring-your-own-agent/) today. | |
| 12 | + | g1t agents are paid for by the workspace they work for; see | |
| 13 | + | [what it costs](#what-it-costs). Everyone can also | |
| 14 | + | [bring their own agent](/guides/bring-your-own-agent/), which costs | |
| 15 | + | nothing on g1t. | |
| 12 | 16 | ||
| 13 | 17 | ## Assigning agents | |
| 14 | 18 | ||
| 19 | + | One issue: | |
| 20 | + | ||
| 15 | 21 | 1. Open an issue on a repository. | |
| 16 | − | 2. In **Assign g1t agents**, choose how many agents to put on it and which | |
| 17 | − | model they use. | |
| 18 | − | 3. Optionally add guidance for this run, on top of the issue's description. | |
| 19 | − | 4. Choose **Start**. | |
| 22 | + | 2. In **Assign to g1t agent**, optionally add guidance for this run, on top | |
| 23 | + | of the issue's description. | |
| 24 | + | 3. Choose **Assign**. | |
| 20 | 25 | ||
| 21 | − | Each agent appears as a draft pull request on the issue within a few | |
| 22 | − | seconds. The page updates on its own while they work. | |
| 26 | + | Many issues: | |
| 23 | 27 | ||
| 28 | + | 1. Open the repository's **Issues** tab. | |
| 29 | + | 2. Tick the issues to hand over, up to ten at a time. | |
| 30 | + | 3. Choose **Assign to g1t agent**. | |
| 31 | + | ||
| 32 | + | From the API, an agent of your own, or a script: | |
| 33 | + | ||
| 34 | + | ```sh | |
| 35 | + | curl -X POST https://api.g1t.sh/repos/<workspace>/<repo>/issues/12/assign \ | |
| 36 | + | -H "Authorization: Bearer $G1T_TOKEN" | |
| 37 | + | ``` | |
| 38 | + | ||
| 39 | + | The same thing is the `assign_issue` tool on the MCP server, so an agent | |
| 40 | + | planning work can hand issues to g1t agents itself. | |
| 41 | + | ||
| 42 | + | Each agent appears as a draft pull request on its issue within a few | |
| 43 | + | seconds. The pages update on their own while they work. | |
| 44 | + | ||
| 45 | + | An issue can still have more than one pull request: assign it again, or | |
| 46 | + | have your own agent open one alongside. That is for when you want a second | |
| 47 | + | attempt, not the normal way of working. | |
| 48 | + | ||
| 24 | 49 | ## What an agent does | |
| 25 | 50 | ||
| 26 | 51 | 1. Clones its pull request's fork. | |
| 27 | − | 2. Reads the code and makes the change the issue asks for. | |
| 28 | − | 3. Commits its work. | |
| 29 | − | 4. Pushes to the fork and marks the pull request ready for review, with a | |
| 52 | + | 2. Is told what else is in progress: every other open pull request in the | |
| 53 | + | repository, what it is for and which files it changes. Its session | |
| 54 | + | starts with a note of what it was told. | |
| 55 | + | 3. Reads the code and makes the change the issue asks for, keeping clear of | |
| 56 | + | the other work where it can. | |
| 57 | + | 4. Commits its work. | |
| 58 | + | 5. Pushes to the fork and marks the pull request ready for review, with a | |
| 30 | 59 | summary as its description. | |
| 31 | 60 | ||
| 32 | 61 | Everything it reads, runs and decides is recorded in the pull request's | |
| 33 | 62 | **Session** as it happens. The **Changes** tab shows the resulting diff. | |
| 34 | 63 | ||
| 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 | − | ||
| 39 | 64 | If an agent fails, or finishes without changing anything, its pull request | |
| 40 | 65 | is closed and its session says why. | |
| 41 | 66 | ||
| 67 | + | ## Seeing it through | |
| 68 | + | ||
| 69 | + | Making the change is the first step. g1t takes the rest itself, and you | |
| 70 | + | get the pull request back ready to merge: | |
| 71 | + | ||
| 72 | + | 1. **Checks.** The issue's | |
| 73 | + | [acceptance checks](/concepts/overview/#acceptance-checks) run against | |
| 74 | + | the change in a separate, clean sandbox. The agent has no say in the | |
| 75 | + | result. | |
| 76 | + | 2. **Review.** A different agent reads the change and posts comments on | |
| 77 | + | lines, a summary and a verdict. | |
| 78 | + | 3. **Revision.** If the checks fail or the review asks for changes, the | |
| 79 | + | author is sent back with exactly what was found, and steps 1 and 2 run | |
| 80 | + | again on the result. This happens at most twice. | |
| 81 | + | 4. **Ready to merge.** Checks passed and approved. Merging is yours, | |
| 82 | + | unless the repository says otherwise (below). | |
| 83 | + | ||
| 84 | + | If `main` has moved in the meantime, that does not hold the pull request | |
| 85 | + | up. Merging it brings it up to date first: g1t merges `main` in, an agent | |
| 86 | + | resolves any conflict, and it lands. A repository that wants every pull | |
| 87 | + | request caught up and checked again before it may merge turns on **Require | |
| 88 | + | pull requests to be up to date before merging** in its settings; catching | |
| 89 | + | up is then a step of its own, before "ready". | |
| 90 | + | ||
| 91 | + | The pull request's page shows which step it is at. If g1t cannot finish, | |
| 92 | + | because the checks still fail after two revisions, a review could not be | |
| 93 | + | written, or a conflict could not be resolved while bringing it up to date, | |
| 94 | + | it stops and the page says | |
| 95 | + | **Needs you**, with the reason. Pushing to the pull request yourself starts | |
| 96 | + | it moving again. | |
| 97 | + | ||
| 98 | + | ### What a repository can ask for | |
| 99 | + | ||
| 100 | + | Under a repository's **Settings** tab, a member of its workspace sets the | |
| 101 | + | rules its pull requests follow: | |
| 102 | + | ||
| 103 | + | | Setting | Default | What it does | | |
| 104 | + | | --- | --- | --- | | |
| 105 | + | | Require a pull request to change `main` | Off | Refuses pushes to the default branch. | | |
| 106 | + | | Required approvals | None | How many reviewers must approve before a merge. A reviewer who asked for changes blocks it. | | |
| 107 | + | | A g1t agent's approval counts | On | Off means approvals have to come from people. | | |
| 108 | + | | Require acceptance checks to pass | Off | On means nobody can merge with failed checks. | | |
| 109 | + | | Require pull requests to be up to date | Off | On means catching up is a step of its own and the checks run again. | | |
| 110 | + | | Review by a second agent | On | Off leaves review to people. | | |
| 111 | + | | Revisions before asking you | 2 | How often an agent is sent back before g1t stops. | | |
| 112 | + | | Merge automatically when ready | Off | Lands a g1t agent's pull request once every rule is met. | | |
| 113 | + | | Merge through a queue | Off | Merging tests a pull request together with those ahead of it; `main` only moves to a combination that passed. See [the merge queue](/concepts/overview/#the-merge-queue). | | |
| 114 | + | ||
| 115 | + | A g1t agent's pull request follows the same rules as anyone's. If the | |
| 116 | + | repository wants approvals from people, it waits for them, and shows | |
| 117 | + | **Needs you** until they arrive. | |
| 118 | + | ||
| 119 | + | ### Asking the agent for changes | |
| 120 | + | ||
| 121 | + | Review a g1t agent's pull request the way you would anyone's: comment on | |
| 122 | + | lines, then submit **Request changes** with what you want. The agent is | |
| 123 | + | sent back with your review, your comments on lines included, makes the | |
| 124 | + | changes, and the checks and review run again on the result. You do not | |
| 125 | + | need to reassign anything. Each time counts towards **Revisions before | |
| 126 | + | asking you**; past that, g1t stops and the page says so. | |
| 127 | + | ||
| 128 | + | ### Merging automatically | |
| 129 | + | ||
| 130 | + | A repository can land a g1t agent's pull request by itself once it is | |
| 131 | + | ready. A member of the workspace turns this on under the repository's | |
| 132 | + | **Settings** tab; it is off to begin with. The merge is recorded as made by | |
| 133 | + | `g1t`, the issue closes naming the pull request, and nothing short of | |
| 134 | + | ready is ever merged this way. One that is behind `main` is brought up to | |
| 135 | + | date as part of the merge. Pull requests from people and from other | |
| 136 | + | agents always wait for a member. | |
| 137 | + | ||
| 138 | + | Every step is recorded: revisions and catch-ups in the pull request's | |
| 139 | + | **Session**, reviews in its conversation. | |
| 140 | + | ||
| 141 | + | This applies to pull requests made by g1t agents. One you or your own | |
| 142 | + | agent opened is yours to drive; the same checks run on it, and you can ask | |
| 143 | + | for a review or a catch-up from its page. | |
| 144 | + | ||
| 42 | 145 | ## Choosing between pull requests | |
| 43 | 146 | ||
| 44 | 147 | Each pull request on the issue's page shows whether its checks passed. Open | |
| 48 | 151 | the issue close as superseded. See | |
| 49 | 152 | [merging](/concepts/overview/#merging) for what happens when `main` has moved. | |
| 50 | 153 | ||
| 51 | − | ## Choosing a model | |
| 154 | + | ## Other things g1t agents do | |
| 52 | 155 | ||
| 53 | − | When you assign agents you pick how much model to spend: | |
| 156 | + | - **Review.** On a pull request that is ready, **Review by a g1t agent** | |
| 157 | + | has an agent read the change and post comments on lines, a summary and a | |
| 158 | + | verdict. | |
| 159 | + | - **Catch up.** When `main` has moved under a pull request, **Catch up with | |
| 160 | + | main** has an agent merge it in and resolve any conflict. | |
| 54 | 161 | ||
| 55 | − | | Choice | Model today | Use it for | | |
| 56 | − | | --- | --- | --- | | |
| 57 | − | | **Balanced** | Claude Sonnet 5.5 | Most tasks. The default. | | |
| 58 | − | | **Deep** | Claude Opus 5.5 | Hard problems that need the strongest reasoning. Slower and costlier. | | |
| 59 | − | | **Fast** | Claude Haiku 4.5 | Small, well-defined changes. | | |
| 162 | + | Both run in sandboxes of their own. | |
| 60 | 163 | ||
| 61 | − | The menu always shows which model each choice runs on, and every pull | |
| 62 | − | request's session opens with a note naming the model that produced it. You | |
| 63 | − | are paying for model usage, so you can always see what you are getting. | |
| 164 | + | ## Which model runs | |
| 64 | 165 | ||
| 65 | − | The choices keep their names when the model behind one is upgraded, so | |
| 66 | − | automations that say "Balanced" keep working. A pull request made by a g1t | |
| 67 | − | agent carries the label `g1t-agent`, and its commits are authored by | |
| 68 | − | `g1t agent`. | |
| 166 | + | You do not pick one. You assign the work to `g1t-agent`, the way you would | |
| 167 | + | assign an issue to a colleague, and g1t routes it. The kind of work decides: | |
| 168 | + | ||
| 169 | + | | Work | Model today | | |
| 170 | + | | --- | --- | | |
| 171 | + | | Making a change for an issue | Claude Sonnet 5.5 | | |
| 172 | + | | Reviewing a pull request | Claude Sonnet 5.5 | | |
| 173 | + | | Catching up with `main` and resolving conflicts | Claude Sonnet 5.5 | | |
| 174 | + | ||
| 175 | + | Every session opens with a note naming the model that ran, and an agent's | |
| 176 | + | review says which model wrote it, so what you got is always on the record. | |
| 177 | + | When a better model for a kind of work appears, g1t changes the route and | |
| 178 | + | nothing you have set up needs to change. | |
| 69 | 179 | ||
| 180 | + | A pull request made by a g1t agent carries the label `g1t-agent`, and its | |
| 181 | + | commits are authored by `g1t agent`. | |
| 182 | + | ||
| 70 | 183 | ## How model traffic is routed | |
| 71 | 184 | ||
| 72 | − | g1t agents can send every model request through | |
| 185 | + | g1t agents send model requests through | |
| 73 | 186 | [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/). The | |
| 74 | 187 | gateway is where an operator sees each request, caps spend, caches, and | |
| 75 | − | sets a fallback to another provider if one is down, without changing | |
| 76 | − | anything in g1t. | |
| 188 | + | holds the provider's key so that no sandbox does. Each request is tagged | |
| 189 | + | with the kind of work, the repository and the pull request, so spend can be | |
| 190 | + | read per pull request. | |
| 77 | 191 | ||
| 78 | − | If you run your own copy of g1t, two settings on the runner control this: | |
| 192 | + | If you run your own copy of g1t, these settings on the runner control it: | |
| 79 | 193 | ||
| 80 | 194 | | Setting | What it does | | |
| 81 | 195 | | --- | --- | | |
| 82 | − | | `AGENT_MODELS` | The choices offered, in order, each mapped to a provider's model. | | |
| 196 | + | | `AGENT_ROUTES` | The model for each kind of work: `implement`, `review` and `update`. | | |
| 83 | 197 | | `AI_GATEWAY_ID` | The gateway to route through. Empty sends requests to the provider directly. | | |
| 198 | + | | `AI_GATEWAY_TOKEN` | Secret. Authenticates to the gateway. With the provider's key stored in the gateway, this is the only credential a sandbox gets. | | |
| 199 | + | | `ANTHROPIC_API_KEY` | Secret. The provider's key, if the gateway does not hold it. | | |
| 200 | + | ||
| 201 | + | ## What it costs | |
| 202 | + | ||
| 203 | + | A workspace pays for the g1t agents that work on its repositories, from | |
| 204 | + | credit it buys in advance. | |
| 205 | + | ||
| 206 | + | - An owner adds credit by card under **Billing** on the workspace's page. | |
| 207 | + | - Each run is charged when it finishes: what the model cost, plus 20%. A | |
| 208 | + | change, a review, a revision and a catch-up that needed an agent are each | |
| 209 | + | a run. Acceptance checks are free. | |
| 210 | + | - The charge goes to the workspace that owns the repository, whoever | |
| 211 | + | assigned the issue, so only its members can put agents to work there. | |
| 212 | + | - With no credit, agents do not start, and assigning an issue says so. | |
| 213 | + | Runs already under way finish, so a balance can dip slightly below zero. | |
| 214 | + | - The statement on the Billing page lists every run with the pull request | |
| 215 | + | it was for, and each pull request's session ends with what its run cost | |
| 216 | + | before the margin. | |
| 217 | + | ||
| 218 | + | There is no subscription and no seat price. A small change costs a few | |
| 219 | + | cents. | |
| 84 | 220 | ||
| 85 | 221 | ## What a sandbox has | |
| 86 | 222 |
| 41 | 41 | ||
| 42 | 42 | ## Private repositories | |
| 43 | 43 | ||
| 44 | + | ## Protected branches | |
| 45 | + | ||
| 46 | + | A repository can protect its default branch under **Settings**. Pushing to | |
| 47 | + | it is then refused, for members and agents alike, and git says why: | |
| 48 | + | ||
| 49 | + | ``` | |
| 50 | + | ! [remote rejected] main -> main (main is protected: push a branch and open a pull request) | |
| 51 | + | ``` | |
| 52 | + | ||
| 53 | + | Changes reach a protected branch only by merging a pull request. The first | |
| 54 | + | push to an empty repository is still allowed. | |
| 55 | + | ||
| 44 | 56 | A private repository is visible only to members of its workspace. To everyone | |
| 45 | 57 | else it looks | |
| 46 | 58 | exactly like a repository that does not exist, both on the site and to git. |
| 43 | 43 | git push -u g1t main | |
| 44 | 44 | ``` | |
| 45 | 45 | ||
| 46 | − | You can also create an empty repository from the **+** button in the header, | |
| 47 | − | and choose its workspace and whether it is public or private. | |
| 46 | + | You can also create a repository from the **+** button in the header: | |
| 47 | + | empty, or as a copy of a public repository on GitHub or any other git host. | |
| 48 | + | Paste its address under **Import from**. | |
| 48 | 49 | ||
| 49 | 50 | ## 4. Open an issue | |
| 50 | 51 |
| 24 | 24 | ```json | |
| 25 | 25 | { | |
| 26 | 26 | "documentation_url": "https://docs.g1t.sh/api/reference/", | |
| 27 | − | "current_user_url": "https://api.g1t.sh/v1/user", | |
| 28 | − | "repository_url": "https://api.g1t.sh/v1/repos/{owner}/{name}", | |
| 29 | − | "issues_url": "https://api.g1t.sh/v1/repos/{owner}/{name}/issues{?state,label}", | |
| 30 | − | "pulls_url": "https://api.g1t.sh/v1/repos/{owner}/{name}/pulls{?state}" | |
| 27 | + | "current_user_url": "https://api.g1t.sh/user", | |
| 28 | + | "repository_url": "https://api.g1t.sh/repos/{owner}/{name}", | |
| 29 | + | "issues_url": "https://api.g1t.sh/repos/{owner}/{name}/issues{?state,label}", | |
| 30 | + | "pulls_url": "https://api.g1t.sh/repos/{owner}/{name}/pulls{?state}" | |
| 31 | 31 | } | |
| 32 | 32 | ``` | |
| 33 | 33 | ||
| 37 | 37 | [access token](https://g1t.sh/settings) as a bearer token: | |
| 38 | 38 | ||
| 39 | 39 | ```sh | |
| 40 | − | curl https://api.g1t.sh/v1/user \ | |
| 40 | + | curl https://api.g1t.sh/user \ | |
| 41 | 41 | -H "Authorization: Bearer $G1T_TOKEN" | |
| 42 | 42 | ``` | |
| 43 | 43 | ||
| 51 | 51 | ||
| 52 | 52 | | Method | Path | | | |
| 53 | 53 | | --- | --- | --- | | |
| 54 | − | | `POST` | `/v1/device/code` | Start a sign-in. Body: `client_name`. | | |
| 55 | − | | `POST` | `/v1/device/token` | Ask whether it was approved. Body: `device_code`. | | |
| 54 | + | | `POST` | `/device/code` | Start a sign-in. Body: `client_name`. | | |
| 55 | + | | `POST` | `/device/token` | Ask whether it was approved. Body: `device_code`. | | |
| 56 | 56 | ||
| 57 | 57 | Applications that can open a browser use OAuth instead. See | |
| 58 | 58 | [signing in with OAuth](/guides/authentication/#signing-in-with-oauth). | |
| 88 | 88 | ||
| 89 | 89 | | Method | Path | | | |
| 90 | 90 | | --- | --- | --- | | |
| 91 | − | | `GET` | `/v1/user` | The account the token belongs to, and its workspaces. | | |
| 92 | − | | `POST` | `/v1/workspaces` | Create a workspace. Body: `slug`, `name`. | | |
| 93 | − | | `GET` | `/v1/repos?q=` | Repositories you can see. | | |
| 94 | − | | `POST` | `/v1/repos` | Create one. Body: `workspace`, `name`, `description`, `private`. | | |
| 95 | − | | `GET` | `/v1/repos/{owner}/{name}` | One repository. | | |
| 96 | − | | `GET` | `/v1/repos/{owner}/{name}/events?before=` | Its timeline, newest first. | | |
| 91 | + | | `GET` | `/user` | Who the token acts as, and the workspaces it can work in. `kind` is `user`, or `workspace` for a [workspace's own token](/guides/authentication/#workspace-access-tokens). | | |
| 92 | + | | `POST` | `/workspaces` | Create a workspace. Body: `slug`, `name`. | | |
| 93 | + | | `GET` | `/repos?q=` | Repositories you can see. | | |
| 94 | + | | `POST` | `/repos` | Create one. Body: `workspace`, `name`, `description`, `private`, and `import_url` to copy a public repository's default branch. | | |
| 95 | + | | `GET` | `/repos/{owner}/{name}` | One repository. | | |
| 96 | + | | `PATCH` | `/repos/{owner}/{name}` | Change it. Body: `description`, `private`, and `protected` to refuse pushes to the default branch. Members only. | | |
| 97 | + | | `GET` | `/repos/{owner}/{name}/settings` | How it handles pull requests. | | |
| 98 | + | | `PATCH` | `/repos/{owner}/{name}/settings` | Change that. Body, all optional: `required_approvals`, `count_agent_approvals`, `allow_ignoring_checks`, `require_up_to_date`, `agent_review`, `max_revisions`, `auto_merge`. Members only. | | |
| 99 | + | | `GET` | `/repos/{owner}/{name}/events?before=` | Its timeline, newest first. | | |
| 97 | 100 | ||
| 98 | 101 | ## Issues | |
| 99 | 102 | ||
| 101 | 104 | ||
| 102 | 105 | | Method | Path | | | |
| 103 | 106 | | --- | --- | --- | | |
| 104 | − | | `GET` | `/v1/repos/{owner}/{name}/issues?state=&label=` | Issues, newest first. `state` is `open` or `closed`. | | |
| 105 | − | | `POST` | `/v1/repos/{owner}/{name}/issues` | Open one. Body: `title`, `body`, `labels`, `checks`. | | |
| 106 | − | | `GET` | `/v1/repos/{owner}/{name}/issues/{number}` | An issue, its comments and its pull requests. | | |
| 107 | − | | `PATCH` | `/v1/repos/{owner}/{name}/issues/{number}` | Change `title`, `body` or `labels`. | | |
| 108 | − | | `POST` | `/v1/repos/{owner}/{name}/issues/{number}/close` | Close. Body: `reason`, `completed` or `not_planned`. | | |
| 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, and then `path` and `line` put the comment on a line of its change. | | |
| 111 | − | | `GET` | `/v1/repos/{owner}/{name}/labels` | The labels in use. | | |
| 107 | + | | `GET` | `/repos/{owner}/{name}/issues?state=&label=` | Issues, newest first. `state` is `open` or `closed`. | | |
| 108 | + | | `POST` | `/repos/{owner}/{name}/issues` | Open one. Body: `title`, `body`, `labels`, `checks`. | | |
| 109 | + | | `GET` | `/repos/{owner}/{name}/issues/{number}` | An issue, its comments and its pull requests. | | |
| 110 | + | | `PATCH` | `/repos/{owner}/{name}/issues/{number}` | Change `title`, `body` or `labels`. | | |
| 111 | + | | `POST` | `/repos/{owner}/{name}/issues/{number}/close` | Close. Body: `reason`, `completed` or `not_planned`. | | |
| 112 | + | | `POST` | `/repos/{owner}/{name}/issues/{number}/reopen` | Reopen. | | |
| 113 | + | | `POST` | `/repos/{owner}/{name}/plans` | Turn an outcome into a plan. Body: `brief`. Returns `planId`; the plan takes a minute or two to write. Members only. | | |
| 114 | + | | `GET` | `/repos/{owner}/{name}/plans/{plan}` | The plan: its `status` and the issues it proposes. | | |
| 115 | + | | `POST` | `/repos/{owner}/{name}/plans/{plan}/apply` | Open its issues. Body: `assign` to put g1t agents on them in dependency order, `keep` to open only some, by position from 1. | | |
| 116 | + | | `POST` | `/repos/{owner}/{name}/issues/{number}/assign` | Assign it to the [g1t agent](/guides/g1t-agents/), which opens a pull request and sees it through. Body: `instructions` (optional). Returns the pull request. Preview: enabled accounts only. | | |
| 117 | + | | `POST` | `/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. | | |
| 118 | + | | `GET` | `/repos/{owner}/{name}/labels` | The labels in use. | | |
| 112 | 119 | ||
| 113 | 120 | ```sh | |
| 114 | − | curl -X POST https://api.g1t.sh/v1/repos/syntaqx/hello/issues \ | |
| 121 | + | curl -X POST https://api.g1t.sh/repos/syntaqx/hello/issues \ | |
| 115 | 122 | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 116 | 123 | -H "Content-Type: application/json" \ | |
| 117 | 124 | -d '{ | |
| 140 | 147 | ||
| 141 | 148 | | Method | Path | | | |
| 142 | 149 | | --- | --- | --- | | |
| 143 | − | | `GET` | `/v1/repos/{owner}/{name}/pulls?state=` | Pull requests, newest first. | | |
| 144 | − | | `POST` | `/v1/repos/{owner}/{name}/pulls` | Open one. Body: `issue`, `title`, `agent`, and for a branch `branch`, `body`. | | |
| 145 | − | | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}` | A pull request, its comments and its issue. | | |
| 146 | − | | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}/changes` | The files it changes, with diffs. | | |
| 147 | − | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/ready` | Mark ready for review. Body: `summary`. | | |
| 148 | − | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/close` | Close without merging. | | |
| 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 | + | | `GET` | `/repos/{owner}/{name}/pulls?state=` | Pull requests, newest first. | | |
| 151 | + | | `POST` | `/repos/{owner}/{name}/pulls` | Open one. Body: `issue`, `title`, `agent`, and for a branch `branch`, `body`. | | |
| 152 | + | | `GET` | `/repos/{owner}/{name}/pulls/{number}` | A pull request, its comments and its issue. | | |
| 153 | + | | `GET` | `/repos/{owner}/{name}/pulls/{number}/changes` | The files it changes, with diffs. | | |
| 154 | + | | `POST` | `/repos/{owner}/{name}/pulls/{number}/ready` | Mark ready for review. Body: `summary`. | | |
| 155 | + | | `POST` | `/repos/{owner}/{name}/pulls/{number}/close` | Close without merging. | | |
| 156 | + | | `POST` | `/repos/{owner}/{name}/pulls/{number}/reviews` | Give a verdict. Body: `verdict` (`approve` or `request_changes`), `body`. Not on your own pull request. | | |
| 157 | + | | `POST` | `/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 or its checks have not passed. If `main` has moved, the pull request is brought up to date first and lands when that is done: the response is the pull request, still open, and `landing` is true on it until then. A repository that requires pull requests to be up to date answers `409` instead. | | |
| 151 | 158 | ||
| 152 | 159 | Opening a pull request returns the git remote of its fork: | |
| 153 | 160 | ||
| 177 | 184 | that are still a draft or open are closed with `supersededBy` set. Send | |
| 178 | 185 | `"keep_issue_open": true` to merge without any of that. | |
| 179 | 186 | ||
| 187 | + | Fetching one pull request also returns: | |
| 188 | + | ||
| 189 | + | | Field | | | |
| 190 | + | | --- | --- | | |
| 191 | + | | `pull.files` | The files it changes, with lines added and removed. | | |
| 192 | + | | `overlaps` | Other pull requests in progress changing the same files. | | |
| 193 | + | | `behind` | Whether `main` has moved since it was made. | | |
| 194 | + | | `checks` | The latest run of the acceptance checks. | | |
| 195 | + | | `comments` | Comments and reviews, with `path`, `line` and `verdict`. | | |
| 196 | + | ||
| 180 | 197 | ### Checks | |
| 181 | 198 | ||
| 182 | 199 | A pull request carries `checkStatus`: `queued`, `running`, `passed`, `failed`, | |
| 203 | 220 | ||
| 204 | 221 | | Method | Path | | | |
| 205 | 222 | | --- | --- | --- | | |
| 206 | − | | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}/session?after=` | Entries after a sequence number. | | |
| 207 | − | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/session` | Append. Body: `entries`. | | |
| 223 | + | | `GET` | `/repos/{owner}/{name}/pulls/{number}/session?after=` | Entries after a sequence number. | | |
| 224 | + | | `POST` | `/repos/{owner}/{name}/pulls/{number}/session` | Append. Body: `entries`. | | |
| 208 | 225 | ||
| 209 | 226 | ```sh | |
| 210 | − | curl -X POST https://api.g1t.sh/v1/repos/syntaqx/hello/pulls/14/session \ | |
| 227 | + | curl -X POST https://api.g1t.sh/repos/syntaqx/hello/pulls/14/session \ | |
| 211 | 228 | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 212 | 229 | -H "Content-Type: application/json" \ | |
| 213 | 230 | -d '{"entries": [{"kind": "message", "text": "Reading src/main.rs."}]}' | |
| 223 | 240 | `pr_01jb2k7x9hfq0b3zj0f5s2m8ra`. They sort by creation time. | |
| 224 | 241 | ||
| 225 | 242 | Times are RFC 3339 in UTC, such as `2026-10-01T18:04:11.482Z`. | |
| 243 | + | ||
| 244 | + | ## Field names | |
| 245 | + | ||
| 246 | + | Responses use `camelCase`. Request bodies take the same names as the MCP | |
| 247 | + | tools, in `snake_case`, and also accept `camelCase`, so you can send back a | |
| 248 | + | field exactly as you read it: | |
| 249 | + | ||
| 250 | + | ```sh | |
| 251 | + | # Both turn off counting agents' approvals. | |
| 252 | + | curl -X PATCH https://api.g1t.sh/repos/acme/web/settings \ | |
| 253 | + | -H "Authorization: Bearer g1t_…" -d '{"count_agent_approvals": false}' | |
| 254 | + | curl -X PATCH https://api.g1t.sh/repos/acme/web/settings \ | |
| 255 | + | -H "Authorization: Bearer g1t_…" -d '{"countAgentApprovals": false}' | |
| 256 | + | ``` | |
| 257 | + | ||
| 258 | + | When a body gives a field both ways, the `snake_case` one is used. |
| 55 | 55 | background: color-mix(in srgb, var(--color-accent) 35%, transparent); | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | − | :focus-visible { | |
| 59 | − | outline: 2px solid var(--color-accent); | |
| 60 | − | outline-offset: 2px; | |
| 58 | + | /* In the base layer, so a component can opt out with a utility. */ | |
| 59 | + | @layer base { | |
| 60 | + | :focus-visible { | |
| 61 | + | outline: 2px solid var(--color-accent); | |
| 62 | + | outline-offset: 2px; | |
| 63 | + | } | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | @media (prefers-reduced-motion: reduce) { | |
| 70 | 73 | } | |
| 71 | 74 | ||
| 72 | 75 | /* Rendered markdown: READMEs, briefs and docs. */ | |
| 76 | + | .prose > :first-child { | |
| 77 | + | margin-top: 0; | |
| 78 | + | padding-top: 0; | |
| 79 | + | } | |
| 73 | 80 | .prose { | |
| 74 | 81 | @apply leading-7 text-fg; | |
| 75 | 82 | } | |
| 122 | 129 | .prose hr { | |
| 123 | 130 | @apply border-line; | |
| 124 | 131 | } | |
| 132 | + | .prose h4 { | |
| 133 | + | @apply mt-6 text-sm font-semibold; | |
| 134 | + | } | |
| 135 | + | .prose li.task-list-item { | |
| 136 | + | @apply list-none; | |
| 137 | + | } | |
| 138 | + | .prose ul.contains-task-list { | |
| 139 | + | @apply pl-1; | |
| 140 | + | } | |
| 141 | + | .prose kbd { | |
| 142 | + | @apply rounded border border-line-strong bg-raised px-1.5 py-px font-mono text-[0.8em] shadow-[inset_0_-1px_0_var(--color-line-strong)]; | |
| 143 | + | } | |
| 144 | + | .prose del { | |
| 145 | + | @apply text-muted; | |
| 146 | + | } | |
| 147 | + | .prose details { | |
| 148 | + | @apply rounded-lg border border-line px-4 py-2; | |
| 149 | + | } | |
| 150 | + | .prose summary { | |
| 151 | + | @apply cursor-pointer font-medium; | |
| 152 | + | } | |
| 153 | + | .prose img { | |
| 154 | + | @apply inline max-w-full; | |
| 155 | + | } | |
| 156 | + | .prose section[data-footnotes] { | |
| 157 | + | @apply mt-10 border-t border-line pt-4 text-sm text-muted; | |
| 158 | + | } | |
| 159 | + | .prose a[data-footnote-ref] { | |
| 160 | + | @apply no-underline; | |
| 161 | + | } | |
| 125 | 162 | ||
| 126 | 163 | /* Syntax-highlighted code from Shiki. */ | |
| 127 | 164 | .shiki { |
| 1 | + | import { | |
| 2 | + | ArrowUpRight, | |
| 3 | + | CircleDot, | |
| 4 | + | GitCommitHorizontal, | |
| 5 | + | GitPullRequest, | |
| 6 | + | MessagesSquare, | |
| 7 | + | Sparkles, | |
| 8 | + | Terminal, | |
| 9 | + | X, | |
| 10 | + | } from "lucide-react"; | |
| 11 | + | import { Fragment, useEffect, useMemo, useState } from "react"; | |
| 12 | + | import { Link, useFetcher } from "react-router"; | |
| 13 | + | ||
| 14 | + | import type { Blame, Commit } from "@g1t/contracts"; | |
| 15 | + | ||
| 16 | + | import type { loader as whyLoader } from "../routes/repo/why"; | |
| 17 | + | import { repoAt } from "../lib/markdown-plugins"; | |
| 18 | + | import { Markdown } from "./markdown"; | |
| 19 | + | import { Avatar, TimeAgo } from "./ui"; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * A file with, beside each run of lines, the commit that last changed it. | |
| 23 | + | * Picking a line opens why it is the way it is: the commit, the pull | |
| 24 | + | * request and issue it came from, and what the agent was thinking. | |
| 25 | + | */ | |
| 26 | + | export function BlameView({ | |
| 27 | + | base, | |
| 28 | + | path, | |
| 29 | + | lines, | |
| 30 | + | html, | |
| 31 | + | blame, | |
| 32 | + | }: { | |
| 33 | + | base: string; | |
| 34 | + | path: string; | |
| 35 | + | lines: string[]; | |
| 36 | + | /** Highlighted HTML per line, when the language is known. */ | |
| 37 | + | html: string[] | null; | |
| 38 | + | blame: Blame; | |
| 39 | + | }) { | |
| 40 | + | const commits = useMemo( | |
| 41 | + | () => new Map<string, Commit>(blame.commits.map((commit) => [commit.hash, commit])), | |
| 42 | + | [blame.commits], | |
| 43 | + | ); | |
| 44 | + | // Newer commits are brighter: rank them by when they were written. | |
| 45 | + | const age = useMemo(() => { | |
| 46 | + | const sorted = [...blame.commits].sort((a, b) => b.authoredAt.localeCompare(a.authoredAt)); | |
| 47 | + | const rank = new Map(sorted.map((commit, index) => [commit.hash, index])); | |
| 48 | + | return (hash: string) => 1 - (rank.get(hash) ?? 0) / Math.max(sorted.length, 1); | |
| 49 | + | }, [blame.commits]); | |
| 50 | + | // The run of lines picked, and the line clicked, under which the | |
| 51 | + | // explanation opens. | |
| 52 | + | const [picked, setPicked] = useState<{ commit: string; start: number; end: number; line: number } | null>( | |
| 53 | + | null, | |
| 54 | + | ); | |
| 55 | + | const [hovered, setHovered] = useState<string | null>(null); | |
| 56 | + | const why = useFetcher<typeof whyLoader>(); | |
| 57 | + | ||
| 58 | + | useEffect(() => { | |
| 59 | + | if (!picked) return; | |
| 60 | + | why.load(`${base}/why/${picked.commit}?path=${encodeURIComponent(path)}`); | |
| 61 | + | // `why` changes identity as it loads; the pick is what matters. | |
| 62 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 63 | + | }, [picked?.commit, base, path]); | |
| 64 | + | ||
| 65 | + | // A line named in the address is picked on arrival: `#L12`. | |
| 66 | + | useEffect(() => { | |
| 67 | + | const match = /^#L(\d+)$/.exec(window.location.hash); | |
| 68 | + | if (!match) return; | |
| 69 | + | const line = Number(match[1]); | |
| 70 | + | const range = blame.ranges.find((r) => r.start <= line && line <= r.end); | |
| 71 | + | if (range) { | |
| 72 | + | setPicked({ ...range, line }); | |
| 73 | + | document.getElementById(`L${line}`)?.scrollIntoView({ block: "center" }); | |
| 74 | + | } | |
| 75 | + | }, [blame.ranges]); | |
| 76 | + | ||
| 77 | + | const pick = (start: number, end: number, commit: string, line: number) => { | |
| 78 | + | // Clicking the open line again closes it. | |
| 79 | + | if (picked?.line === line) { | |
| 80 | + | close(); | |
| 81 | + | return; | |
| 82 | + | } | |
| 83 | + | setPicked({ commit, start, end, line }); | |
| 84 | + | window.history.replaceState(null, "", `#L${line}`); | |
| 85 | + | }; | |
| 86 | + | const close = () => { | |
| 87 | + | setPicked(null); | |
| 88 | + | window.history.replaceState(null, "", window.location.pathname + window.location.search); | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | return ( | |
| 92 | + | <div> | |
| 93 | + | <p className="flex items-center gap-2 border-b border-line px-4 py-2 text-xs text-muted"> | |
| 94 | + | <Sparkles size={13} className="text-merged" /> | |
| 95 | + | Pick any line to see why it is the way it is: the commit, the pull request and issue it | |
| 96 | + | came from, and what the agent was thinking. | |
| 97 | + | </p> | |
| 98 | + | <div className="overflow-x-auto"> | |
| 99 | + | <table className="w-full border-collapse font-mono text-[0.8125rem] leading-6"> | |
| 100 | + | <tbody> | |
| 101 | + | {blame.ranges.map((range) => { | |
| 102 | + | const commit = commits.get(range.commit); | |
| 103 | + | const selected = picked?.commit === range.commit; | |
| 104 | + | const lit = hovered === range.commit; | |
| 105 | + | const bright = age(range.commit); | |
| 106 | + | const rows = []; | |
| 107 | + | for (let line = range.start; line <= range.end; line++) { | |
| 108 | + | const first = line === range.start; | |
| 109 | + | rows.push( | |
| 110 | + | <tr | |
| 111 | + | key={line} | |
| 112 | + | id={`L${line}`} | |
| 113 | + | onMouseEnter={() => setHovered(range.commit)} | |
| 114 | + | onMouseLeave={() => setHovered(null)} | |
| 115 | + | onClick={() => pick(range.start, range.end, range.commit, line)} | |
| 116 | + | className={`cursor-pointer transition-colors ${ | |
| 117 | + | selected ? "bg-accent/[0.07]" : lit ? "bg-raised/50" : "" | |
| 118 | + | } ${first ? "border-t border-line" : ""}`} | |
| 119 | + | > | |
| 120 | + | <td className="w-72 max-w-72 border-r border-line px-3 align-top font-sans"> | |
| 121 | + | {first && commit && ( | |
| 122 | + | <span className="flex items-center gap-2 py-0.5 text-xs"> | |
| 123 | + | <Avatar name={commit.author.name} size={16} /> | |
| 124 | + | <span className={`min-w-0 grow truncate ${selected ? "text-fg" : "text-muted"}`}> | |
| 125 | + | {commit.message.split("\n")[0]} | |
| 126 | + | </span> | |
| 127 | + | <span className="shrink-0 text-faint"> | |
| 128 | + | <TimeAgo at={commit.authoredAt} /> | |
| 129 | + | </span> | |
| 130 | + | </span> | |
| 131 | + | )} | |
| 132 | + | </td> | |
| 133 | + | <td className="w-1 p-0" aria-hidden="true"> | |
| 134 | + | <span | |
| 135 | + | className="block h-6 w-0.5 bg-accent" | |
| 136 | + | style={{ opacity: 0.15 + bright * 0.85 }} | |
| 137 | + | /> | |
| 138 | + | </td> | |
| 139 | + | <td className="w-12 px-2 text-right text-faint select-none">{line}</td> | |
| 140 | + | <td className="pr-4 pl-2 whitespace-pre"> | |
| 141 | + | {html?.[line - 1] != null ? ( | |
| 142 | + | <span dangerouslySetInnerHTML={{ __html: html[line - 1] || " " }} /> | |
| 143 | + | ) : ( | |
| 144 | + | lines[line - 1] || " " | |
| 145 | + | )} | |
| 146 | + | </td> | |
| 147 | + | </tr>, | |
| 148 | + | ); | |
| 149 | + | // The explanation opens right under the line clicked. | |
| 150 | + | if (picked?.line === line) { | |
| 151 | + | rows.push( | |
| 152 | + | <tr key={`why-${line}`}> | |
| 153 | + | <td colSpan={4} className="border-y border-line bg-bg/60 px-3 py-3 font-sans whitespace-normal md:pl-80"> | |
| 154 | + | <div className="max-w-2xl overflow-hidden rounded-xl border border-merged/30 bg-surface shadow-xl shadow-black/40"> | |
| 155 | + | <WhyPanel | |
| 156 | + | base={base} | |
| 157 | + | picked={picked} | |
| 158 | + | loading={why.state === "loading"} | |
| 159 | + | data={why.data} | |
| 160 | + | onClose={close} | |
| 161 | + | /> | |
| 162 | + | </div> | |
| 163 | + | </td> | |
| 164 | + | </tr>, | |
| 165 | + | ); | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | return <Fragment key={range.start}>{rows}</Fragment>; | |
| 169 | + | })} | |
| 170 | + | </tbody> | |
| 171 | + | </table> | |
| 172 | + | {blame.partial && ( | |
| 173 | + | <p className="border-t border-line px-4 py-2 text-xs text-faint"> | |
| 174 | + | This file's history is long; its oldest lines are credited to the oldest commit read. | |
| 175 | + | </p> | |
| 176 | + | )} | |
| 177 | + | </div> | |
| 178 | + | </div> | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | type WhyData = Awaited<ReturnType<typeof whyLoader>>; | |
| 183 | + | ||
| 184 | + | function WhyPanel({ | |
| 185 | + | base, | |
| 186 | + | picked, | |
| 187 | + | loading, | |
| 188 | + | data, | |
| 189 | + | onClose, | |
| 190 | + | }: { | |
| 191 | + | base: string; | |
| 192 | + | picked: { commit: string; start: number; end: number; line: number }; | |
| 193 | + | loading: boolean; | |
| 194 | + | data: WhyData | undefined; | |
| 195 | + | onClose: () => void; | |
| 196 | + | }) { | |
| 197 | + | const current = data && data.commit.hash === picked.commit ? data : null; | |
| 198 | + | return ( | |
| 199 | + | <div> | |
| 200 | + | <header className="flex items-center gap-2 border-b border-line px-4 py-3"> | |
| 201 | + | <Sparkles size={15} className="text-merged" /> | |
| 202 | + | <span className="grow text-sm font-medium"> | |
| 203 | + | Why line{picked.start === picked.end ? ` ${picked.start}` : `s ${picked.start}–${picked.end}`} | |
| 204 | + | </span> | |
| 205 | + | <button | |
| 206 | + | type="button" | |
| 207 | + | onClick={onClose} | |
| 208 | + | aria-label="Close" | |
| 209 | + | className="rounded p-1 text-faint hover:bg-raised hover:text-fg" | |
| 210 | + | > | |
| 211 | + | <X size={14} /> | |
| 212 | + | </button> | |
| 213 | + | </header> | |
| 214 | + | {!current ? ( | |
| 215 | + | <div className="space-y-3 p-4" aria-busy={loading}> | |
| 216 | + | {[70, 90, 55].map((width) => ( | |
| 217 | + | <span key={width} className="block h-3 animate-pulse rounded bg-raised" style={{ width: `${width}%` }} /> | |
| 218 | + | ))} | |
| 219 | + | </div> | |
| 220 | + | ) : ( | |
| 221 | + | <div className="divide-y divide-line text-sm"> | |
| 222 | + | <section className="p-4"> | |
| 223 | + | <p className="flex items-center gap-1.5 text-xs text-faint"> | |
| 224 | + | <GitCommitHorizontal size={13} /> | |
| 225 | + | Last changed in | |
| 226 | + | </p> | |
| 227 | + | <Link | |
| 228 | + | to={`${base}/commit/${current.commit.hash}`} | |
| 229 | + | className="mt-1.5 block font-medium hover:text-accent" | |
| 230 | + | > | |
| 231 | + | {current.commit.message.split("\n")[0]} | |
| 232 | + | </Link> | |
| 233 | + | <p className="mt-1.5 flex items-center gap-2 text-xs text-muted"> | |
| 234 | + | <Avatar name={current.commit.author.name} size={14} /> | |
| 235 | + | {current.commit.author.name} · <TimeAgo at={current.commit.authoredAt} /> ·{" "} | |
| 236 | + | <span className="font-mono">{current.commit.hash.slice(0, 7)}</span> | |
| 237 | + | </p> | |
| 238 | + | </section> | |
| 239 | + | ||
| 240 | + | {current.pull ? ( | |
| 241 | + | <section className="p-4"> | |
| 242 | + | <p className="flex items-center gap-1.5 text-xs text-faint"> | |
| 243 | + | <GitPullRequest size={13} /> | |
| 244 | + | Arrived in | |
| 245 | + | </p> | |
| 246 | + | <Link | |
| 247 | + | to={`${base}/pull/${current.pull.number}`} | |
| 248 | + | className="mt-1.5 block font-medium hover:text-accent" | |
| 249 | + | > | |
| 250 | + | {current.pull.title} <span className="font-normal text-faint">#{current.pull.number}</span> | |
| 251 | + | </Link> | |
| 252 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted"> | |
| 253 | + | {current.pull.runtime === "hosted" ? ( | |
| 254 | + | <span className="flex items-center gap-1 text-merged"> | |
| 255 | + | <Sparkles size={11} /> | |
| 256 | + | written by {current.pull.agent} | |
| 257 | + | </span> | |
| 258 | + | ) : ( | |
| 259 | + | <span>by {current.pull.author} with {current.pull.agent}</span> | |
| 260 | + | )} | |
| 261 | + | {current.pull.mergedBy && <span>· merged by {current.pull.mergedBy}</span>} | |
| 262 | + | </p> | |
| 263 | + | </section> | |
| 264 | + | ) : ( | |
| 265 | + | <section className="p-4 text-xs text-muted"> | |
| 266 | + | Pushed straight to the branch, not through a pull request, so there is no | |
| 267 | + | conversation behind it. | |
| 268 | + | </section> | |
| 269 | + | )} | |
| 270 | + | ||
| 271 | + | {current.issue && ( | |
| 272 | + | <section className="p-4"> | |
| 273 | + | <p className="flex items-center gap-1.5 text-xs text-faint"> | |
| 274 | + | <CircleDot size={13} /> | |
| 275 | + | What was asked for | |
| 276 | + | </p> | |
| 277 | + | <Link | |
| 278 | + | to={`${base}/issues/${current.issue.number}`} | |
| 279 | + | className="mt-1.5 block font-medium hover:text-accent" | |
| 280 | + | > | |
| 281 | + | {current.issue.title} <span className="font-normal text-faint">#{current.issue.number}</span> | |
| 282 | + | </Link> | |
| 283 | + | {current.issue.body && ( | |
| 284 | + | <div className="mt-2 max-h-48 overflow-hidden text-muted [&_*]:text-[0.8125rem]! [&_*]:leading-5! mask-[linear-gradient(to_bottom,black_70%,transparent)]"> | |
| 285 | + | <Markdown source={current.issue.body} repo={repoAt(base)} /> | |
| 286 | + | </div> | |
| 287 | + | )} | |
| 288 | + | </section> | |
| 289 | + | )} | |
| 290 | + | ||
| 291 | + | {current.steps.length > 0 && current.pull && ( | |
| 292 | + | <section className="p-4"> | |
| 293 | + | <p className="flex items-center gap-1.5 text-xs text-faint"> | |
| 294 | + | <MessagesSquare size={13} /> | |
| 295 | + | In the agent's words | |
| 296 | + | </p> | |
| 297 | + | {current.steps | |
| 298 | + | .filter((step) => step.kind === "message") | |
| 299 | + | .map((step) => ( | |
| 300 | + | <blockquote | |
| 301 | + | key={step.seq} | |
| 302 | + | className="mt-3 border-l-2 border-merged/50 pl-3 text-[0.8125rem] whitespace-pre-line text-fg/90" | |
| 303 | + | > | |
| 304 | + | {step.text} | |
| 305 | + | </blockquote> | |
| 306 | + | ))} | |
| 307 | + | {current.steps.some((step) => step.kind === "tool_call") && ( | |
| 308 | + | <details className="group mt-3"> | |
| 309 | + | <summary className="cursor-pointer text-xs text-muted hover:text-fg"> | |
| 310 | + | What it ran ({current.steps.filter((step) => step.kind === "tool_call").length}) | |
| 311 | + | </summary> | |
| 312 | + | <ol className="mt-2 space-y-1.5"> | |
| 313 | + | {current.steps | |
| 314 | + | .filter((step) => step.kind === "tool_call") | |
| 315 | + | .map((step) => ( | |
| 316 | + | <li key={step.seq} className="flex items-start gap-1.5 font-mono text-xs text-muted"> | |
| 317 | + | <Terminal size={12} className="mt-0.5 shrink-0" /> | |
| 318 | + | <span className="line-clamp-2 break-all"> | |
| 319 | + | <span className="text-fg">{step.tool}</span> {step.text.split("\n")[0]} | |
| 320 | + | </span> | |
| 321 | + | </li> | |
| 322 | + | ))} | |
| 323 | + | </ol> | |
| 324 | + | </details> | |
| 325 | + | )} | |
| 326 | + | <Link | |
| 327 | + | to={`${base}/pull/${current.pull.number}?tab=session`} | |
| 328 | + | className="mt-4 inline-flex items-center gap-1 text-xs text-accent hover:underline" | |
| 329 | + | > | |
| 330 | + | The whole session | |
| 331 | + | <ArrowUpRight size={12} /> | |
| 332 | + | </Link> | |
| 333 | + | </section> | |
| 334 | + | )} | |
| 335 | + | </div> | |
| 336 | + | )} | |
| 337 | + | </div> | |
| 338 | + | ); | |
| 339 | + | } |
| 1 | − | import { FileDiff as FileIcon, FileMinus, FilePlus, MessageSquarePlus } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 3 | − | import { Form } from "react-router"; | |
| 1 | + | import { | |
| 2 | + | ChevronDown, | |
| 3 | + | ChevronRight, | |
| 4 | + | Columns2, | |
| 5 | + | FileDiff as FileIcon, | |
| 6 | + | FileMinus, | |
| 7 | + | FilePlus, | |
| 8 | + | Folder, | |
| 9 | + | MessageSquarePlus, | |
| 10 | + | Rows3, | |
| 11 | + | Search, | |
| 12 | + | } from "lucide-react"; | |
| 13 | + | import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | + | import { Form, Link } from "react-router"; | |
| 4 | 15 | ||
| 5 | − | import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts"; | |
| 16 | + | import type { Comment, Comparison, DiffLine } from "@g1t/contracts"; | |
| 6 | 17 | ||
| 18 | + | import type { | |
| 19 | + | HighlightedComparison, | |
| 20 | + | HighlightedFile, | |
| 21 | + | HighlightedLine, | |
| 22 | + | } from "../lib/diff"; | |
| 7 | 23 | import { Markdown } from "./markdown"; | |
| 8 | 24 | import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui"; | |
| 9 | 25 | ||
| 10 | 26 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 11 | 27 | context: "", | |
| 12 | − | add: "bg-accent/10", | |
| 13 | − | delete: "bg-danger/10", | |
| 28 | + | add: "bg-accent/[0.09]", | |
| 29 | + | delete: "bg-danger/[0.09]", | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | const GUTTER_STYLES: Record<DiffLine["kind"], string> = { | |
| 33 | + | context: "text-faint", | |
| 34 | + | add: "bg-accent/[0.14] text-accent/70", | |
| 35 | + | delete: "bg-danger/[0.14] text-danger/70", | |
| 14 | 36 | }; | |
| 15 | 37 | ||
| 16 | 38 | const MARKERS: Record<DiffLine["kind"], string> = { | |
| 17 | 39 | context: " ", | |
| 18 | 40 | add: "+", | |
| 19 | − | delete: "-", | |
| 41 | + | delete: "−", | |
| 20 | 42 | }; | |
| 21 | 43 | ||
| 22 | 44 | /** Comments on lines of the change, and whether the viewer may add one. */ | |
| 23 | 45 | export type LineReview = { comments: Comment[]; canComment: boolean }; | |
| 24 | 46 | ||
| 25 | − | /** `+12 −3` with a five-block bar, like the summary on a pull request. */ | |
| 26 | − | function Stat({ | |
| 27 | − | additions, | |
| 28 | − | deletions, | |
| 29 | − | }: { | |
| 30 | − | additions: number; | |
| 31 | − | deletions: number; | |
| 32 | − | }) { | |
| 47 | + | type Layout = "unified" | "split"; | |
| 48 | + | ||
| 49 | + | /** Reads a per-viewer preference; storage can be missing or refuse. */ | |
| 50 | + | function stored(key: string): string | null { | |
| 51 | + | try { | |
| 52 | + | return window.localStorage.getItem(key); | |
| 53 | + | } catch { | |
| 54 | + | return null; | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | function store(key: string, value: string | null) { | |
| 59 | + | try { | |
| 60 | + | if (value == null) window.localStorage.removeItem(key); | |
| 61 | + | else window.localStorage.setItem(key, value); | |
| 62 | + | } catch { | |
| 63 | + | // A private window: the preference lasts until the page is left. | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** `+12 −3` with a five-block bar. */ | |
| 68 | + | export function Stat({ additions, deletions }: { additions: number; deletions: number }) { | |
| 33 | 69 | const total = additions + deletions; | |
| 34 | 70 | const green = total === 0 ? 0 : Math.round((additions / total) * 5); | |
| 35 | 71 | return ( | |
| 50 | 86 | ); | |
| 51 | 87 | } | |
| 52 | 88 | ||
| 89 | + | function fileIcon(status: HighlightedFile["status"]) { | |
| 90 | + | return status === "added" ? FilePlus : status === "deleted" ? FileMinus : FileIcon; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | function fileTone(status: HighlightedFile["status"]) { | |
| 94 | + | return status === "added" ? "text-accent" : status === "deleted" ? "text-danger" : "text-faint"; | |
| 95 | + | } | |
| 96 | + | ||
| 53 | 97 | /** One comment made on a line, shown under that line. */ | |
| 54 | 98 | function LineComment({ comment }: { comment: Comment }) { | |
| 55 | 99 | return ( | |
| 66 | 110 | ); | |
| 67 | 111 | } | |
| 68 | 112 | ||
| 69 | − | function File({ file, review }: { file: FileDiff; review?: LineReview }) { | |
| 70 | − | const Icon = | |
| 71 | − | file.status === "added" | |
| 72 | − | ? FilePlus | |
| 73 | − | : file.status === "deleted" | |
| 74 | − | ? FileMinus | |
| 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)), | |
| 113 | + | /** A line's text, highlighted when the server could. */ | |
| 114 | + | function Code({ line }: { line: HighlightedLine | null }) { | |
| 115 | + | if (!line) return null; | |
| 116 | + | if (line.html != null) { | |
| 117 | + | return <span dangerouslySetInnerHTML={{ __html: line.html || " " }} />; | |
| 118 | + | } | |
| 119 | + | return <>{line.text || " "}</>; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /** The comments on a line and, when open, the box to add one. */ | |
| 123 | + | function Thread({ | |
| 124 | + | path, | |
| 125 | + | line, | |
| 126 | + | comments, | |
| 127 | + | open, | |
| 128 | + | onClose, | |
| 129 | + | }: { | |
| 130 | + | path: string; | |
| 131 | + | line: number; | |
| 132 | + | comments: Comment[]; | |
| 133 | + | open: boolean; | |
| 134 | + | onClose: () => void; | |
| 135 | + | }) { | |
| 136 | + | return ( | |
| 137 | + | <div className="max-w-2xl space-y-2"> | |
| 138 | + | {comments.map((comment) => ( | |
| 139 | + | <LineComment key={comment.id} comment={comment} /> | |
| 140 | + | ))} | |
| 141 | + | {open && ( | |
| 142 | + | <Form method="post" className="space-y-2 font-sans" onSubmit={onClose}> | |
| 143 | + | <input type="hidden" name="action" value="comment" /> | |
| 144 | + | <input type="hidden" name="path" value={path} /> | |
| 145 | + | <input type="hidden" name="line" value={line} /> | |
| 146 | + | <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} /> | |
| 147 | + | <div className="flex gap-2"> | |
| 148 | + | <Button type="submit">Comment</Button> | |
| 149 | + | <Button variant="quiet" type="button" onClick={onClose}> | |
| 150 | + | Cancel | |
| 151 | + | </Button> | |
| 152 | + | </div> | |
| 153 | + | </Form> | |
| 154 | + | )} | |
| 155 | + | </div> | |
| 79 | 156 | ); | |
| 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), | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | function CommentButton({ line, onClick }: { line: number; onClick: () => void }) { | |
| 160 | + | return ( | |
| 161 | + | <button | |
| 162 | + | type="button" | |
| 163 | + | aria-label={`Comment on line ${line}`} | |
| 164 | + | onClick={onClick} | |
| 165 | + | 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" | |
| 166 | + | > | |
| 167 | + | <MessageSquarePlus size={12} /> | |
| 168 | + | </button> | |
| 83 | 169 | ); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | type FileProps = { | |
| 173 | + | file: HighlightedFile; | |
| 174 | + | comments: Comment[]; | |
| 175 | + | canComment: boolean; | |
| 176 | + | }; | |
| 177 | + | ||
| 178 | + | function UnifiedRows({ file, comments, canComment }: FileProps) { | |
| 179 | + | const [writing, setWriting] = useState<number | null>(null); | |
| 84 | 180 | return ( | |
| 181 | + | <table className="w-full border-collapse font-mono text-xs leading-5"> | |
| 182 | + | <tbody> | |
| 183 | + | {file.hunks.map((hunk, h) => ( | |
| 184 | + | <Fragment key={h}> | |
| 185 | + | {h > 0 && <HunkGap colSpan={4} />} | |
| 186 | + | {hunk.lines.map((line, index) => { | |
| 187 | + | const here = comments.filter((c) => line.new != null && c.line === line.new); | |
| 188 | + | const open = line.new != null && writing === line.new; | |
| 189 | + | return ( | |
| 190 | + | <Fragment key={index}> | |
| 191 | + | <tr className={`group ${ROW_STYLES[line.kind]}`}> | |
| 192 | + | <td className={`w-12 px-2 text-right select-none ${GUTTER_STYLES[line.kind]}`}> | |
| 193 | + | {line.old} | |
| 194 | + | </td> | |
| 195 | + | <td className={`relative w-12 px-2 text-right select-none ${GUTTER_STYLES[line.kind]}`}> | |
| 196 | + | {line.new} | |
| 197 | + | {canComment && line.new != null && ( | |
| 198 | + | <CommentButton line={line.new} onClick={() => setWriting(open ? null : line.new)} /> | |
| 199 | + | )} | |
| 200 | + | </td> | |
| 201 | + | <td className={`w-5 text-center select-none ${fileToneFor(line.kind)}`}> | |
| 202 | + | {MARKERS[line.kind]} | |
| 203 | + | </td> | |
| 204 | + | <td className="pr-4 whitespace-pre"> | |
| 205 | + | <Code line={line} /> | |
| 206 | + | </td> | |
| 207 | + | </tr> | |
| 208 | + | {(here.length > 0 || open) && line.new != null && ( | |
| 209 | + | <tr> | |
| 210 | + | <td colSpan={4} className="border-y border-line bg-surface p-3"> | |
| 211 | + | <Thread | |
| 212 | + | path={file.path} | |
| 213 | + | line={line.new} | |
| 214 | + | comments={here} | |
| 215 | + | open={open} | |
| 216 | + | onClose={() => setWriting(null)} | |
| 217 | + | /> | |
| 218 | + | </td> | |
| 219 | + | </tr> | |
| 220 | + | )} | |
| 221 | + | </Fragment> | |
| 222 | + | ); | |
| 223 | + | })} | |
| 224 | + | </Fragment> | |
| 225 | + | ))} | |
| 226 | + | </tbody> | |
| 227 | + | </table> | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | function fileToneFor(kind: DiffLine["kind"]) { | |
| 232 | + | return kind === "add" ? "text-accent" : kind === "delete" ? "text-danger" : "text-faint"; | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /** Lines side by side: a run of deletions beside the additions that replaced it. */ | |
| 236 | + | function pairs(lines: HighlightedLine[]): [HighlightedLine | null, HighlightedLine | null][] { | |
| 237 | + | const rows: [HighlightedLine | null, HighlightedLine | null][] = []; | |
| 238 | + | let index = 0; | |
| 239 | + | while (index < lines.length) { | |
| 240 | + | const line = lines[index]!; | |
| 241 | + | if (line.kind === "context") { | |
| 242 | + | rows.push([line, line]); | |
| 243 | + | index++; | |
| 244 | + | continue; | |
| 245 | + | } | |
| 246 | + | const removed: HighlightedLine[] = []; | |
| 247 | + | const added: HighlightedLine[] = []; | |
| 248 | + | while (index < lines.length && lines[index]!.kind === "delete") removed.push(lines[index++]!); | |
| 249 | + | while (index < lines.length && lines[index]!.kind === "add") added.push(lines[index++]!); | |
| 250 | + | for (let n = 0; n < Math.max(removed.length, added.length); n++) { | |
| 251 | + | rows.push([removed[n] ?? null, added[n] ?? null]); | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return rows; | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | function SplitRows({ file, comments, canComment }: FileProps) { | |
| 258 | + | const [writing, setWriting] = useState<number | null>(null); | |
| 259 | + | const side = (line: HighlightedLine | null, which: "old" | "new") => { | |
| 260 | + | const kind = line ? (line.kind === "context" ? "context" : line.kind) : null; | |
| 261 | + | const empty = "bg-surface/60"; | |
| 262 | + | return ( | |
| 263 | + | <> | |
| 264 | + | <td | |
| 265 | + | className={`relative w-12 px-2 text-right align-top select-none ${kind ? GUTTER_STYLES[kind] : empty} ${ | |
| 266 | + | which === "new" ? "border-l border-line" : "" | |
| 267 | + | }`} | |
| 268 | + | > | |
| 269 | + | {line?.[which]} | |
| 270 | + | {which === "new" && canComment && line?.new != null && ( | |
| 271 | + | <CommentButton line={line.new} onClick={() => setWriting(writing === line.new ? null : line.new)} /> | |
| 272 | + | )} | |
| 273 | + | </td> | |
| 274 | + | <td className={`pr-3 pl-2 align-top break-all whitespace-pre-wrap ${kind ? ROW_STYLES[kind] : empty}`}> | |
| 275 | + | <Code line={line} /> | |
| 276 | + | </td> | |
| 277 | + | </> | |
| 278 | + | ); | |
| 279 | + | }; | |
| 280 | + | return ( | |
| 281 | + | <table className="w-full table-fixed border-collapse font-mono text-xs leading-5"> | |
| 282 | + | <colgroup> | |
| 283 | + | <col className="w-12" /> | |
| 284 | + | <col /> | |
| 285 | + | <col className="w-12" /> | |
| 286 | + | <col /> | |
| 287 | + | </colgroup> | |
| 288 | + | <tbody> | |
| 289 | + | {file.hunks.map((hunk, h) => ( | |
| 290 | + | <Fragment key={h}> | |
| 291 | + | {h > 0 && <HunkGap colSpan={4} />} | |
| 292 | + | {pairs(hunk.lines).map(([before, after], index) => { | |
| 293 | + | const line = after?.new ?? null; | |
| 294 | + | const here = comments.filter((c) => line != null && c.line === line); | |
| 295 | + | const open = line != null && writing === line; | |
| 296 | + | return ( | |
| 297 | + | <Fragment key={index}> | |
| 298 | + | <tr className="group"> | |
| 299 | + | {side(before && before.kind !== "add" ? before : null, "old")} | |
| 300 | + | {side(after && after.kind !== "delete" ? after : null, "new")} | |
| 301 | + | </tr> | |
| 302 | + | {(here.length > 0 || open) && line != null && ( | |
| 303 | + | <tr> | |
| 304 | + | <td colSpan={4} className="border-y border-line bg-surface p-3"> | |
| 305 | + | <Thread | |
| 306 | + | path={file.path} | |
| 307 | + | line={line} | |
| 308 | + | comments={here} | |
| 309 | + | open={open} | |
| 310 | + | onClose={() => setWriting(null)} | |
| 311 | + | /> | |
| 312 | + | </td> | |
| 313 | + | </tr> | |
| 314 | + | )} | |
| 315 | + | </Fragment> | |
| 316 | + | ); | |
| 317 | + | })} | |
| 318 | + | </Fragment> | |
| 319 | + | ))} | |
| 320 | + | </tbody> | |
| 321 | + | </table> | |
| 322 | + | ); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | function HunkGap({ colSpan }: { colSpan: number }) { | |
| 326 | + | return ( | |
| 327 | + | <tr aria-hidden="true"> | |
| 328 | + | <td colSpan={colSpan} className="border-y border-line bg-surface py-0.5 text-center text-faint"> | |
| 329 | + | ⋯ | |
| 330 | + | </td> | |
| 331 | + | </tr> | |
| 332 | + | ); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | function FileSection({ | |
| 336 | + | file, | |
| 337 | + | review, | |
| 338 | + | layout, | |
| 339 | + | collapsed, | |
| 340 | + | viewed, | |
| 341 | + | onToggle, | |
| 342 | + | onViewed, | |
| 343 | + | fileBase, | |
| 344 | + | }: { | |
| 345 | + | file: HighlightedFile; | |
| 346 | + | review?: LineReview; | |
| 347 | + | layout: Layout; | |
| 348 | + | collapsed: boolean; | |
| 349 | + | viewed: boolean; | |
| 350 | + | onToggle: () => void; | |
| 351 | + | onViewed: (viewed: boolean) => void; | |
| 352 | + | /** Where the file can be read as of the change, e.g. `/acme/web/blob/<hash>`. */ | |
| 353 | + | fileBase?: string; | |
| 354 | + | }) { | |
| 355 | + | const Icon = fileIcon(file.status); | |
| 356 | + | const comments = review?.comments.filter((comment) => comment.path === file.path) ?? []; | |
| 357 | + | const shown = new Set(file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new))); | |
| 358 | + | // Made against an earlier version of the change, on a line no longer in it. | |
| 359 | + | const outdated = comments.filter((comment) => comment.line == null || !shown.has(comment.line)); | |
| 360 | + | // Shown plain at once, and highlighted in the browser when it comes near | |
| 361 | + | // the screen: the page never waits for colours. | |
| 362 | + | const section = useRef<HTMLElement>(null); | |
| 363 | + | const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null); | |
| 364 | + | useEffect(() => { | |
| 365 | + | setHighlighted(null); | |
| 366 | + | const element = section.current; | |
| 367 | + | if (!element || collapsed) return; | |
| 368 | + | let cancelled = false; | |
| 369 | + | const observer = new IntersectionObserver( | |
| 370 | + | ([entry]) => { | |
| 371 | + | if (!entry?.isIntersecting) return; | |
| 372 | + | observer.disconnect(); | |
| 373 | + | void import("../lib/shiki") | |
| 374 | + | .then(({ highlightFile }) => highlightFile(file)) | |
| 375 | + | .then((result) => { | |
| 376 | + | if (!cancelled && result) setHighlighted(result); | |
| 377 | + | }) | |
| 378 | + | .catch(() => {}); | |
| 379 | + | }, | |
| 380 | + | { rootMargin: "600px 0px" }, | |
| 381 | + | ); | |
| 382 | + | observer.observe(element); | |
| 383 | + | return () => { | |
| 384 | + | cancelled = true; | |
| 385 | + | observer.disconnect(); | |
| 386 | + | }; | |
| 387 | + | }, [file, collapsed]); | |
| 388 | + | const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false }; | |
| 389 | + | return ( | |
| 85 | 390 | <section | |
| 391 | + | ref={section} | |
| 86 | 392 | id={`file-${file.path}`} | |
| 87 | − | className="scroll-mt-20 overflow-hidden rounded-xl border border-line" | |
| 393 | + | data-diff-file={file.path} | |
| 394 | + | className="scroll-mt-28 rounded-xl border border-line" | |
| 88 | 395 | > | |
| 89 | − | <header className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5"> | |
| 90 | − | <Icon | |
| 91 | − | size={15} | |
| 92 | − | className={ | |
| 93 | − | file.status === "added" | |
| 94 | − | ? "text-accent" | |
| 95 | − | : file.status === "deleted" | |
| 96 | − | ? "text-danger" | |
| 97 | − | : "text-faint" | |
| 98 | − | } | |
| 99 | − | /> | |
| 100 | − | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> | |
| 396 | + | <header | |
| 397 | + | className={`sticky top-12 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ | |
| 398 | + | collapsed ? "rounded-xl" : "rounded-t-xl border-b" | |
| 399 | + | }`} | |
| 400 | + | > | |
| 401 | + | <button | |
| 402 | + | type="button" | |
| 403 | + | onClick={onToggle} | |
| 404 | + | aria-label={collapsed ? `Show ${file.path}` : `Hide ${file.path}`} | |
| 405 | + | aria-expanded={!collapsed} | |
| 406 | + | className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg" | |
| 407 | + | > | |
| 408 | + | {collapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />} | |
| 409 | + | </button> | |
| 410 | + | <Icon size={15} className={fileTone(file.status)} /> | |
| 411 | + | <button | |
| 412 | + | type="button" | |
| 413 | + | onClick={onToggle} | |
| 414 | + | className={`min-w-0 grow truncate text-left font-mono text-[0.8125rem] ${viewed ? "text-muted" : ""}`} | |
| 415 | + | > | |
| 101 | 416 | {file.path} | |
| 102 | − | </span> | |
| 417 | + | </button> | |
| 418 | + | {comments.length > 0 && ( | |
| 419 | + | <span className="flex items-center gap-1 text-xs text-muted"> | |
| 420 | + | <MessageSquarePlus size={12} /> | |
| 421 | + | {comments.length} | |
| 422 | + | </span> | |
| 423 | + | )} | |
| 103 | 424 | <Stat additions={file.additions} deletions={file.deletions} /> | |
| 425 | + | {fileBase && file.status !== "deleted" && ( | |
| 426 | + | <span className="hidden items-center gap-0.5 text-xs sm:flex"> | |
| 427 | + | <Link to={`${fileBase}/${file.path}`} className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"> | |
| 428 | + | View | |
| 429 | + | </Link> | |
| 430 | + | <Link | |
| 431 | + | to={`${fileBase}/${file.path}?blame=1`} | |
| 432 | + | className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg" | |
| 433 | + | > | |
| 434 | + | Blame | |
| 435 | + | </Link> | |
| 436 | + | </span> | |
| 437 | + | )} | |
| 438 | + | <label | |
| 439 | + | className={`ml-1 flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs transition-colors select-none ${ | |
| 440 | + | viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg" | |
| 441 | + | }`} | |
| 442 | + | > | |
| 443 | + | <input | |
| 444 | + | type="checkbox" | |
| 445 | + | checked={viewed} | |
| 446 | + | onChange={(event) => onViewed(event.target.checked)} | |
| 447 | + | className="accent-accent" | |
| 448 | + | /> | |
| 449 | + | Viewed | |
| 450 | + | </label> | |
| 104 | 451 | </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 | − | )} | |
| 113 | − | {file.binary ? ( | |
| 114 | − | <p className="px-4 py-6 text-sm text-muted"> | |
| 115 | − | Binary or large file; its contents are not shown. | |
| 116 | − | </p> | |
| 117 | − | ) : file.hunks.length === 0 ? ( | |
| 118 | − | <p className="px-4 py-6 text-sm text-muted">No line changes.</p> | |
| 119 | − | ) : ( | |
| 120 | − | <div className="overflow-x-auto"> | |
| 121 | − | <table className="w-full border-collapse font-mono text-xs leading-5"> | |
| 122 | − | <tbody> | |
| 123 | − | {file.hunks.map((hunk, index) => ( | |
| 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 | − | /> | |
| 452 | + | {!collapsed && ( | |
| 453 | + | <div className="overflow-hidden rounded-b-xl"> | |
| 454 | + | {outdated.length > 0 && ( | |
| 455 | + | <div className="space-y-2 border-b border-line bg-surface/50 p-3"> | |
| 456 | + | <p className="text-xs text-faint">On lines that have since changed</p> | |
| 457 | + | {outdated.map((comment) => ( | |
| 458 | + | <LineComment key={comment.id} comment={comment} /> | |
| 132 | 459 | ))} | |
| 133 | − | </tbody> | |
| 134 | − | </table> | |
| 460 | + | </div> | |
| 461 | + | )} | |
| 462 | + | {file.binary ? ( | |
| 463 | + | <p className="px-4 py-6 text-sm text-muted">Binary or large file; its contents are not shown.</p> | |
| 464 | + | ) : file.hunks.length === 0 ? ( | |
| 465 | + | <p className="px-4 py-6 text-sm text-muted">No line changes{file.status === "added" ? ": an empty file" : ""}.</p> | |
| 466 | + | ) : ( | |
| 467 | + | <div className="overflow-x-auto"> | |
| 468 | + | {layout === "split" ? <SplitRows {...props} /> : <UnifiedRows {...props} />} | |
| 469 | + | </div> | |
| 470 | + | )} | |
| 135 | 471 | </div> | |
| 136 | 472 | )} | |
| 137 | 473 | </section> | |
| 138 | 474 | ); | |
| 139 | 475 | } | |
| 140 | 476 | ||
| 141 | − | function HunkRows({ | |
| 142 | − | path, | |
| 143 | − | lines, | |
| 144 | − | first, | |
| 145 | − | comments, | |
| 146 | − | canComment, | |
| 477 | + | type TreeNode = { name: string; path: string; children: TreeNode[]; file?: HighlightedFile }; | |
| 478 | + | ||
| 479 | + | /** The changed files as folders, with folders of one folder merged: `src/lib`. */ | |
| 480 | + | function treeOf(files: HighlightedFile[]): TreeNode[] { | |
| 481 | + | const root: TreeNode = { name: "", path: "", children: [] }; | |
| 482 | + | for (const file of files) { | |
| 483 | + | const parts = file.path.split("/"); | |
| 484 | + | let node = root; | |
| 485 | + | parts.forEach((part, index) => { | |
| 486 | + | const path = parts.slice(0, index + 1).join("/"); | |
| 487 | + | let child = node.children.find((c) => c.name === part && !c.file === (index < parts.length - 1)); | |
| 488 | + | if (!child) { | |
| 489 | + | child = { name: part, path, children: [] }; | |
| 490 | + | if (index === parts.length - 1) child.file = file; | |
| 491 | + | node.children.push(child); | |
| 492 | + | } | |
| 493 | + | node = child; | |
| 494 | + | }); | |
| 495 | + | } | |
| 496 | + | const squash = (node: TreeNode): TreeNode => { | |
| 497 | + | let current = node; | |
| 498 | + | while (!current.file && current.children.length === 1 && !current.children[0]!.file) { | |
| 499 | + | const only = current.children[0]!; | |
| 500 | + | current = { ...only, name: `${current.name}/${only.name}` }; | |
| 501 | + | } | |
| 502 | + | const children = current.children | |
| 503 | + | .map(squash) | |
| 504 | + | .sort((a, b) => Number(!!a.file) - Number(!!b.file) || a.name.localeCompare(b.name)); | |
| 505 | + | return { ...current, children }; | |
| 506 | + | }; | |
| 507 | + | return squash(root).children; | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | function Tree({ | |
| 511 | + | nodes, | |
| 512 | + | depth, | |
| 513 | + | active, | |
| 514 | + | viewed, | |
| 515 | + | onPick, | |
| 147 | 516 | }: { | |
| 148 | − | path: string; | |
| 149 | − | lines: DiffLine[]; | |
| 150 | − | first: boolean; | |
| 151 | − | comments: Comment[]; | |
| 152 | − | canComment: boolean; | |
| 517 | + | nodes: TreeNode[]; | |
| 518 | + | depth: number; | |
| 519 | + | active: string | null; | |
| 520 | + | viewed: Set<string>; | |
| 521 | + | onPick: (path: string) => void; | |
| 153 | 522 | }) { | |
| 154 | − | // The line whose comment box is open, if any. | |
| 155 | − | const [writing, setWriting] = useState<number | null>(null); | |
| 156 | 523 | return ( | |
| 157 | − | <> | |
| 158 | − | {!first && ( | |
| 159 | − | <tr aria-hidden="true"> | |
| 160 | − | <td | |
| 161 | − | colSpan={4} | |
| 162 | − | className="border-y border-line bg-surface py-1 text-center text-faint" | |
| 163 | − | > | |
| 164 | − | ⋯ | |
| 165 | − | </td> | |
| 166 | − | </tr> | |
| 167 | − | )} | |
| 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; | |
| 524 | + | <ul> | |
| 525 | + | {nodes.map((node) => { | |
| 526 | + | if (!node.file) { | |
| 527 | + | return ( | |
| 528 | + | <li key={node.path}> | |
| 529 | + | <p | |
| 530 | + | className="flex items-center gap-1.5 truncate py-1 font-mono text-xs text-faint" | |
| 531 | + | style={{ paddingLeft: depth * 12 + 8 }} | |
| 532 | + | > | |
| 533 | + | <Folder size={13} className="shrink-0" /> | |
| 534 | + | {node.name} | |
| 535 | + | </p> | |
| 536 | + | <Tree nodes={node.children} depth={depth + 1} active={active} viewed={viewed} onPick={onPick} /> | |
| 537 | + | </li> | |
| 538 | + | ); | |
| 539 | + | } | |
| 540 | + | const file = node.file; | |
| 541 | + | const Icon = fileIcon(file.status); | |
| 173 | 542 | 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> | |
| 543 | + | <li key={node.path}> | |
| 544 | + | <button | |
| 545 | + | type="button" | |
| 546 | + | onClick={() => onPick(file.path)} | |
| 547 | + | style={{ paddingLeft: depth * 12 + 8 }} | |
| 548 | + | className={`flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left font-mono text-xs transition-colors ${ | |
| 549 | + | active === file.path ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 550 | + | } ${viewed.has(file.path) ? "opacity-55" : ""}`} | |
| 551 | + | > | |
| 552 | + | <Icon size={13} className={`shrink-0 ${fileTone(file.status)}`} /> | |
| 553 | + | <span className="min-w-0 grow truncate">{node.name}</span> | |
| 554 | + | <span className="shrink-0 text-[0.625rem] tabular-nums"> | |
| 555 | + | {file.additions > 0 && <span className="text-accent">+{file.additions}</span>} | |
| 556 | + | {file.deletions > 0 && <span className="ml-1 text-danger">−{file.deletions}</span>} | |
| 557 | + | </span> | |
| 558 | + | </button> | |
| 559 | + | </li> | |
| 240 | 560 | ); | |
| 241 | 561 | })} | |
| 242 | − | </> | |
| 562 | + | </ul> | |
| 243 | 563 | ); | |
| 244 | 564 | } | |
| 245 | 565 | ||
| 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 | − | ||
| 566 | + | /** | |
| 567 | + | * A change, file by file: a tree of what changed beside the diffs, unified | |
| 568 | + | * or side by side, each file foldable and markable as viewed. `j` and `k` | |
| 569 | + | * move between files and `t` filters them. | |
| 570 | + | */ | |
| 251 | 571 | export function DiffView({ | |
| 252 | 572 | comparison, | |
| 253 | 573 | review, | |
| 574 | + | empty, | |
| 575 | + | fileBase, | |
| 254 | 576 | }: { | |
| 255 | 577 | comparison: Comparison; | |
| 256 | 578 | review?: LineReview; | |
| 579 | + | /** What to say when nothing changed. */ | |
| 580 | + | empty?: React.ReactNode; | |
| 581 | + | /** Where each file can be read as of the change, to link to it. */ | |
| 582 | + | fileBase?: string; | |
| 257 | 583 | }) { | |
| 258 | 584 | const { files, truncated } = comparison; | |
| 585 | + | const [layout, setLayout] = useState<Layout>("unified"); | |
| 586 | + | const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); | |
| 587 | + | const [viewed, setViewed] = useState<Set<string>>(new Set()); | |
| 588 | + | const [active, setActive] = useState<string | null>(files[0]?.path ?? null); | |
| 589 | + | const [filter, setFilter] = useState(""); | |
| 590 | + | const filterInput = useRef<HTMLInputElement>(null); | |
| 591 | + | const viewedKey = `g1t:viewed:${comparison.head}`; | |
| 592 | + | ||
| 593 | + | // Preferences live in the browser: read them once it is there. | |
| 594 | + | useEffect(() => { | |
| 595 | + | if (stored("g1t:diff-layout") === "split") setLayout("split"); | |
| 596 | + | const saved = stored(viewedKey); | |
| 597 | + | if (saved) { | |
| 598 | + | try { | |
| 599 | + | const paths = new Set<string>(JSON.parse(saved)); | |
| 600 | + | setViewed(paths); | |
| 601 | + | setCollapsed(new Set(paths)); | |
| 602 | + | } catch { | |
| 603 | + | // Something else wrote it. | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | }, [viewedKey]); | |
| 607 | + | ||
| 608 | + | const changeLayout = (next: Layout) => { | |
| 609 | + | setLayout(next); | |
| 610 | + | store("g1t:diff-layout", next); | |
| 611 | + | }; | |
| 612 | + | const markViewed = (path: string, on: boolean) => { | |
| 613 | + | setViewed((previous) => { | |
| 614 | + | const next = new Set(previous); | |
| 615 | + | if (on) next.add(path); | |
| 616 | + | else next.delete(path); | |
| 617 | + | store(viewedKey, next.size ? JSON.stringify([...next]) : null); | |
| 618 | + | return next; | |
| 619 | + | }); | |
| 620 | + | // Marking a file viewed folds it away, as a reviewer moves on. | |
| 621 | + | setCollapsed((previous) => { | |
| 622 | + | const next = new Set(previous); | |
| 623 | + | if (on) next.add(path); | |
| 624 | + | else next.delete(path); | |
| 625 | + | return next; | |
| 626 | + | }); | |
| 627 | + | }; | |
| 628 | + | const toggle = (path: string) => | |
| 629 | + | setCollapsed((previous) => { | |
| 630 | + | const next = new Set(previous); | |
| 631 | + | if (next.has(path)) next.delete(path); | |
| 632 | + | else next.add(path); | |
| 633 | + | return next; | |
| 634 | + | }); | |
| 635 | + | ||
| 636 | + | const shownFiles = useMemo(() => { | |
| 637 | + | const words = filter.toLowerCase().split(/\s+/).filter(Boolean); | |
| 638 | + | return words.length === 0 | |
| 639 | + | ? files | |
| 640 | + | : files.filter((file) => words.every((word) => file.path.toLowerCase().includes(word))); | |
| 641 | + | }, [files, filter]); | |
| 642 | + | const tree = useMemo(() => treeOf(shownFiles), [shownFiles]); | |
| 643 | + | ||
| 644 | + | const jump = useCallback((path: string) => { | |
| 645 | + | setCollapsed((previous) => { | |
| 646 | + | if (!previous.has(path)) return previous; | |
| 647 | + | const next = new Set(previous); | |
| 648 | + | next.delete(path); | |
| 649 | + | return next; | |
| 650 | + | }); | |
| 651 | + | setActive(path); | |
| 652 | + | requestAnimationFrame(() => | |
| 653 | + | document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: "smooth", block: "start" }), | |
| 654 | + | ); | |
| 655 | + | }, []); | |
| 656 | + | ||
| 657 | + | // The file at the top of the screen is the current one. | |
| 658 | + | useEffect(() => { | |
| 659 | + | const sections = [...document.querySelectorAll<HTMLElement>("[data-diff-file]")]; | |
| 660 | + | if (sections.length === 0) return; | |
| 661 | + | const observer = new IntersectionObserver( | |
| 662 | + | (entries) => { | |
| 663 | + | const top = entries | |
| 664 | + | .filter((entry) => entry.isIntersecting) | |
| 665 | + | .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; | |
| 666 | + | if (top) setActive((top.target as HTMLElement).dataset.diffFile ?? null); | |
| 667 | + | }, | |
| 668 | + | { rootMargin: "-112px 0px -60% 0px" }, | |
| 669 | + | ); | |
| 670 | + | sections.forEach((section) => observer.observe(section)); | |
| 671 | + | return () => observer.disconnect(); | |
| 672 | + | }, [shownFiles, collapsed]); | |
| 673 | + | ||
| 674 | + | useEffect(() => { | |
| 675 | + | const onKey = (event: KeyboardEvent) => { | |
| 676 | + | const target = event.target as HTMLElement | null; | |
| 677 | + | if (event.metaKey || event.ctrlKey || event.altKey) return; | |
| 678 | + | if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return; | |
| 679 | + | const index = shownFiles.findIndex((file) => file.path === active); | |
| 680 | + | if (event.key === "j" || event.key === "k") { | |
| 681 | + | const next = shownFiles[Math.max(0, Math.min(shownFiles.length - 1, index + (event.key === "j" ? 1 : -1)))]; | |
| 682 | + | if (next) { | |
| 683 | + | event.preventDefault(); | |
| 684 | + | jump(next.path); | |
| 685 | + | } | |
| 686 | + | } else if (event.key === "t") { | |
| 687 | + | event.preventDefault(); | |
| 688 | + | filterInput.current?.focus(); | |
| 689 | + | } else if (event.key === "v" && active) { | |
| 690 | + | event.preventDefault(); | |
| 691 | + | markViewed(active, !viewed.has(active)); | |
| 692 | + | } | |
| 693 | + | }; | |
| 694 | + | window.addEventListener("keydown", onKey); | |
| 695 | + | return () => window.removeEventListener("keydown", onKey); | |
| 696 | + | }); | |
| 697 | + | ||
| 259 | 698 | if (files.length === 0) { | |
| 260 | 699 | return ( | |
| 261 | − | <EmptyState title="No changes yet"> | |
| 262 | − | Nothing has been pushed to this pull request yet, or it matches the | |
| 263 | − | branch it would merge into. | |
| 700 | + | <EmptyState title="No changes"> | |
| 701 | + | {empty ?? "Nothing has been pushed to this pull request yet, or it matches the branch it would merge into."} | |
| 264 | 702 | </EmptyState> | |
| 265 | 703 | ); | |
| 266 | 704 | } | |
| 267 | 705 | const additions = files.reduce((sum, file) => sum + file.additions, 0); | |
| 268 | 706 | const deletions = files.reduce((sum, file) => sum + file.deletions, 0); | |
| 707 | + | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| 269 | 708 | return ( | |
| 270 | − | <div className="space-y-4"> | |
| 271 | − | <div className="flex items-center gap-3 text-sm text-muted"> | |
| 272 | − | <span> | |
| 273 | − | <span className="font-medium text-fg">{files.length}</span>{" "} | |
| 274 | − | {files.length === 1 ? "file" : "files"} changed | |
| 709 | + | <div> | |
| 710 | + | <div className="sticky top-12 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> | |
| 711 | + | <span className="text-sm text-muted"> | |
| 712 | + | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 275 | 713 | </span> | |
| 276 | 714 | <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. | |
| 715 | + | <span className="flex items-center gap-2 text-xs text-muted"> | |
| 716 | + | <span className="h-1.5 w-20 overflow-hidden rounded-full bg-line"> | |
| 717 | + | <span | |
| 718 | + | className="block h-full rounded-full bg-accent transition-all" | |
| 719 | + | style={{ width: `${(viewed.size / files.length) * 100}%` }} | |
| 720 | + | /> | |
| 280 | 721 | </span> | |
| 281 | − | )} | |
| 722 | + | {viewed.size}/{files.length} viewed | |
| 723 | + | </span> | |
| 724 | + | <div className="ml-auto flex items-center gap-2"> | |
| 725 | + | <button | |
| 726 | + | type="button" | |
| 727 | + | onClick={() => setCollapsed(allCollapsed ? new Set() : new Set(files.map((file) => file.path)))} | |
| 728 | + | className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg" | |
| 729 | + | > | |
| 730 | + | {allCollapsed ? "Expand all" : "Collapse all"} | |
| 731 | + | </button> | |
| 732 | + | <div className="flex rounded-md border border-line p-0.5" role="radiogroup" aria-label="Layout"> | |
| 733 | + | {( | |
| 734 | + | [ | |
| 735 | + | ["unified", <Rows3 key="u" size={13} />, "Unified"], | |
| 736 | + | ["split", <Columns2 key="s" size={13} />, "Split"], | |
| 737 | + | ] as const | |
| 738 | + | ).map(([value, icon, label]) => ( | |
| 739 | + | <button | |
| 740 | + | key={value} | |
| 741 | + | type="button" | |
| 742 | + | role="radio" | |
| 743 | + | aria-checked={layout === value} | |
| 744 | + | onClick={() => changeLayout(value)} | |
| 745 | + | className={`flex items-center gap-1.5 rounded px-2 py-0.5 text-xs transition-colors ${ | |
| 746 | + | layout === value ? "bg-raised text-fg" : "text-muted hover:text-fg" | |
| 747 | + | }`} | |
| 748 | + | > | |
| 749 | + | {icon} | |
| 750 | + | {label} | |
| 751 | + | </button> | |
| 752 | + | ))} | |
| 753 | + | </div> | |
| 754 | + | </div> | |
| 282 | 755 | </div> | |
| 283 | − | {files.length > 1 && ( | |
| 284 | − | <ul className="rounded-xl border border-line bg-surface p-2 text-sm"> | |
| 285 | − | {files.map((file) => ( | |
| 286 | − | <li key={file.path}> | |
| 287 | − | <a | |
| 288 | − | href={`#file-${file.path}`} | |
| 289 | − | className="flex items-center gap-3 rounded-md px-2 py-1 hover:bg-raised" | |
| 290 | − | > | |
| 291 | − | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> | |
| 292 | − | {file.path} | |
| 293 | − | </span> | |
| 294 | − | <Stat additions={file.additions} deletions={file.deletions} /> | |
| 295 | − | </a> | |
| 296 | − | </li> | |
| 756 | + | <div className="flex gap-4"> | |
| 757 | + | {files.length > 1 && ( | |
| 758 | + | <aside className="sticky top-28 hidden max-h-[calc(100vh-8rem)] w-60 shrink-0 flex-col self-start xl:flex"> | |
| 759 | + | <div className="relative"> | |
| 760 | + | <Search size={13} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" /> | |
| 761 | + | <input | |
| 762 | + | ref={filterInput} | |
| 763 | + | value={filter} | |
| 764 | + | onChange={(event) => setFilter(event.target.value)} | |
| 765 | + | onKeyDown={(event) => { | |
| 766 | + | if (event.key === "Escape") { | |
| 767 | + | setFilter(""); | |
| 768 | + | event.currentTarget.blur(); | |
| 769 | + | } else if (event.key === "Enter" && shownFiles[0]) { | |
| 770 | + | jump(shownFiles[0].path); | |
| 771 | + | event.currentTarget.blur(); | |
| 772 | + | } | |
| 773 | + | }} | |
| 774 | + | placeholder="Filter files" | |
| 775 | + | autoComplete="off" | |
| 776 | + | data-1p-ignore | |
| 777 | + | className="h-8 w-full rounded-md border border-line bg-surface/60 pr-7 pl-7 text-xs outline-none placeholder:text-faint focus:border-accent-dim" | |
| 778 | + | /> | |
| 779 | + | <kbd className="absolute top-1/2 right-2 -translate-y-1/2 rounded border border-line px-1 font-mono text-[0.625rem] text-faint"> | |
| 780 | + | t | |
| 781 | + | </kbd> | |
| 782 | + | </div> | |
| 783 | + | <nav className="mt-2 min-h-0 overflow-y-auto pb-2"> | |
| 784 | + | <Tree nodes={tree} depth={0} active={active} viewed={viewed} onPick={jump} /> | |
| 785 | + | </nav> | |
| 786 | + | <p className="mt-auto border-t border-line pt-2 text-[0.6875rem] text-faint"> | |
| 787 | + | <kbd className="font-mono">j</kbd>/<kbd className="font-mono">k</kbd> next and previous file,{" "} | |
| 788 | + | <kbd className="font-mono">v</kbd> mark viewed | |
| 789 | + | </p> | |
| 790 | + | </aside> | |
| 791 | + | )} | |
| 792 | + | <div className="min-w-0 grow space-y-3"> | |
| 793 | + | {review?.canComment && ( | |
| 794 | + | <p className="text-xs text-faint">Hover a line and press the button beside its number to comment on it.</p> | |
| 795 | + | )} | |
| 796 | + | {shownFiles.map((file) => ( | |
| 797 | + | <FileSection | |
| 798 | + | key={file.path} | |
| 799 | + | file={file} | |
| 800 | + | review={review} | |
| 801 | + | layout={layout} | |
| 802 | + | collapsed={collapsed.has(file.path)} | |
| 803 | + | viewed={viewed.has(file.path)} | |
| 804 | + | onToggle={() => toggle(file.path)} | |
| 805 | + | onViewed={(on) => markViewed(file.path, on)} | |
| 806 | + | fileBase={fileBase} | |
| 807 | + | /> | |
| 297 | 808 | ))} | |
| 298 | − | </ul> | |
| 299 | − | )} | |
| 300 | − | {files.map((file) => ( | |
| 301 | − | <File key={file.path} file={file} review={review} /> | |
| 302 | − | ))} | |
| 303 | − | {truncated && ( | |
| 304 | − | <p className="text-center text-sm text-muted"> | |
| 305 | − | This change is too large to show in full. | |
| 306 | − | </p> | |
| 307 | − | )} | |
| 809 | + | {shownFiles.length === 0 && <p className="py-8 text-center text-sm text-muted">No file matches.</p>} | |
| 810 | + | {truncated && <p className="text-center text-sm text-muted">This change is too large to show in full.</p>} | |
| 811 | + | </div> | |
| 812 | + | </div> | |
| 308 | 813 | </div> | |
| 309 | 814 | ); | |
| 310 | 815 | } |
| 70 | 70 | "The agent's full session, kept with the code", | |
| 71 | 71 | ], | |
| 72 | 72 | [ | |
| 73 | + | "Is it done?", | |
| 74 | + | "Whatever the author says they ran", | |
| 75 | + | "The issue's checks, run by the forge in a clean sandbox, before anything can merge", | |
| 76 | + | ], | |
| 77 | + | [ | |
| 78 | + | "Two changes to one file", | |
| 79 | + | "A merge conflict at the end", | |
| 80 | + | "Flagged on both pull requests while the work is still under way", | |
| 81 | + | ], | |
| 82 | + | [ | |
| 83 | + | "When main moves", | |
| 84 | + | "Someone rebases by hand", | |
| 85 | + | "An agent merges it in and resolves the conflict", | |
| 86 | + | ], | |
| 87 | + | [ | |
| 88 | + | "Review", | |
| 89 | + | "A person reads every diff", | |
| 90 | + | "Agents review first, on the lines that matter; people decide", | |
| 91 | + | ], | |
| 92 | + | [ | |
| 73 | 93 | "Connecting an agent", | |
| 74 | 94 | "A vendor integration", | |
| 75 | 95 | "Any MCP client, or plain HTTP", | |
| 76 | 96 | ], | |
| 77 | 97 | ]; | |
| 78 | 98 | ||
| 79 | − | const PLATFORM = ["Workers", "Artifacts", "D1", "Queues", "Rust"]; | |
| 99 | + | const PLATFORM = ["Workers", "Artifacts", "Containers", "D1", "Queues", "Rust"]; | |
| 80 | 100 | ||
| 81 | 101 | export function Landing({ repos }: { repos: Repo[] }) { | |
| 82 | 102 | return ( | |
| 201 | 221 | <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" /> | |
| 202 | 222 | <CopyLine | |
| 203 | 223 | prompt | |
| 204 | − | text="curl https://api.g1t.sh/v1/repos/syntaqx/g1t/issues" | |
| 224 | + | text="curl https://api.g1t.sh/repos/syntaqx/g1t/issues" | |
| 205 | 225 | /> | |
| 206 | 226 | <div className="pt-2"> | |
| 207 | 227 | <EventsIllustration /> | |
| 214 | 234 | <section className="mx-auto max-w-6xl px-4 py-24"> | |
| 215 | 235 | <p className="text-sm font-medium text-accent">What changes</p> | |
| 216 | 236 | <h2 className="mt-2 text-3xl font-semibold tracking-tight"> | |
| 217 | − | The same git. Different primitives. | |
| 237 | + | The same git. Built for more hands. | |
| 218 | 238 | </h2> | |
| 219 | 239 | <div className="mt-10 overflow-hidden rounded-2xl border border-line"> | |
| 220 | 240 | <div className="grid grid-cols-[1fr_1.4fr_1.4fr] border-b border-line bg-surface text-sm font-medium"> |
| 1 | + | import { Check, Hand } from "lucide-react"; | |
| 2 | + | ||
| 3 | + | import type { Lifecycle, Stage } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | const STEPS = ["Change", "Checks", "Review", "Up to date", "Landing"]; | |
| 6 | + | ||
| 7 | + | /** Which step a stage is at. A revision is the change being made again. */ | |
| 8 | + | const STEP_OF: Record<Exclude<Stage, "needs_you">, number> = { | |
| 9 | + | working: 0, | |
| 10 | + | revising: 0, | |
| 11 | + | checking: 1, | |
| 12 | + | reviewing: 2, | |
| 13 | + | catching_up: 3, | |
| 14 | + | ready: 4, | |
| 15 | + | queued: 4, | |
| 16 | + | }; | |
| 17 | + | ||
| 18 | + | /** A word or two for a stage, for lists. */ | |
| 19 | + | export const STAGE_LABEL: Record<Stage, string> = { | |
| 20 | + | working: "Making the change", | |
| 21 | + | checking: "Running checks", | |
| 22 | + | reviewing: "In review", | |
| 23 | + | revising: "Revising", | |
| 24 | + | catching_up: "Catching up", | |
| 25 | + | queued: "In the merge queue", | |
| 26 | + | ready: "Ready to merge", | |
| 27 | + | needs_you: "Needs you", | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | /** Five dots showing how far along a pull request is. */ | |
| 31 | + | export function StageDots({ stage }: { stage: Stage }) { | |
| 32 | + | if (stage === "needs_you") return null; | |
| 33 | + | const current = STEP_OF[stage]; | |
| 34 | + | return ( | |
| 35 | + | <span className="flex items-center gap-1" aria-hidden="true"> | |
| 36 | + | {STEPS.map((step, index) => ( | |
| 37 | + | <span | |
| 38 | + | key={step} | |
| 39 | + | className={`size-1.5 rounded-full ${ | |
| 40 | + | index < current || stage === "ready" | |
| 41 | + | ? "bg-accent" | |
| 42 | + | : index === current | |
| 43 | + | ? "animate-pulse bg-accent" | |
| 44 | + | : "bg-line-strong" | |
| 45 | + | }`} | |
| 46 | + | /> | |
| 47 | + | ))} | |
| 48 | + | </span> | |
| 49 | + | ); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | const TITLE: Record<Stage, string> = { | |
| 53 | + | working: "g1t is seeing this through", | |
| 54 | + | checking: "g1t is seeing this through", | |
| 55 | + | reviewing: "g1t is seeing this through", | |
| 56 | + | revising: "g1t is seeing this through", | |
| 57 | + | catching_up: "g1t is seeing this through", | |
| 58 | + | queued: "In the merge queue", | |
| 59 | + | ready: "Ready to merge", | |
| 60 | + | needs_you: "Needs you", | |
| 61 | + | }; | |
| 62 | + | ||
| 63 | + | /** | |
| 64 | + | * Where a pull request made by a g1t agent stands between "assigned" and | |
| 65 | + | * "ready to merge", and what is happening to it right now. | |
| 66 | + | */ | |
| 67 | + | export function LifecyclePanel({ lifecycle }: { lifecycle: Lifecycle }) { | |
| 68 | + | const { stage, detail, revisions } = lifecycle; | |
| 69 | + | if (stage === "needs_you") { | |
| 70 | + | return ( | |
| 71 | + | <div className="mt-4 rounded-xl border border-warn/40 bg-warn/5 px-4 py-3 text-sm"> | |
| 72 | + | <p className="flex items-center gap-2.5 font-medium"> | |
| 73 | + | <Hand size={16} className="shrink-0 text-warn" /> | |
| 74 | + | {TITLE[stage]} | |
| 75 | + | </p> | |
| 76 | + | <p className="mt-1 text-muted">{detail}</p> | |
| 77 | + | </div> | |
| 78 | + | ); | |
| 79 | + | } | |
| 80 | + | const current = STEP_OF[stage]; | |
| 81 | + | const ready = stage === "ready"; | |
| 82 | + | return ( | |
| 83 | + | <div | |
| 84 | + | className={`mt-4 rounded-xl border px-4 py-3 text-sm ${ | |
| 85 | + | ready ? "border-accent/40 bg-accent/5" : "border-line bg-surface" | |
| 86 | + | }`} | |
| 87 | + | > | |
| 88 | + | <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 89 | + | <p className="font-medium">{TITLE[stage]}</p> | |
| 90 | + | {revisions > 0 && ( | |
| 91 | + | <p className="text-xs text-faint"> | |
| 92 | + | Revised {revisions === 1 ? "once" : `${revisions} times`} | |
| 93 | + | </p> | |
| 94 | + | )} | |
| 95 | + | </div> | |
| 96 | + | <ol className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs"> | |
| 97 | + | {STEPS.map((step, index) => { | |
| 98 | + | const done = index < current || ready; | |
| 99 | + | const active = index === current && !ready; | |
| 100 | + | return ( | |
| 101 | + | <li key={step} className="flex items-center gap-2"> | |
| 102 | + | {index > 0 && ( | |
| 103 | + | <span | |
| 104 | + | aria-hidden="true" | |
| 105 | + | className={`h-px w-4 sm:w-6 ${done || active ? "bg-accent-dim" : "bg-line"}`} | |
| 106 | + | /> | |
| 107 | + | )} | |
| 108 | + | <span | |
| 109 | + | className={`flex items-center gap-1.5 ${ | |
| 110 | + | done ? "text-fg" : active ? "font-medium text-accent" : "text-faint" | |
| 111 | + | }`} | |
| 112 | + | > | |
| 113 | + | {done ? ( | |
| 114 | + | <Check size={13} className="text-accent" /> | |
| 115 | + | ) : ( | |
| 116 | + | <span | |
| 117 | + | className={`size-1.5 rounded-full ${ | |
| 118 | + | active ? "animate-pulse bg-accent" : "bg-line-strong" | |
| 119 | + | }`} | |
| 120 | + | /> | |
| 121 | + | )} | |
| 122 | + | {step} | |
| 123 | + | </span> | |
| 124 | + | </li> | |
| 125 | + | ); | |
| 126 | + | })} | |
| 127 | + | </ol> | |
| 128 | + | <p className="mt-3 text-muted">{detail}</p> | |
| 129 | + | </div> | |
| 130 | + | ); | |
| 131 | + | } |
| 1 | − | import type { ReactNode } from "react"; | |
| 1 | + | import { | |
| 2 | + | AlertTriangle, | |
| 3 | + | Check, | |
| 4 | + | Copy, | |
| 5 | + | Info, | |
| 6 | + | Lightbulb, | |
| 7 | + | Link2, | |
| 8 | + | MessageSquareWarning, | |
| 9 | + | OctagonAlert, | |
| 10 | + | } from "lucide-react"; | |
| 11 | + | import { type ReactNode, isValidElement, useEffect, useRef, useState } from "react"; | |
| 2 | 12 | import ReactMarkdown from "react-markdown"; | |
| 3 | 13 | import { Link } from "react-router"; | |
| 14 | + | import rehypeRaw from "rehype-raw"; | |
| 15 | + | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | |
| 4 | 16 | import remarkGfm from "remark-gfm"; | |
| 5 | 17 | ||
| 18 | + | import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins"; | |
| 19 | + | ||
| 20 | + | /** The text inside a React tree, for anchors and copying. */ | |
| 21 | + | function textOf(node: ReactNode): string { | |
| 22 | + | if (node == null || typeof node === "boolean") return ""; | |
| 23 | + | if (typeof node === "string" || typeof node === "number") return String(node); | |
| 24 | + | if (Array.isArray(node)) return node.map(textOf).join(""); | |
| 25 | + | if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children); | |
| 26 | + | return ""; | |
| 27 | + | } | |
| 28 | + | ||
| 6 | 29 | /** An anchor id for a heading, so sections can be linked to. */ | |
| 7 | 30 | function slug(children: ReactNode): string { | |
| 8 | − | const text = Array.isArray(children) ? children.join("") : String(children ?? ""); | |
| 9 | − | return text | |
| 31 | + | return textOf(children) | |
| 10 | 32 | .toLowerCase() | |
| 11 | 33 | .replace(/[^a-z0-9]+/g, "-") | |
| 12 | 34 | .replace(/^-|-$/g, ""); | |
| 13 | 35 | } | |
| 14 | 36 | ||
| 37 | + | /** What raw HTML may stay: GitHub's own allow-list, as `rehype-sanitize` ships it. */ | |
| 38 | + | const SCHEMA = { | |
| 39 | + | ...defaultSchema, | |
| 40 | + | attributes: { | |
| 41 | + | ...defaultSchema.attributes, | |
| 42 | + | // Fenced blocks say their language in a class. | |
| 43 | + | code: [...(defaultSchema.attributes?.code ?? []), ["className", /^language-./]], | |
| 44 | + | }, | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | const ALERT: Record<AlertKind, { title: string; icon: ReactNode; tone: string }> = { | |
| 48 | + | note: { title: "Note", icon: <Info size={15} />, tone: "border-info/60 [&_.alert-title]:text-info" }, | |
| 49 | + | tip: { title: "Tip", icon: <Lightbulb size={15} />, tone: "border-accent/60 [&_.alert-title]:text-accent" }, | |
| 50 | + | important: { | |
| 51 | + | title: "Important", | |
| 52 | + | icon: <MessageSquareWarning size={15} />, | |
| 53 | + | tone: "border-merged/60 [&_.alert-title]:text-merged", | |
| 54 | + | }, | |
| 55 | + | warning: { title: "Warning", icon: <AlertTriangle size={15} />, tone: "border-warn/60 [&_.alert-title]:text-warn" }, | |
| 56 | + | caution: { title: "Caution", icon: <OctagonAlert size={15} />, tone: "border-danger/60 [&_.alert-title]:text-danger" }, | |
| 57 | + | }; | |
| 58 | + | ||
| 59 | + | function Heading({ level, children }: { level: 1 | 2 | 3 | 4; children: ReactNode }) { | |
| 60 | + | const id = slug(children); | |
| 61 | + | const Tag = `h${level}` as const; | |
| 62 | + | return ( | |
| 63 | + | <Tag id={id} className="group relative scroll-mt-20"> | |
| 64 | + | {children} | |
| 65 | + | {id && ( | |
| 66 | + | <a | |
| 67 | + | href={`#${id}`} | |
| 68 | + | aria-label="Link to this section" | |
| 69 | + | className="ml-2 inline-flex align-middle text-faint no-underline opacity-0 transition-opacity group-hover:opacity-100 hover:text-fg" | |
| 70 | + | > | |
| 71 | + | <Link2 size={14} /> | |
| 72 | + | </a> | |
| 73 | + | )} | |
| 74 | + | </Tag> | |
| 75 | + | ); | |
| 76 | + | } | |
| 77 | + | ||
| 15 | 78 | /** | |
| 16 | − | * Renders markdown from repositories, briefs and docs. Raw HTML in the | |
| 17 | − | * source is not rendered, so untrusted content is safe to pass in. | |
| 79 | + | * A fenced code block: plain at once, coloured in the browser when its | |
| 80 | + | * language is known, with a button to copy it. | |
| 18 | 81 | */ | |
| 19 | − | export function Markdown({ source }: { source: string }) { | |
| 82 | + | function CodeBlock({ language, code }: { language: string | null; code: string }) { | |
| 83 | + | const [html, setHtml] = useState<string[] | null>(null); | |
| 84 | + | const [copied, setCopied] = useState(false); | |
| 85 | + | const block = useRef<HTMLPreElement>(null); | |
| 86 | + | useEffect(() => { | |
| 87 | + | if (!language) return; | |
| 88 | + | let cancelled = false; | |
| 89 | + | const element = block.current; | |
| 90 | + | if (!element) return; | |
| 91 | + | const observer = new IntersectionObserver( | |
| 92 | + | ([entry]) => { | |
| 93 | + | if (!entry?.isIntersecting) return; | |
| 94 | + | observer.disconnect(); | |
| 95 | + | void import("../lib/shiki") | |
| 96 | + | .then(async ({ getHighlighter, languageNamed, linesToHtml }) => { | |
| 97 | + | const lang = languageNamed(language); | |
| 98 | + | if (!lang) return null; | |
| 99 | + | return linesToHtml(await getHighlighter(), code, lang); | |
| 100 | + | }) | |
| 101 | + | .then((lines) => { | |
| 102 | + | if (!cancelled && lines) setHtml(lines); | |
| 103 | + | }) | |
| 104 | + | .catch(() => {}); | |
| 105 | + | }, | |
| 106 | + | { rootMargin: "400px 0px" }, | |
| 107 | + | ); | |
| 108 | + | observer.observe(element); | |
| 109 | + | return () => { | |
| 110 | + | cancelled = true; | |
| 111 | + | observer.disconnect(); | |
| 112 | + | }; | |
| 113 | + | }, [language, code]); | |
| 20 | 114 | return ( | |
| 115 | + | <div className="group relative"> | |
| 116 | + | <pre ref={block}> | |
| 117 | + | <code> | |
| 118 | + | {html | |
| 119 | + | ? html.map((line, index) => ( | |
| 120 | + | <span key={index} className="block" dangerouslySetInnerHTML={{ __html: line || " " }} /> | |
| 121 | + | )) | |
| 122 | + | : code} | |
| 123 | + | </code> | |
| 124 | + | </pre> | |
| 125 | + | <div className="absolute top-2 right-2 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"> | |
| 126 | + | {language && <span className="font-mono text-[0.6875rem] text-faint">{language}</span>} | |
| 127 | + | <button | |
| 128 | + | type="button" | |
| 129 | + | aria-label="Copy" | |
| 130 | + | onClick={() => { | |
| 131 | + | void navigator.clipboard?.writeText(code).then(() => { | |
| 132 | + | setCopied(true); | |
| 133 | + | setTimeout(() => setCopied(false), 1500); | |
| 134 | + | }); | |
| 135 | + | }} | |
| 136 | + | className="rounded-md border border-line bg-raised p-1.5 text-muted hover:text-fg" | |
| 137 | + | > | |
| 138 | + | {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />} | |
| 139 | + | </button> | |
| 140 | + | </div> | |
| 141 | + | </div> | |
| 142 | + | ); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | function isExternal(href: string) { | |
| 146 | + | return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//"); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /** | |
| 150 | + | * Renders markdown the way people expect from a forge: GitHub flavoured | |
| 151 | + | * markdown (tables, task lists, footnotes, strikethrough, autolinks), the | |
| 152 | + | * HTML GitHub allows, alerts, highlighted code, heading anchors, and | |
| 153 | + | * references (`#12`, `owner/repo#12`, `@name`, commit hashes) linked | |
| 154 | + | * within `repo`. Anything an author writes is sanitized, so untrusted | |
| 155 | + | * content is safe to pass in. | |
| 156 | + | */ | |
| 157 | + | export function Markdown({ | |
| 158 | + | source, | |
| 159 | + | repo, | |
| 160 | + | base, | |
| 161 | + | }: { | |
| 162 | + | source: string; | |
| 163 | + | /** The repository the text belongs to, for its references. */ | |
| 164 | + | repo?: MarkdownRepo; | |
| 165 | + | /** Where relative links point, e.g. `/acme/web/blob/main/docs` for a file's own folder. */ | |
| 166 | + | base?: string; | |
| 167 | + | }) { | |
| 168 | + | return ( | |
| 21 | 169 | <div className="prose"> | |
| 22 | 170 | <ReactMarkdown | |
| 23 | 171 | remarkPlugins={[remarkGfm]} | |
| 172 | + | rehypePlugins={[rehypeRaw, [rehypeSanitize, SCHEMA], rehypeAlerts, [rehypeReferences, { repo }]]} | |
| 24 | 173 | components={{ | |
| 25 | − | h2: ({ children }) => <h2 id={slug(children)}>{children}</h2>, | |
| 26 | − | h3: ({ children }) => <h3 id={slug(children)}>{children}</h3>, | |
| 27 | − | a({ href, children }) { | |
| 28 | − | // In-site links navigate on the client. | |
| 29 | − | return href?.startsWith("/") ? ( | |
| 30 | − | <Link to={href}>{children}</Link> | |
| 31 | − | ) : ( | |
| 32 | − | <a href={href} rel="noreferrer nofollow"> | |
| 33 | − | {children} | |
| 34 | − | </a> | |
| 174 | + | h1: ({ children }) => <Heading level={1}>{children}</Heading>, | |
| 175 | + | h2: ({ children }) => <Heading level={2}>{children}</Heading>, | |
| 176 | + | h3: ({ children }) => <Heading level={3}>{children}</Heading>, | |
| 177 | + | h4: ({ children }) => <Heading level={4}>{children}</Heading>, | |
| 178 | + | a({ href = "", children, node }) { | |
| 179 | + | const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef; | |
| 180 | + | if (ref) { | |
| 181 | + | return ( | |
| 182 | + | <Link | |
| 183 | + | to={href} | |
| 184 | + | prefetch="intent" | |
| 185 | + | className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""} | |
| 186 | + | > | |
| 187 | + | {children} | |
| 188 | + | </Link> | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | if (href.startsWith("#")) return <a href={href}>{children}</a>; | |
| 192 | + | if (isExternal(href)) { | |
| 193 | + | return ( | |
| 194 | + | <a href={href} rel="noreferrer nofollow ugc" target="_blank"> | |
| 195 | + | {children} | |
| 196 | + | </a> | |
| 197 | + | ); | |
| 198 | + | } | |
| 199 | + | // A link within the site, or relative to the document's folder. | |
| 200 | + | const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`; | |
| 201 | + | return <Link to={to}>{children}</Link>; | |
| 202 | + | }, | |
| 203 | + | blockquote({ children, node }) { | |
| 204 | + | const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert; | |
| 205 | + | if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>; | |
| 206 | + | const alert = ALERT[kind]; | |
| 207 | + | return ( | |
| 208 | + | <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}> | |
| 209 | + | <p className="alert-title flex items-center gap-2 text-sm font-medium"> | |
| 210 | + | {alert.icon} | |
| 211 | + | {alert.title} | |
| 212 | + | </p> | |
| 213 | + | <div className="mt-1 [&>*+*]:mt-3">{children}</div> | |
| 214 | + | </div> | |
| 35 | 215 | ); | |
| 36 | 216 | }, | |
| 217 | + | pre({ children }) { | |
| 218 | + | const code = Array.isArray(children) ? children[0] : children; | |
| 219 | + | if (isValidElement<{ className?: string; children?: ReactNode }>(code)) { | |
| 220 | + | const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null; | |
| 221 | + | return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />; | |
| 222 | + | } | |
| 223 | + | return <pre>{children}</pre>; | |
| 224 | + | }, | |
| 225 | + | input({ type, checked, disabled }) { | |
| 226 | + | // Task list boxes: shown, not editable. | |
| 227 | + | return type === "checkbox" ? ( | |
| 228 | + | <input type="checkbox" checked={checked} disabled={disabled} readOnly className="mr-1.5 translate-y-0.5 accent-accent" /> | |
| 229 | + | ) : null; | |
| 230 | + | }, | |
| 231 | + | img({ src, alt }) { | |
| 232 | + | return <img src={typeof src === "string" ? src : undefined} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; | |
| 233 | + | }, | |
| 37 | 234 | }} | |
| 38 | 235 | > | |
| 39 | 236 | {source} |
| 1 | − | import { BookMarked, Lock } from "lucide-react"; | |
| 1 | + | import { BookMarked, CircleDot, GitPullRequest, Lock } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Repo } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { EmptyState, Pill, TimeAgo } from "./ui"; | |
| 7 | 7 | ||
| 8 | − | export function RepoList({ repos }: { repos: Repo[] }) { | |
| 8 | + | export function RepoList({ | |
| 9 | + | repos, | |
| 10 | + | open, | |
| 11 | + | }: { | |
| 12 | + | repos: Repo[]; | |
| 13 | + | /** Open issues and pull requests by repository id, where known. */ | |
| 14 | + | open?: Record<string, { issues: number; pulls: number }>; | |
| 15 | + | }) { | |
| 9 | 16 | if (repos.length === 0) { | |
| 10 | 17 | return ( | |
| 11 | 18 | <div className="mt-4"> | |
| 18 | 25 | {repos.map((repo) => ( | |
| 19 | 26 | <li key={repo.id}> | |
| 20 | 27 | <Link | |
| 28 | + | prefetch="intent" | |
| 21 | 29 | to={`/${repo.namespace}/${repo.name}`} | |
| 22 | 30 | className="flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong" | |
| 23 | 31 | > | |
| 36 | 44 | <span className="mt-2 line-clamp-2 grow text-sm text-muted"> | |
| 37 | 45 | {repo.description ?? "No description."} | |
| 38 | 46 | </span> | |
| 39 | − | <span className="mt-3 text-xs text-faint"> | |
| 40 | − | Created <TimeAgo at={repo.createdAt} /> | |
| 47 | + | <span className="mt-3 flex items-center gap-3 text-xs text-faint"> | |
| 48 | + | <span className="grow"> | |
| 49 | + | Created <TimeAgo at={repo.createdAt} /> | |
| 50 | + | </span> | |
| 51 | + | {open?.[repo.id] && ( | |
| 52 | + | <> | |
| 53 | + | <span className="flex items-center gap-1" title="Open issues"> | |
| 54 | + | <CircleDot size={12} /> | |
| 55 | + | {open[repo.id].issues} | |
| 56 | + | </span> | |
| 57 | + | <span className="flex items-center gap-1" title="Pull requests in progress"> | |
| 58 | + | <GitPullRequest size={12} /> | |
| 59 | + | {open[repo.id].pulls} | |
| 60 | + | </span> | |
| 61 | + | </> | |
| 62 | + | )} | |
| 41 | 63 | </span> | |
| 42 | 64 | </Link> | |
| 43 | 65 | </li> |
| 1 | 1 | import { BookOpen, File, Folder, FolderGit2 } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { BlobView as Blob, Commit, TreeView as Tree } from "@g1t/contracts"; | |
| 4 | + | import type { Blame, BlobView as Blob, Commit, TreeView as Tree } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | + | import { BlameView } from "./blame-view"; | |
| 7 | + | ||
| 6 | 8 | import { CloneBox } from "./clone-box"; | |
| 7 | 9 | import { Markdown } from "./markdown"; | |
| 8 | 10 | import { Avatar, CopyLine, TimeAgo } from "./ui"; | |
| 129 | 131 | </h2> | |
| 130 | 132 | <div className="p-6"> | |
| 131 | 133 | {/\.(md|markdown)$/i.test(readme.name) ? ( | |
| 132 | − | <Markdown source={readme.text} /> | |
| 134 | + | <Markdown | |
| 135 | + | source={readme.text} | |
| 136 | + | repo={{ namespace: repo.namespace, name: repo.name }} | |
| 137 | + | // Relative links in a README point into the repository. | |
| 138 | + | base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`} | |
| 139 | + | /> | |
| 133 | 140 | ) : ( | |
| 134 | 141 | <pre className="whitespace-pre-wrap text-sm">{readme.text}</pre> | |
| 135 | 142 | )} | |
| 164 | 171 | export function BlobView({ | |
| 165 | 172 | blob, | |
| 166 | 173 | html, | |
| 174 | + | blame, | |
| 167 | 175 | }: { | |
| 168 | 176 | blob: Blob; | |
| 169 | 177 | /** Syntax-highlighted HTML, when the language is known. */ | |
| 170 | 178 | html: string | null; | |
| 179 | + | /** Shown instead of the plain file when asked for, with HTML per line. */ | |
| 180 | + | blame?: { blame: Blame; lines: string[] | null } | null; | |
| 171 | 181 | }) { | |
| 172 | 182 | const { repo, ref, path, size, text } = blob; | |
| 173 | 183 | const lines = text?.replace(/\n$/, "").split("\n"); | |
| 184 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 185 | + | const toggle = (label: string, on: boolean, search: string) => ( | |
| 186 | + | <Link | |
| 187 | + | to={{ search }} | |
| 188 | + | preventScrollReset | |
| 189 | + | className={`rounded px-2 py-0.5 transition-colors ${on ? "bg-raised text-fg" : "hover:text-fg"}`} | |
| 190 | + | > | |
| 191 | + | {label} | |
| 192 | + | </Link> | |
| 193 | + | ); | |
| 174 | 194 | return ( | |
| 175 | 195 | <div> | |
| 176 | 196 | <Breadcrumbs | |
| 183 | 203 | <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted"> | |
| 184 | 204 | {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>} | |
| 185 | 205 | <span>{size.toLocaleString("en-US")} bytes</span> | |
| 206 | + | {lines && ( | |
| 207 | + | <span className="ml-auto flex rounded-md border border-line p-0.5"> | |
| 208 | + | {toggle("Code", !blame, "")} | |
| 209 | + | {toggle("Blame", Boolean(blame), "?blame=1")} | |
| 210 | + | </span> | |
| 211 | + | )} | |
| 186 | 212 | </div> | |
| 187 | − | {html ? ( | |
| 213 | + | {blame && lines ? ( | |
| 214 | + | <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} /> | |
| 215 | + | ) : html ? ( | |
| 188 | 216 | <div | |
| 189 | 217 | className="overflow-x-auto py-3 pr-4" | |
| 190 | 218 | // Shiki escapes the source; this is its generated markup. |
| 1 | + | import { | |
| 2 | + | BookMarked, | |
| 3 | + | BookOpen, | |
| 4 | + | Check, | |
| 5 | + | ChevronsUpDown, | |
| 6 | + | CircleDot, | |
| 7 | + | Code2, | |
| 8 | + | Compass, | |
| 9 | + | CornerDownLeft, | |
| 10 | + | CreditCard, | |
| 11 | + | GitPullRequest, | |
| 12 | + | History, | |
| 13 | + | KeyRound, | |
| 14 | + | Layers, | |
| 15 | + | LayoutDashboard, | |
| 16 | + | ListTree, | |
| 17 | + | Lock, | |
| 18 | + | LogOut, | |
| 19 | + | Menu, | |
| 20 | + | Plus, | |
| 21 | + | Search, | |
| 22 | + | Settings, | |
| 23 | + | Users, | |
| 24 | + | X, | |
| 25 | + | } from "lucide-react"; | |
| 26 | + | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 27 | + | import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router"; | |
| 28 | + | ||
| 29 | + | import type { User } from "@g1t/contracts"; | |
| 30 | + | import { MICROS_PER_DOLLAR } from "@g1t/contracts"; | |
| 31 | + | ||
| 32 | + | import { Logo } from "./logo"; | |
| 33 | + | import { Avatar } from "./ui"; | |
| 34 | + | import { | |
| 35 | + | DropdownMenu, | |
| 36 | + | DropdownMenuContent, | |
| 37 | + | DropdownMenuItem, | |
| 38 | + | DropdownMenuLabel, | |
| 39 | + | DropdownMenuSeparator, | |
| 40 | + | DropdownMenuTrigger, | |
| 41 | + | } from "./ui/dropdown-menu"; | |
| 42 | + | ||
| 43 | + | /** What the sidebar needs, worked out by the root loader for a signed-in person. */ | |
| 44 | + | export type ShellData = { | |
| 45 | + | /** The workspace the sidebar is about: the one being looked at, or their first. */ | |
| 46 | + | workspace: { slug: string; role: "owner" | "member" } | null; | |
| 47 | + | /** Its repositories, newest first. */ | |
| 48 | + | repos: { namespace: string; name: string; isPrivate: boolean }[]; | |
| 49 | + | /** The repository being looked at, if any, whoever owns it. */ | |
| 50 | + | repo: { | |
| 51 | + | namespace: string; | |
| 52 | + | name: string; | |
| 53 | + | member: boolean; | |
| 54 | + | issues: number; | |
| 55 | + | pulls: number; | |
| 56 | + | } | null; | |
| 57 | + | /** The workspace's agent credit, if billing is on and they may see it. */ | |
| 58 | + | creditMicros: number | null; | |
| 59 | + | }; | |
| 60 | + | ||
| 61 | + | function SidebarLink({ | |
| 62 | + | to, | |
| 63 | + | icon, | |
| 64 | + | end, | |
| 65 | + | count, | |
| 66 | + | also, | |
| 67 | + | children, | |
| 68 | + | }: { | |
| 69 | + | to: string; | |
| 70 | + | icon: ReactNode; | |
| 71 | + | end?: boolean; | |
| 72 | + | count?: number; | |
| 73 | + | /** Another path prefix under which this link is the current one. */ | |
| 74 | + | also?: string; | |
| 75 | + | children: ReactNode; | |
| 76 | + | }) { | |
| 77 | + | const { pathname } = useLocation(); | |
| 78 | + | return ( | |
| 79 | + | <NavLink | |
| 80 | + | to={to} | |
| 81 | + | end={end} | |
| 82 | + | prefetch="intent" | |
| 83 | + | className={({ isActive, isPending }) => { | |
| 84 | + | const current = isActive || (also != null && pathname.startsWith(also + "/")); | |
| 85 | + | return `group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors ${ | |
| 86 | + | current | |
| 87 | + | ? "bg-raised font-medium text-fg" | |
| 88 | + | : isPending | |
| 89 | + | ? "bg-raised/60 text-fg" | |
| 90 | + | : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 91 | + | }`; | |
| 92 | + | }} | |
| 93 | + | > | |
| 94 | + | <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span> | |
| 95 | + | <span className="min-w-0 grow truncate">{children}</span> | |
| 96 | + | {count != null && count > 0 && ( | |
| 97 | + | <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted"> | |
| 98 | + | {count} | |
| 99 | + | </span> | |
| 100 | + | )} | |
| 101 | + | </NavLink> | |
| 102 | + | ); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { | |
| 106 | + | return ( | |
| 107 | + | <section className="mt-5"> | |
| 108 | + | <div className="mb-1 flex items-center justify-between px-2"> | |
| 109 | + | <h2 className="text-[0.6875rem] font-medium tracking-wider text-faint uppercase"> | |
| 110 | + | {title} | |
| 111 | + | </h2> | |
| 112 | + | {action} | |
| 113 | + | </div> | |
| 114 | + | <div className="space-y-px">{children}</div> | |
| 115 | + | </section> | |
| 116 | + | ); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) { | |
| 120 | + | const current = shell.workspace?.slug; | |
| 121 | + | return ( | |
| 122 | + | <DropdownMenu> | |
| 123 | + | <DropdownMenuTrigger className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"> | |
| 124 | + | {current ? ( | |
| 125 | + | <Avatar name={current} size={26} square /> | |
| 126 | + | ) : ( | |
| 127 | + | <span className="size-6.5 rounded-md border border-dashed border-line-strong" /> | |
| 128 | + | )} | |
| 129 | + | <span className="min-w-0 grow"> | |
| 130 | + | <span className="block truncate text-sm font-semibold">{current ?? "No workspace"}</span> | |
| 131 | + | <span className="block truncate text-xs text-faint"> | |
| 132 | + | {shell.workspace?.role ?? "Create one to start"} | |
| 133 | + | </span> | |
| 134 | + | </span> | |
| 135 | + | <ChevronsUpDown size={14} className="shrink-0 text-faint" /> | |
| 136 | + | </DropdownMenuTrigger> | |
| 137 | + | <DropdownMenuContent align="start" className="w-60"> | |
| 138 | + | <DropdownMenuLabel>Workspaces</DropdownMenuLabel> | |
| 139 | + | {(user.workspaces ?? []).map((membership) => ( | |
| 140 | + | <DropdownMenuItem asChild key={membership.slug}> | |
| 141 | + | <Link to={`/${membership.slug}`}> | |
| 142 | + | <Avatar name={membership.slug} size={16} square /> | |
| 143 | + | <span className="grow">{membership.slug}</span> | |
| 144 | + | {membership.slug === current && <Check className="text-accent" />} | |
| 145 | + | </Link> | |
| 146 | + | </DropdownMenuItem> | |
| 147 | + | ))} | |
| 148 | + | <DropdownMenuSeparator /> | |
| 149 | + | <DropdownMenuItem asChild> | |
| 150 | + | <Link to="/workspaces/new"> | |
| 151 | + | <Plus /> | |
| 152 | + | New workspace | |
| 153 | + | </Link> | |
| 154 | + | </DropdownMenuItem> | |
| 155 | + | </DropdownMenuContent> | |
| 156 | + | </DropdownMenu> | |
| 157 | + | ); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | function Sidebar({ user, shell }: { user: User; shell: ShellData }) { | |
| 161 | + | const ws = shell.workspace; | |
| 162 | + | const active = shell.repo; | |
| 163 | + | const repoBase = active ? `/${active.namespace}/${active.name}` : null; | |
| 164 | + | // The repository being looked at is listed even when it is someone else's. | |
| 165 | + | const listed = | |
| 166 | + | active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name) | |
| 167 | + | ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos] | |
| 168 | + | : shell.repos; | |
| 169 | + | return ( | |
| 170 | + | <div className="flex h-full flex-col"> | |
| 171 | + | <div className="flex h-12 items-center gap-2 px-4"> | |
| 172 | + | <Link to="/" aria-label="g1t home"> | |
| 173 | + | <Logo /> | |
| 174 | + | </Link> | |
| 175 | + | </div> | |
| 176 | + | <div className="px-2"> | |
| 177 | + | <WorkspaceSwitcher user={user} shell={shell} /> | |
| 178 | + | </div> | |
| 179 | + | <nav className="min-h-0 grow overflow-y-auto px-2 pb-4"> | |
| 180 | + | <div className="mt-3 space-y-px"> | |
| 181 | + | <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}> | |
| 182 | + | Mission control | |
| 183 | + | </SidebarLink> | |
| 184 | + | <SidebarLink to="/explore" icon={<Compass size={15} />}> | |
| 185 | + | Explore | |
| 186 | + | </SidebarLink> | |
| 187 | + | </div> | |
| 188 | + | ||
| 189 | + | {ws && ( | |
| 190 | + | <SidebarGroup title="Workspace"> | |
| 191 | + | <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}> | |
| 192 | + | Overview | |
| 193 | + | </SidebarLink> | |
| 194 | + | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 195 | + | People | |
| 196 | + | </SidebarLink> | |
| 197 | + | <SidebarLink to={`/${ws.slug}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 198 | + | Access tokens | |
| 199 | + | </SidebarLink> | |
| 200 | + | <SidebarLink to={`/${ws.slug}/-/billing`} icon={<CreditCard size={15} />}> | |
| 201 | + | Billing | |
| 202 | + | </SidebarLink> | |
| 203 | + | {ws.role === "owner" && ( | |
| 204 | + | <SidebarLink to={`/${ws.slug}/-/settings`} icon={<Settings size={15} />}> | |
| 205 | + | Settings | |
| 206 | + | </SidebarLink> | |
| 207 | + | )} | |
| 208 | + | </SidebarGroup> | |
| 209 | + | )} | |
| 210 | + | ||
| 211 | + | <SidebarGroup | |
| 212 | + | title="Repositories" | |
| 213 | + | action={ | |
| 214 | + | ws && ( | |
| 215 | + | <Link | |
| 216 | + | to={`/new?workspace=${ws.slug}`} | |
| 217 | + | aria-label="New repository" | |
| 218 | + | className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg" | |
| 219 | + | > | |
| 220 | + | <Plus size={13} /> | |
| 221 | + | </Link> | |
| 222 | + | ) | |
| 223 | + | } | |
| 224 | + | > | |
| 225 | + | {listed.length === 0 && ( | |
| 226 | + | <p className="px-2 py-1 text-xs text-faint">None yet.</p> | |
| 227 | + | )} | |
| 228 | + | {listed.map((repo) => { | |
| 229 | + | const open = | |
| 230 | + | active && repo.namespace === active.namespace && repo.name === active.name; | |
| 231 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 232 | + | return ( | |
| 233 | + | <div key={base}> | |
| 234 | + | <SidebarLink | |
| 235 | + | to={base} | |
| 236 | + | end={!open} | |
| 237 | + | icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />} | |
| 238 | + | > | |
| 239 | + | <span className="font-mono text-[0.8125rem]"> | |
| 240 | + | {repo.namespace !== ws?.slug && ( | |
| 241 | + | <span className="text-faint">{repo.namespace}/</span> | |
| 242 | + | )} | |
| 243 | + | {repo.name} | |
| 244 | + | </span> | |
| 245 | + | </SidebarLink> | |
| 246 | + | {open && repoBase && ( | |
| 247 | + | <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2"> | |
| 248 | + | <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}> | |
| 249 | + | Code | |
| 250 | + | </SidebarLink> | |
| 251 | + | <SidebarLink | |
| 252 | + | to={`${repoBase}/issues`} | |
| 253 | + | icon={<CircleDot size={14} />} | |
| 254 | + | count={active.issues} | |
| 255 | + | > | |
| 256 | + | Issues | |
| 257 | + | </SidebarLink> | |
| 258 | + | <SidebarLink | |
| 259 | + | to={`${repoBase}/pulls`} | |
| 260 | + | also={`${repoBase}/pull`} | |
| 261 | + | icon={<GitPullRequest size={14} />} | |
| 262 | + | count={active.pulls} | |
| 263 | + | > | |
| 264 | + | Pull requests | |
| 265 | + | </SidebarLink> | |
| 266 | + | <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}> | |
| 267 | + | Merge queue | |
| 268 | + | </SidebarLink> | |
| 269 | + | <SidebarLink | |
| 270 | + | to={`${repoBase}/commits`} | |
| 271 | + | also={`${repoBase}/commit`} | |
| 272 | + | icon={<History size={14} />} | |
| 273 | + | > | |
| 274 | + | Commits | |
| 275 | + | </SidebarLink> | |
| 276 | + | {active.member && ( | |
| 277 | + | <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}> | |
| 278 | + | Plan | |
| 279 | + | </SidebarLink> | |
| 280 | + | )} | |
| 281 | + | {active.member && ( | |
| 282 | + | <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}> | |
| 283 | + | Settings | |
| 284 | + | </SidebarLink> | |
| 285 | + | )} | |
| 286 | + | </div> | |
| 287 | + | )} | |
| 288 | + | </div> | |
| 289 | + | ); | |
| 290 | + | })} | |
| 291 | + | </SidebarGroup> | |
| 292 | + | </nav> | |
| 293 | + | ||
| 294 | + | <div className="border-t border-line p-2"> | |
| 295 | + | {ws && shell.creditMicros != null && ( | |
| 296 | + | <Link | |
| 297 | + | to={`/${ws.slug}/-/billing`} | |
| 298 | + | className={`mb-1 flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-raised ${ | |
| 299 | + | shell.creditMicros <= 0 ? "text-warn" : "text-muted" | |
| 300 | + | }`} | |
| 301 | + | > | |
| 302 | + | <CreditCard size={15} className="shrink-0" /> | |
| 303 | + | <span className="grow">Agent credit</span> | |
| 304 | + | <span className="font-mono text-xs tabular-nums"> | |
| 305 | + | ${(shell.creditMicros / MICROS_PER_DOLLAR).toFixed(2)} | |
| 306 | + | </span> | |
| 307 | + | </Link> | |
| 308 | + | )} | |
| 309 | + | <a | |
| 310 | + | href="https://docs.g1t.sh/" | |
| 311 | + | className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg" | |
| 312 | + | > | |
| 313 | + | <BookOpen size={15} className="shrink-0 text-faint" /> | |
| 314 | + | Documentation | |
| 315 | + | </a> | |
| 316 | + | </div> | |
| 317 | + | </div> | |
| 318 | + | ); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | type Command = { label: string; hint?: string; to: string; icon: ReactNode }; | |
| 322 | + | ||
| 323 | + | /** Everything the palette can jump to, from what the sidebar already knows. */ | |
| 324 | + | function commandsFor(user: User, shell: ShellData): Command[] { | |
| 325 | + | const commands: Command[] = [ | |
| 326 | + | { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> }, | |
| 327 | + | { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> }, | |
| 328 | + | { label: "New repository", to: "/new", icon: <Plus size={15} /> }, | |
| 329 | + | { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> }, | |
| 330 | + | { label: "Your settings", to: "/settings", icon: <Settings size={15} /> }, | |
| 331 | + | ]; | |
| 332 | + | const repo = shell.repo; | |
| 333 | + | if (repo) { | |
| 334 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 335 | + | const name = `${repo.namespace}/${repo.name}`; | |
| 336 | + | commands.unshift( | |
| 337 | + | { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> }, | |
| 338 | + | { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> }, | |
| 339 | + | { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> }, | |
| 340 | + | { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> }, | |
| 341 | + | ...(repo.member | |
| 342 | + | ? [ | |
| 343 | + | { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> }, | |
| 344 | + | { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> }, | |
| 345 | + | ] | |
| 346 | + | : []), | |
| 347 | + | ); | |
| 348 | + | } | |
| 349 | + | for (const membership of user.workspaces ?? []) { | |
| 350 | + | commands.push( | |
| 351 | + | { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> }, | |
| 352 | + | { label: "Billing", hint: membership.slug, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| 353 | + | { label: "Access tokens", hint: membership.slug, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> }, | |
| 354 | + | ); | |
| 355 | + | } | |
| 356 | + | for (const listed of shell.repos) { | |
| 357 | + | commands.push({ | |
| 358 | + | label: `${listed.namespace}/${listed.name}`, | |
| 359 | + | hint: "Repository", | |
| 360 | + | to: `/${listed.namespace}/${listed.name}`, | |
| 361 | + | icon: <BookMarked size={15} />, | |
| 362 | + | }); | |
| 363 | + | } | |
| 364 | + | return commands; | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | function CommandPalette({ | |
| 368 | + | open, | |
| 369 | + | onClose, | |
| 370 | + | commands, | |
| 371 | + | }: { | |
| 372 | + | open: boolean; | |
| 373 | + | onClose: () => void; | |
| 374 | + | commands: Command[]; | |
| 375 | + | }) { | |
| 376 | + | const navigate = useNavigate(); | |
| 377 | + | const [query, setQuery] = useState(""); | |
| 378 | + | const [selected, setSelected] = useState(0); | |
| 379 | + | const input = useRef<HTMLInputElement>(null); | |
| 380 | + | const matches = useMemo(() => { | |
| 381 | + | const words = query.toLowerCase().split(/\s+/).filter(Boolean); | |
| 382 | + | return commands | |
| 383 | + | .filter((command) => { | |
| 384 | + | const text = `${command.label} ${command.hint ?? ""}`.toLowerCase(); | |
| 385 | + | return words.every((word) => text.includes(word)); | |
| 386 | + | }) | |
| 387 | + | .slice(0, 12); | |
| 388 | + | }, [commands, query]); | |
| 389 | + | ||
| 390 | + | useEffect(() => { | |
| 391 | + | if (!open) return; | |
| 392 | + | setQuery(""); | |
| 393 | + | setSelected(0); | |
| 394 | + | input.current?.focus(); | |
| 395 | + | }, [open]); | |
| 396 | + | useEffect(() => setSelected(0), [query]); | |
| 397 | + | ||
| 398 | + | if (!open) return null; | |
| 399 | + | const go = (command: Command | undefined) => { | |
| 400 | + | if (!command) return; | |
| 401 | + | onClose(); | |
| 402 | + | navigate(command.to); | |
| 403 | + | }; | |
| 404 | + | return ( | |
| 405 | + | <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}> | |
| 406 | + | <div | |
| 407 | + | role="dialog" | |
| 408 | + | aria-label="Go to" | |
| 409 | + | className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60" | |
| 410 | + | onClick={(event) => event.stopPropagation()} | |
| 411 | + | > | |
| 412 | + | <div className="flex items-center gap-3 border-b border-line px-4"> | |
| 413 | + | <Search size={16} className="text-faint" /> | |
| 414 | + | <input | |
| 415 | + | ref={input} | |
| 416 | + | value={query} | |
| 417 | + | onChange={(event) => setQuery(event.target.value)} | |
| 418 | + | onKeyDown={(event) => { | |
| 419 | + | if (event.key === "ArrowDown") { | |
| 420 | + | event.preventDefault(); | |
| 421 | + | setSelected((index) => Math.min(index + 1, matches.length - 1)); | |
| 422 | + | } else if (event.key === "ArrowUp") { | |
| 423 | + | event.preventDefault(); | |
| 424 | + | setSelected((index) => Math.max(index - 1, 0)); | |
| 425 | + | } else if (event.key === "Enter") { | |
| 426 | + | event.preventDefault(); | |
| 427 | + | go(matches[selected]); | |
| 428 | + | } else if (event.key === "Escape") { | |
| 429 | + | onClose(); | |
| 430 | + | } | |
| 431 | + | }} | |
| 432 | + | placeholder="Go to a repository, a page, or an action…" | |
| 433 | + | autoComplete="off" | |
| 434 | + | data-1p-ignore | |
| 435 | + | className="h-12 grow border-0 bg-transparent text-sm shadow-none outline-none ring-0 placeholder:text-faint focus:outline-none focus-visible:outline-none" | |
| 436 | + | /> | |
| 437 | + | <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd> | |
| 438 | + | </div> | |
| 439 | + | <ul className="max-h-[50vh] overflow-y-auto p-1.5"> | |
| 440 | + | {matches.length === 0 && ( | |
| 441 | + | <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li> | |
| 442 | + | )} | |
| 443 | + | {matches.map((command, index) => ( | |
| 444 | + | <li key={`${command.to}-${command.label}`}> | |
| 445 | + | <button | |
| 446 | + | type="button" | |
| 447 | + | onMouseEnter={() => setSelected(index)} | |
| 448 | + | onClick={() => go(command)} | |
| 449 | + | className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${ | |
| 450 | + | index === selected ? "bg-line text-fg" : "text-muted" | |
| 451 | + | }`} | |
| 452 | + | > | |
| 453 | + | <span className="shrink-0 text-faint">{command.icon}</span> | |
| 454 | + | <span className="min-w-0 grow truncate">{command.label}</span> | |
| 455 | + | {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>} | |
| 456 | + | {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />} | |
| 457 | + | </button> | |
| 458 | + | </li> | |
| 459 | + | ))} | |
| 460 | + | </ul> | |
| 461 | + | </div> | |
| 462 | + | </div> | |
| 463 | + | ); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /** | |
| 467 | + | * A bar across the top of the page while the next one loads, as GitHub | |
| 468 | + | * has: it appears at once, creeps towards the end while waiting, then fills | |
| 469 | + | * and fades when the page arrives. | |
| 470 | + | */ | |
| 471 | + | export function Progress() { | |
| 472 | + | const navigation = useNavigation(); | |
| 473 | + | const busy = navigation.state !== "idle"; | |
| 474 | + | const [width, setWidth] = useState(0); | |
| 475 | + | const [visible, setVisible] = useState(false); | |
| 476 | + | useEffect(() => { | |
| 477 | + | if (busy) { | |
| 478 | + | setVisible(true); | |
| 479 | + | setWidth(12); | |
| 480 | + | // Slows as it goes, never quite arriving until the page does. | |
| 481 | + | const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200); | |
| 482 | + | return () => clearInterval(timer); | |
| 483 | + | } | |
| 484 | + | setWidth((now) => (now > 0 ? 100 : 0)); | |
| 485 | + | const fade = setTimeout(() => setVisible(false), 250); | |
| 486 | + | const reset = setTimeout(() => setWidth(0), 550); | |
| 487 | + | return () => { | |
| 488 | + | clearTimeout(fade); | |
| 489 | + | clearTimeout(reset); | |
| 490 | + | }; | |
| 491 | + | }, [busy]); | |
| 492 | + | return ( | |
| 493 | + | <div | |
| 494 | + | aria-hidden="true" | |
| 495 | + | className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${ | |
| 496 | + | visible ? "opacity-100" : "opacity-0" | |
| 497 | + | }`} | |
| 498 | + | > | |
| 499 | + | <div | |
| 500 | + | className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out" | |
| 501 | + | style={{ width: `${width}%` }} | |
| 502 | + | /> | |
| 503 | + | </div> | |
| 504 | + | ); | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | /** | |
| 508 | + | * The signed-in app: a sidebar with the workspace, its repositories and the | |
| 509 | + | * sections of the one being looked at; a slim bar with search and the | |
| 510 | + | * account; and the page. | |
| 511 | + | */ | |
| 512 | + | export function AppShell({ | |
| 513 | + | user, | |
| 514 | + | shell, | |
| 515 | + | banner, | |
| 516 | + | children, | |
| 517 | + | }: { | |
| 518 | + | user: User; | |
| 519 | + | shell: ShellData; | |
| 520 | + | banner?: ReactNode; | |
| 521 | + | children: ReactNode; | |
| 522 | + | }) { | |
| 523 | + | const submit = useSubmit(); | |
| 524 | + | const { pathname } = useLocation(); | |
| 525 | + | const [drawer, setDrawer] = useState(false); | |
| 526 | + | const [palette, setPalette] = useState(false); | |
| 527 | + | const commands = useMemo(() => commandsFor(user, shell), [user, shell]); | |
| 528 | + | ||
| 529 | + | // A new page closes the drawer on small screens. | |
| 530 | + | useEffect(() => setDrawer(false), [pathname]); | |
| 531 | + | useEffect(() => { | |
| 532 | + | const onKey = (event: KeyboardEvent) => { | |
| 533 | + | if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { | |
| 534 | + | event.preventDefault(); | |
| 535 | + | setPalette((open) => !open); | |
| 536 | + | } | |
| 537 | + | }; | |
| 538 | + | window.addEventListener("keydown", onKey); | |
| 539 | + | return () => window.removeEventListener("keydown", onKey); | |
| 540 | + | }, []); | |
| 541 | + | ||
| 542 | + | return ( | |
| 543 | + | <div className="min-h-screen"> | |
| 544 | + | <Progress /> | |
| 545 | + | <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block"> | |
| 546 | + | <Sidebar user={user} shell={shell} /> | |
| 547 | + | </aside> | |
| 548 | + | {drawer && ( | |
| 549 | + | <div className="fixed inset-0 z-50 lg:hidden"> | |
| 550 | + | <button | |
| 551 | + | type="button" | |
| 552 | + | aria-label="Close menu" | |
| 553 | + | className="absolute inset-0 bg-black/60" | |
| 554 | + | onClick={() => setDrawer(false)} | |
| 555 | + | /> | |
| 556 | + | <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface"> | |
| 557 | + | <button | |
| 558 | + | type="button" | |
| 559 | + | aria-label="Close menu" | |
| 560 | + | onClick={() => setDrawer(false)} | |
| 561 | + | className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg" | |
| 562 | + | > | |
| 563 | + | <X size={16} /> | |
| 564 | + | </button> | |
| 565 | + | <Sidebar user={user} shell={shell} /> | |
| 566 | + | </aside> | |
| 567 | + | </div> | |
| 568 | + | )} | |
| 569 | + | ||
| 570 | + | <div className="flex min-h-screen flex-col lg:pl-64"> | |
| 571 | + | <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4"> | |
| 572 | + | <button | |
| 573 | + | type="button" | |
| 574 | + | aria-label="Open menu" | |
| 575 | + | onClick={() => setDrawer(true)} | |
| 576 | + | className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden" | |
| 577 | + | > | |
| 578 | + | <Menu size={18} /> | |
| 579 | + | </button> | |
| 580 | + | <button | |
| 581 | + | type="button" | |
| 582 | + | onClick={() => setPalette(true)} | |
| 583 | + | className="flex h-8 w-full max-w-md items-center gap-2 rounded-md border border-line bg-surface/60 px-2.5 text-sm text-faint transition-colors hover:border-line-strong hover:text-muted" | |
| 584 | + | > | |
| 585 | + | <Search size={14} /> | |
| 586 | + | <span className="grow text-left">Search or jump to…</span> | |
| 587 | + | <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.6875rem] sm:inline">⌘K</kbd> | |
| 588 | + | </button> | |
| 589 | + | <Form action="/search" className="hidden" /> | |
| 590 | + | <div className="ml-auto flex items-center gap-1.5"> | |
| 591 | + | <DropdownMenu> | |
| 592 | + | <DropdownMenuTrigger | |
| 593 | + | aria-label="Create" | |
| 594 | + | className="rounded-md border border-line p-1.5 text-muted outline-none transition-colors hover:border-line-strong hover:text-fg data-[state=open]:bg-raised" | |
| 595 | + | > | |
| 596 | + | <Plus size={15} /> | |
| 597 | + | </DropdownMenuTrigger> | |
| 598 | + | <DropdownMenuContent align="end"> | |
| 599 | + | {shell.repo && ( | |
| 600 | + | <DropdownMenuItem asChild> | |
| 601 | + | <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}> | |
| 602 | + | <CircleDot /> | |
| 603 | + | New issue | |
| 604 | + | </Link> | |
| 605 | + | </DropdownMenuItem> | |
| 606 | + | )} | |
| 607 | + | {shell.repo?.member && ( | |
| 608 | + | <DropdownMenuItem asChild> | |
| 609 | + | <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}> | |
| 610 | + | <ListTree /> | |
| 611 | + | Plan work | |
| 612 | + | </Link> | |
| 613 | + | </DropdownMenuItem> | |
| 614 | + | )} | |
| 615 | + | <DropdownMenuItem asChild> | |
| 616 | + | <Link to="/new"> | |
| 617 | + | <BookMarked /> | |
| 618 | + | New repository | |
| 619 | + | </Link> | |
| 620 | + | </DropdownMenuItem> | |
| 621 | + | <DropdownMenuItem asChild> | |
| 622 | + | <Link to="/workspaces/new"> | |
| 623 | + | <Users /> | |
| 624 | + | New workspace | |
| 625 | + | </Link> | |
| 626 | + | </DropdownMenuItem> | |
| 627 | + | </DropdownMenuContent> | |
| 628 | + | </DropdownMenu> | |
| 629 | + | <DropdownMenu> | |
| 630 | + | <DropdownMenuTrigger | |
| 631 | + | aria-label="Account menu" | |
| 632 | + | className="rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised" | |
| 633 | + | > | |
| 634 | + | <Avatar name={user.username} size={24} /> | |
| 635 | + | </DropdownMenuTrigger> | |
| 636 | + | <DropdownMenuContent align="end" className="w-56"> | |
| 637 | + | <DropdownMenuLabel> | |
| 638 | + | Signed in as <span className="font-mono font-medium text-fg">{user.username}</span> | |
| 639 | + | </DropdownMenuLabel> | |
| 640 | + | <DropdownMenuSeparator /> | |
| 641 | + | <DropdownMenuItem asChild> | |
| 642 | + | <Link to="/settings"> | |
| 643 | + | <Settings /> | |
| 644 | + | Your settings | |
| 645 | + | </Link> | |
| 646 | + | </DropdownMenuItem> | |
| 647 | + | <DropdownMenuItem asChild> | |
| 648 | + | <a href="https://docs.g1t.sh/"> | |
| 649 | + | <BookOpen /> | |
| 650 | + | Documentation | |
| 651 | + | </a> | |
| 652 | + | </DropdownMenuItem> | |
| 653 | + | <DropdownMenuSeparator /> | |
| 654 | + | {/* Submitted from here: the menu closes on select, and a button | |
| 655 | + | that has left the page cannot submit a form. */} | |
| 656 | + | <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}> | |
| 657 | + | <LogOut /> | |
| 658 | + | Sign out | |
| 659 | + | </DropdownMenuItem> | |
| 660 | + | </DropdownMenuContent> | |
| 661 | + | </DropdownMenu> | |
| 662 | + | </div> | |
| 663 | + | </header> | |
| 664 | + | {banner} | |
| 665 | + | <main className="grow">{children}</main> | |
| 666 | + | </div> | |
| 667 | + | <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} /> | |
| 668 | + | </div> | |
| 669 | + | ); | |
| 670 | + | } |
| 1 | − | import { Check, Copy } from "lucide-react"; | |
| 1 | + | import { Check, Copy, Sparkles } from "lucide-react"; | |
| 2 | 2 | import { type ComponentProps, type ReactNode, useState } from "react"; | |
| 3 | − | import { Link, type LinkProps } from "react-router"; | |
| 3 | + | import { Link, type LinkProps, NavLink, useLocation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | export function Field({ | |
| 6 | 6 | label, | |
| 25 | 25 | const CONTROL = | |
| 26 | 26 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 27 | 27 | ||
| 28 | + | /** | |
| 29 | + | * Attributes for a field that is not part of signing in. Password managers | |
| 30 | + | * guess from names such as "name", "title" and "username" and offer to | |
| 31 | + | * fill a login into them; these tell 1Password, LastPass, Bitwarden and | |
| 32 | + | * Dashlane to leave the field alone. A field that states what it takes with | |
| 33 | + | * `autoComplete` is left to them. | |
| 34 | + | */ | |
| 35 | + | export function notACredential(autoComplete?: string) { | |
| 36 | + | if (autoComplete) return {}; | |
| 37 | + | return { | |
| 38 | + | autoComplete: "off", | |
| 39 | + | "data-1p-ignore": true, | |
| 40 | + | "data-lpignore": "true", | |
| 41 | + | "data-bwignore": true, | |
| 42 | + | "data-form-type": "other", | |
| 43 | + | }; | |
| 44 | + | } | |
| 45 | + | ||
| 28 | 46 | export function Input(props: ComponentProps<"input">) { | |
| 29 | − | return <input {...props} className={CONTROL} />; | |
| 47 | + | return <input {...notACredential(props.autoComplete)} {...props} className={CONTROL} />; | |
| 30 | 48 | } | |
| 31 | 49 | ||
| 32 | 50 | export function Textarea(props: ComponentProps<"textarea">) { | |
| 33 | − | return <textarea {...props} className={`${CONTROL} font-mono`} />; | |
| 51 | + | return ( | |
| 52 | + | <textarea | |
| 53 | + | {...notACredential(props.autoComplete)} | |
| 54 | + | {...props} | |
| 55 | + | className={`${CONTROL} font-mono`} | |
| 56 | + | /> | |
| 57 | + | ); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /** One tab in the row under a repository's or a workspace's header. */ | |
| 61 | + | export function TabLink({ | |
| 62 | + | to, | |
| 63 | + | also, | |
| 64 | + | end, | |
| 65 | + | icon, | |
| 66 | + | count, | |
| 67 | + | children, | |
| 68 | + | }: { | |
| 69 | + | to: string; | |
| 70 | + | /** Another path prefix under which this tab is the current one. */ | |
| 71 | + | also?: string; | |
| 72 | + | end?: boolean; | |
| 73 | + | icon: ReactNode; | |
| 74 | + | count?: number; | |
| 75 | + | children: ReactNode; | |
| 76 | + | }) { | |
| 77 | + | const { pathname } = useLocation(); | |
| 78 | + | return ( | |
| 79 | + | <NavLink | |
| 80 | + | to={to} | |
| 81 | + | end={end} | |
| 82 | + | className={({ isActive }) => | |
| 83 | + | `-mb-px flex items-center gap-2 border-b-2 px-1 pb-3 text-sm whitespace-nowrap transition-colors ${ | |
| 84 | + | isActive || (also && pathname.startsWith(also + "/")) | |
| 85 | + | ? "border-accent font-medium text-fg" | |
| 86 | + | : "border-transparent text-muted hover:text-fg" | |
| 87 | + | }` | |
| 88 | + | } | |
| 89 | + | > | |
| 90 | + | {icon} | |
| 91 | + | {children} | |
| 92 | + | {count != null && count > 0 && ( | |
| 93 | + | <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted"> | |
| 94 | + | {count} | |
| 95 | + | </span> | |
| 96 | + | )} | |
| 97 | + | </NavLink> | |
| 98 | + | ); | |
| 34 | 99 | } | |
| 35 | 100 | ||
| 36 | 101 | type Variant = "primary" | "accent" | "quiet"; | |
| 85 | 150 | ||
| 86 | 151 | const AVATAR_HUES = [82, 200, 262, 28, 330, 160]; | |
| 87 | 152 | ||
| 88 | − | /** A letter avatar whose colour is stable for a given name. */ | |
| 89 | − | export function Avatar({ name, size = 20 }: { name: string; size?: number }) { | |
| 153 | + | /** How g1t's agents are named: as reviewers and assignees, and as commit authors. */ | |
| 154 | + | const AGENT_NAMES = new Set(["g1t-agent", "g1t agent", "g1t"]); | |
| 155 | + | ||
| 156 | + | /** | |
| 157 | + | * A letter avatar whose colour is stable for a given name. People are | |
| 158 | + | * round; a workspace is `square`. | |
| 159 | + | */ | |
| 160 | + | export function Avatar({ | |
| 161 | + | name, | |
| 162 | + | size = 20, | |
| 163 | + | square, | |
| 164 | + | }: { | |
| 165 | + | name: string; | |
| 166 | + | size?: number; | |
| 167 | + | square?: boolean; | |
| 168 | + | }) { | |
| 169 | + | // g1t's own agents wear the agent colour and mark everywhere they appear. | |
| 170 | + | if (AGENT_NAMES.has(name)) { | |
| 171 | + | return ( | |
| 172 | + | <span | |
| 173 | + | aria-hidden="true" | |
| 174 | + | className="inline-flex shrink-0 items-center justify-center bg-merged/20 text-merged ring-1 ring-merged/40 ring-inset" | |
| 175 | + | style={{ width: size, height: size, borderRadius: size * 0.3 }} | |
| 176 | + | > | |
| 177 | + | <Sparkles size={Math.round(size * 0.58)} /> | |
| 178 | + | </span> | |
| 179 | + | ); | |
| 180 | + | } | |
| 90 | 181 | let hash = 0; | |
| 91 | 182 | for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0; | |
| 92 | 183 | const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length]; | |
| 93 | 184 | return ( | |
| 94 | 185 | <span | |
| 95 | 186 | aria-hidden="true" | |
| 96 | − | className="inline-flex shrink-0 items-center justify-center rounded-full font-mono font-semibold uppercase" | |
| 187 | + | className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase" | |
| 97 | 188 | style={{ | |
| 98 | 189 | width: size, | |
| 99 | 190 | height: size, | |
| 191 | + | borderRadius: square ? size * 0.24 : size, | |
| 100 | 192 | fontSize: size * 0.5, | |
| 101 | 193 | background: `oklch(0.4 0.09 ${hue})`, | |
| 102 | 194 | color: `oklch(0.93 0.08 ${hue})`, |
| 1 | 1 | import { | |
| 2 | + | Bot, | |
| 2 | 3 | CircleCheck, | |
| 3 | 4 | CircleDot, | |
| 4 | 5 | CircleSlash, | |
| 12 | 13 | ||
| 13 | 14 | import type { Comment, Issue, Pull, State } from "@g1t/contracts"; | |
| 14 | 15 | ||
| 16 | + | import { repoAt } from "../lib/markdown-plugins"; | |
| 15 | 17 | import { Markdown } from "./markdown"; | |
| 16 | 18 | import { Avatar, Button, Textarea, TimeAgo } from "./ui"; | |
| 17 | 19 | ||
| 48 | 50 | ); | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 53 | + | /** The people an issue is assigned to, as overlapping avatars. */ | |
| 54 | + | export function AssigneeStack({ people }: { people: string[] }) { | |
| 55 | + | if (people.length === 0) return null; | |
| 56 | + | const shown = people.slice(0, 3); | |
| 57 | + | return ( | |
| 58 | + | <span | |
| 59 | + | className="flex shrink-0 items-center" | |
| 60 | + | title={`Assigned to ${people.join(", ")}`} | |
| 61 | + | > | |
| 62 | + | {shown.map((name, index) => ( | |
| 63 | + | <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`}> | |
| 64 | + | <Avatar name={name} size={18} /> | |
| 65 | + | </span> | |
| 66 | + | ))} | |
| 67 | + | {people.length > shown.length && ( | |
| 68 | + | <span className="ml-1 text-xs text-faint">+{people.length - shown.length}</span> | |
| 69 | + | )} | |
| 70 | + | </span> | |
| 71 | + | ); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /** The agent working on an issue now. */ | |
| 75 | + | export function Assignee({ agent }: { agent: string }) { | |
| 76 | + | return ( | |
| 77 | + | <span | |
| 78 | + | className="inline-flex shrink-0 items-center gap-1 rounded-full border border-accent/35 bg-accent/10 px-2 py-px text-xs font-medium text-accent" | |
| 79 | + | title={`Assigned to ${agent}`} | |
| 80 | + | > | |
| 81 | + | <Bot size={12} /> | |
| 82 | + | {agent} | |
| 83 | + | </span> | |
| 84 | + | ); | |
| 85 | + | } | |
| 86 | + | ||
| 51 | 87 | export function IssueIcon({ | |
| 52 | 88 | issue, | |
| 53 | 89 | size = 16, | |
| 165 | 201 | * says where its changes are shown and whether the viewer may give a | |
| 166 | 202 | * verdict. | |
| 167 | 203 | */ | |
| 168 | − | export function Comments({ | |
| 204 | + | /** | |
| 205 | + | * One entry in a conversation: who, what they did and when, then what they | |
| 206 | + | * wrote. The author's avatar sits beside it, as on any forge. | |
| 207 | + | */ | |
| 208 | + | export function TimelineItem({ | |
| 209 | + | author, | |
| 210 | + | action, | |
| 211 | + | at, | |
| 212 | + | aside, | |
| 213 | + | children, | |
| 214 | + | }: { | |
| 215 | + | author: string; | |
| 216 | + | /** What they did, after their name: "commented", "opened this". */ | |
| 217 | + | action: ReactNode; | |
| 218 | + | at?: string; | |
| 219 | + | /** Shown at the right of the header. */ | |
| 220 | + | aside?: ReactNode; | |
| 221 | + | children?: ReactNode; | |
| 222 | + | }) { | |
| 223 | + | return ( | |
| 224 | + | <div className="flex gap-3"> | |
| 225 | + | <span className="mt-1 hidden shrink-0 sm:block"> | |
| 226 | + | <Avatar name={author} size={32} /> | |
| 227 | + | </span> | |
| 228 | + | <article className="min-w-0 grow overflow-hidden rounded-xl border border-line bg-surface"> | |
| 229 | + | <header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-line bg-raised/40 px-4 py-2 text-sm text-muted"> | |
| 230 | + | <span className="sm:hidden"> | |
| 231 | + | <Avatar name={author} size={18} /> | |
| 232 | + | </span> | |
| 233 | + | <span className="font-medium text-fg">{author}</span> | |
| 234 | + | {action} | |
| 235 | + | {at && <TimeAgo at={at} />} | |
| 236 | + | {aside && <span className="ml-auto min-w-0">{aside}</span>} | |
| 237 | + | </header> | |
| 238 | + | {children && <div className="px-4 py-3">{children}</div>} | |
| 239 | + | </article> | |
| 240 | + | </div> | |
| 241 | + | ); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | type Review = { changesUrl: string; canJudge: boolean }; | |
| 245 | + | ||
| 246 | + | /** Text with each `#12` linked to the issue or pull request of that number. */ | |
| 247 | + | function WithReferences({ text, base }: { text: string; base?: string }) { | |
| 248 | + | if (!base) return <>{text}</>; | |
| 249 | + | return ( | |
| 250 | + | <> | |
| 251 | + | {text.split(/(#\d+)/).map((part, index) => | |
| 252 | + | /^#\d+$/.test(part) ? ( | |
| 253 | + | // Issues and pull requests share numbers; the issue page forwards. | |
| 254 | + | <Link | |
| 255 | + | key={index} | |
| 256 | + | to={`${base}/issues/${part.slice(1)}`} | |
| 257 | + | className="font-medium text-fg hover:underline" | |
| 258 | + | > | |
| 259 | + | {part} | |
| 260 | + | </Link> | |
| 261 | + | ) : ( | |
| 262 | + | part | |
| 263 | + | ), | |
| 264 | + | )} | |
| 265 | + | </> | |
| 266 | + | ); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /** Something that happened, as one line on the conversation's rail. */ | |
| 270 | + | function TimelineEvent({ comment, base }: { comment: Comment; base?: string }) { | |
| 271 | + | return ( | |
| 272 | + | <div className="flex items-center gap-3 text-sm text-muted"> | |
| 273 | + | <span className="hidden w-8 shrink-0 justify-center sm:flex"> | |
| 274 | + | <span className="size-2 rounded-full border border-line-strong bg-bg" /> | |
| 275 | + | </span> | |
| 276 | + | <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5"> | |
| 277 | + | <Avatar name={comment.author.username} size={16} /> | |
| 278 | + | <span className="font-medium text-fg">{comment.author.username}</span> | |
| 279 | + | <span> | |
| 280 | + | <WithReferences text={comment.body} base={base} /> | |
| 281 | + | </span> | |
| 282 | + | <span className="text-faint"> | |
| 283 | + | · <TimeAgo at={comment.createdAt} /> | |
| 284 | + | </span> | |
| 285 | + | </span> | |
| 286 | + | </div> | |
| 287 | + | ); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /** | |
| 291 | + | * The conversation of an issue or a pull request, oldest first: what | |
| 292 | + | * people and agents wrote, and between those, what happened. | |
| 293 | + | */ | |
| 294 | + | export function CommentList({ | |
| 169 | 295 | comments, | |
| 170 | − | canComment, | |
| 171 | 296 | review, | |
| 297 | + | base, | |
| 172 | 298 | }: { | |
| 173 | 299 | comments: Comment[]; | |
| 174 | − | canComment: boolean; | |
| 175 | − | review?: { changesUrl: string; canJudge: boolean }; | |
| 300 | + | review?: Review; | |
| 301 | + | /** The repository's path, so that `#12` in an event can be linked. */ | |
| 302 | + | base?: string; | |
| 176 | 303 | }) { | |
| 177 | 304 | return ( | |
| 178 | − | <div className="space-y-4"> | |
| 305 | + | <> | |
| 179 | 306 | {comments.map((comment) => { | |
| 307 | + | if (comment.kind === "event") { | |
| 308 | + | return <TimelineEvent key={comment.id} comment={comment} base={base} />; | |
| 309 | + | } | |
| 180 | 310 | const verdict = comment.verdict && VERDICTS[comment.verdict]; | |
| 181 | 311 | 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 ? ( | |
| 312 | + | <TimelineItem | |
| 313 | + | key={comment.id} | |
| 314 | + | author={comment.author.username} | |
| 315 | + | at={comment.createdAt} | |
| 316 | + | action={ | |
| 317 | + | verdict ? ( | |
| 187 | 318 | <span className={`flex items-center gap-1 font-medium ${verdict.style}`}> | |
| 188 | 319 | {comment.verdict === "approve" ? ( | |
| 189 | 320 | <CircleCheck size={14} /> | |
| 194 | 325 | </span> | |
| 195 | 326 | ) : ( | |
| 196 | 327 | <span>commented</span> | |
| 197 | − | )} | |
| 198 | − | <TimeAgo at={comment.createdAt} /> | |
| 199 | − | {comment.path && review && ( | |
| 328 | + | ) | |
| 329 | + | } | |
| 330 | + | aside={ | |
| 331 | + | comment.path && | |
| 332 | + | review && ( | |
| 200 | 333 | <Link | |
| 201 | 334 | to={`${review.changesUrl}#file-${comment.path}`} | |
| 202 | − | className="ml-auto truncate font-mono text-xs text-faint hover:text-fg" | |
| 335 | + | className="block truncate font-mono text-xs text-faint hover:text-fg" | |
| 203 | 336 | > | |
| 204 | 337 | {comment.path} | |
| 205 | 338 | {comment.line != null && `:${comment.line}`} | |
| 206 | 339 | </Link> | |
| 207 | − | )} | |
| 208 | − | </header> | |
| 209 | − | {comment.body && ( | |
| 210 | − | <div className="px-4 py-3"> | |
| 211 | − | <Markdown source={comment.body} /> | |
| 212 | − | </div> | |
| 213 | − | )} | |
| 214 | − | </article> | |
| 340 | + | ) | |
| 341 | + | } | |
| 342 | + | > | |
| 343 | + | {comment.body && <Markdown source={comment.body} repo={repoAt(base)} />} | |
| 344 | + | </TimelineItem> | |
| 215 | 345 | ); | |
| 216 | 346 | })} | |
| 217 | − | {canComment ? ( | |
| 218 | − | <Form method="post" className="space-y-2" key={comments.length}> | |
| 219 | − | <input type="hidden" name="action" value="comment" /> | |
| 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> | |
| 244 | − | </Form> | |
| 245 | − | ) : ( | |
| 246 | − | <p className="text-sm text-muted"> | |
| 247 | − | <Link to="/login" className="text-fg underline underline-offset-4"> | |
| 248 | − | Sign in | |
| 249 | − | </Link>{" "} | |
| 250 | − | to comment. | |
| 251 | − | </p> | |
| 347 | + | </> | |
| 348 | + | ); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | /** Where a signed-in person writes a comment, or a review. */ | |
| 352 | + | export function CommentForm({ | |
| 353 | + | author, | |
| 354 | + | resetKey, | |
| 355 | + | review, | |
| 356 | + | }: { | |
| 357 | + | /** The viewer's username, or null if they are signed out. */ | |
| 358 | + | author: string | null; | |
| 359 | + | /** Changes when a comment is added, which clears the box. */ | |
| 360 | + | resetKey: number; | |
| 361 | + | review?: Review; | |
| 362 | + | }) { | |
| 363 | + | if (!author) { | |
| 364 | + | return ( | |
| 365 | + | <p className="text-sm text-muted"> | |
| 366 | + | <Link to="/login" className="text-fg underline underline-offset-4"> | |
| 367 | + | Sign in | |
| 368 | + | </Link>{" "} | |
| 369 | + | to comment. | |
| 370 | + | </p> | |
| 371 | + | ); | |
| 372 | + | } | |
| 373 | + | return ( | |
| 374 | + | <div className="flex gap-3"> | |
| 375 | + | <span className="mt-1 hidden shrink-0 sm:block"> | |
| 376 | + | <Avatar name={author} size={32} /> | |
| 377 | + | </span> | |
| 378 | + | <Form method="post" className="min-w-0 grow space-y-2" key={resetKey}> | |
| 379 | + | <input type="hidden" name="action" value="comment" /> | |
| 380 | + | <Textarea | |
| 381 | + | name="body" | |
| 382 | + | rows={3} | |
| 383 | + | placeholder={ | |
| 384 | + | review?.canJudge | |
| 385 | + | ? "Leave a comment, or a review. Markdown works." | |
| 386 | + | : "Leave a comment. Markdown works." | |
| 387 | + | } | |
| 388 | + | /> | |
| 389 | + | <div className="flex flex-wrap gap-2"> | |
| 390 | + | <Button type="submit">Comment</Button> | |
| 391 | + | {review?.canJudge && ( | |
| 392 | + | <> | |
| 393 | + | <Button variant="quiet" type="submit" name="verdict" value="approve"> | |
| 394 | + | <CircleCheck size={14} className="text-accent" /> | |
| 395 | + | Approve | |
| 396 | + | </Button> | |
| 397 | + | <Button variant="quiet" type="submit" name="verdict" value="request_changes"> | |
| 398 | + | <CircleSlash size={14} className="text-danger" /> | |
| 399 | + | Request changes | |
| 400 | + | </Button> | |
| 401 | + | </> | |
| 402 | + | )} | |
| 403 | + | </div> | |
| 404 | + | </Form> | |
| 405 | + | </div> | |
| 406 | + | ); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /** | |
| 410 | + | * Checkboxes for choosing people: the workspace's members, anyone already | |
| 411 | + | * chosen, and a box for other usernames. Posts `name` for each ticked and | |
| 412 | + | * `others` for the rest. | |
| 413 | + | */ | |
| 414 | + | export function PeoplePicker({ | |
| 415 | + | name, | |
| 416 | + | members, | |
| 417 | + | chosen, | |
| 418 | + | }: { | |
| 419 | + | name: string; | |
| 420 | + | members: string[]; | |
| 421 | + | chosen: string[]; | |
| 422 | + | }) { | |
| 423 | + | const people = [...new Set([...members, ...chosen])]; | |
| 424 | + | return ( | |
| 425 | + | <> | |
| 426 | + | {people.length > 0 && ( | |
| 427 | + | <div className="space-y-1.5"> | |
| 428 | + | {people.map((person) => ( | |
| 429 | + | <label key={person} className="flex cursor-pointer items-center gap-2 text-sm"> | |
| 430 | + | <input | |
| 431 | + | type="checkbox" | |
| 432 | + | name={name} | |
| 433 | + | value={person} | |
| 434 | + | defaultChecked={chosen.includes(person)} | |
| 435 | + | className="accent-accent" | |
| 436 | + | /> | |
| 437 | + | <Avatar name={person} size={18} /> | |
| 438 | + | <span className="font-mono text-xs">{person}</span> | |
| 439 | + | </label> | |
| 440 | + | ))} | |
| 441 | + | </div> | |
| 252 | 442 | )} | |
| 253 | − | </div> | |
| 443 | + | <input | |
| 444 | + | name="others" | |
| 445 | + | placeholder="Other usernames, comma separated" | |
| 446 | + | autoComplete="off" | |
| 447 | + | data-1p-ignore | |
| 448 | + | className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint hover:border-line-strong focus:border-accent-dim" | |
| 449 | + | /> | |
| 450 | + | </> | |
| 254 | 451 | ); | |
| 255 | 452 | } | |
| 256 | 453 | ||
| 262 | 459 | } | |
| 263 | 460 | return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict })); | |
| 264 | 461 | } | |
| 462 | + | ||
| 463 | + | /** `2 files +12 −3`: the size of a pull request's change. */ | |
| 464 | + | export function ChangeSize({ files }: { files: Pull["files"] }) { | |
| 465 | + | if (files.length === 0) return null; | |
| 466 | + | const additions = files.reduce((sum, file) => sum + file.additions, 0); | |
| 467 | + | const deletions = files.reduce((sum, file) => sum + file.deletions, 0); | |
| 468 | + | return ( | |
| 469 | + | <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-faint"> | |
| 470 | + | <span> | |
| 471 | + | {files.length} {files.length === 1 ? "file" : "files"} | |
| 472 | + | </span> | |
| 473 | + | <span className="text-accent">+{additions}</span> | |
| 474 | + | <span className="text-danger">−{deletions}</span> | |
| 475 | + | </span> | |
| 476 | + | ); | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | /** The words of a Markdown text, without its markup, for a one-line preview. */ | |
| 480 | + | export function plainText(markdown: string): string { | |
| 481 | + | return markdown | |
| 482 | + | .replace(/```[\s\S]*?```/g, " ") | |
| 483 | + | .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, "") | |
| 484 | + | .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") | |
| 485 | + | .replace(/[*_`~]/g, "") | |
| 486 | + | .replace(/\s+/g, " ") | |
| 487 | + | .trim(); | |
| 488 | + | } |
| 1 | + | import type { Comparison, DiffLine, FileDiff } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | /** A diff line, with its text as highlighted HTML when the language is known. */ | |
| 4 | + | export type HighlightedLine = DiffLine & { html?: string }; | |
| 5 | + | export type HighlightedFile = Omit<FileDiff, "hunks"> & { hunks: { lines: HighlightedLine[] }[] }; | |
| 6 | + | export type HighlightedComparison = Omit<Comparison, "files"> & { files: HighlightedFile[] }; |
| 1 | − | import { type HighlighterCore, createHighlighterCore } from "shiki/core"; | |
| 2 | − | import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; | |
| 3 | − | ||
| 4 | − | /** File extension (or whole file name) to Shiki language. */ | |
| 5 | − | const LANGUAGES: Record<string, string> = { | |
| 6 | − | rs: "rust", | |
| 7 | − | ts: "typescript", | |
| 8 | − | tsx: "tsx", | |
| 9 | − | js: "javascript", | |
| 10 | − | mjs: "javascript", | |
| 11 | − | jsx: "jsx", | |
| 12 | − | json: "json", | |
| 13 | − | jsonc: "jsonc", | |
| 14 | − | toml: "toml", | |
| 15 | − | yaml: "yaml", | |
| 16 | − | yml: "yaml", | |
| 17 | − | md: "markdown", | |
| 18 | − | css: "css", | |
| 19 | − | html: "html", | |
| 20 | − | sql: "sql", | |
| 21 | − | sh: "shellscript", | |
| 22 | − | bash: "shellscript", | |
| 23 | − | py: "python", | |
| 24 | − | go: "go", | |
| 25 | − | dockerfile: "docker", | |
| 26 | − | }; | |
| 1 | + | import { THEME, getHighlighter, languageOf, linesToHtml } from "./shiki"; | |
| 27 | 2 | ||
| 28 | 3 | const MAX_HIGHLIGHT_CHARS = 200_000; | |
| 29 | 4 | ||
| 30 | − | let highlighter: Promise<HighlighterCore> | undefined; | |
| 31 | − | ||
| 32 | − | function getHighlighter(): Promise<HighlighterCore> { | |
| 33 | − | highlighter ??= createHighlighterCore({ | |
| 34 | − | themes: [import("shiki/themes/vitesse-dark.mjs")], | |
| 35 | − | langs: [ | |
| 36 | − | import("shiki/langs/rust.mjs"), | |
| 37 | − | import("shiki/langs/typescript.mjs"), | |
| 38 | − | import("shiki/langs/tsx.mjs"), | |
| 39 | − | import("shiki/langs/javascript.mjs"), | |
| 40 | − | import("shiki/langs/jsx.mjs"), | |
| 41 | − | import("shiki/langs/json.mjs"), | |
| 42 | − | import("shiki/langs/jsonc.mjs"), | |
| 43 | − | import("shiki/langs/toml.mjs"), | |
| 44 | − | import("shiki/langs/yaml.mjs"), | |
| 45 | − | import("shiki/langs/markdown.mjs"), | |
| 46 | − | import("shiki/langs/css.mjs"), | |
| 47 | − | import("shiki/langs/html.mjs"), | |
| 48 | − | import("shiki/langs/sql.mjs"), | |
| 49 | − | import("shiki/langs/shellscript.mjs"), | |
| 50 | − | import("shiki/langs/python.mjs"), | |
| 51 | − | import("shiki/langs/go.mjs"), | |
| 52 | − | import("shiki/langs/docker.mjs"), | |
| 53 | − | ], | |
| 54 | − | engine: createJavaScriptRegexEngine(), | |
| 55 | − | }); | |
| 56 | − | return highlighter; | |
| 57 | − | } | |
| 58 | − | ||
| 59 | 5 | /** | |
| 60 | 6 | * Highlighted HTML for a file, or null when its language is unknown or it | |
| 61 | 7 | * is too large, in which case the caller shows plain text. | |
| 62 | 8 | */ | |
| 63 | − | export async function highlight( | |
| 64 | − | path: string, | |
| 65 | − | text: string, | |
| 66 | − | ): Promise<string | null> { | |
| 67 | − | const name = path.split("/").pop()!.toLowerCase(); | |
| 68 | − | const lang = LANGUAGES[name.includes(".") ? name.split(".").pop()! : name]; | |
| 9 | + | export async function highlight(path: string, text: string): Promise<string | null> { | |
| 10 | + | const lang = languageOf(path); | |
| 11 | + | if (!lang || text.length > MAX_HIGHLIGHT_CHARS) return null; | |
| 12 | + | try { | |
| 13 | + | return (await getHighlighter()).codeToHtml(text.replace(/\n$/, ""), { lang, theme: THEME }); | |
| 14 | + | } catch { | |
| 15 | + | return null; | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** | |
| 20 | + | * A file's lines as highlighted HTML, one string per line, or null when its | |
| 21 | + | * language is unknown or it is too large. | |
| 22 | + | */ | |
| 23 | + | export async function highlightLines(path: string, text: string): Promise<string[] | null> { | |
| 24 | + | const lang = languageOf(path); | |
| 69 | 25 | if (!lang || text.length > MAX_HIGHLIGHT_CHARS) return null; | |
| 70 | 26 | try { | |
| 71 | − | return (await getHighlighter()).codeToHtml(text.replace(/\n$/, ""), { | |
| 72 | − | lang, | |
| 73 | − | theme: "vitesse-dark", | |
| 74 | − | }); | |
| 27 | + | return linesToHtml(await getHighlighter(), text.replace(/\n$/, ""), lang); | |
| 75 | 28 | } catch { | |
| 76 | 29 | return null; | |
| 77 | 30 | } |
| 1 | + | /** | |
| 2 | + | * Things that never change once they exist, such as a commit and its diff, | |
| 3 | + | * kept in the data centre's cache so only the first visit pays for them. | |
| 4 | + | * | |
| 5 | + | * Call this only after the viewer is known to be allowed to see the thing: | |
| 6 | + | * the cache is shared by everyone who asks for the same key. | |
| 7 | + | */ | |
| 8 | + | const ORIGIN = "https://immutable.g1t.internal/"; | |
| 9 | + | /** A year: what is cached here does not go stale. */ | |
| 10 | + | const MAX_AGE_SECONDS = 365 * 24 * 60 * 60; | |
| 11 | + | ||
| 12 | + | export async function immutable<T>(key: string, load: () => Promise<T | null>): Promise<T | null> { | |
| 13 | + | const url = ORIGIN + encodeURIComponent(key); | |
| 14 | + | let cache: Cache | null = null; | |
| 15 | + | try { | |
| 16 | + | cache = (caches as unknown as { default: Cache }).default; | |
| 17 | + | const hit = await cache.match(url); | |
| 18 | + | if (hit) return (await hit.json()) as T; | |
| 19 | + | } catch { | |
| 20 | + | // No cache (local development): load it every time. | |
| 21 | + | } | |
| 22 | + | const value = await load(); | |
| 23 | + | if (value != null && cache) { | |
| 24 | + | await cache | |
| 25 | + | .put( | |
| 26 | + | url, | |
| 27 | + | new Response(JSON.stringify(value), { | |
| 28 | + | headers: { "content-type": "application/json", "cache-control": `max-age=${MAX_AGE_SECONDS}, immutable` }, | |
| 29 | + | }), | |
| 30 | + | ) | |
| 31 | + | .catch(() => {}); | |
| 32 | + | } | |
| 33 | + | return value; | |
| 34 | + | } |
| 1 | + | /** | |
| 2 | + | * g1t's additions to GitHub flavoured markdown. They run on the HTML tree | |
| 3 | + | * after it has been sanitized, so what they add is trusted and what the | |
| 4 | + | * author wrote never is. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | type Text = { type: "text"; value: string }; | |
| 8 | + | type Element = { | |
| 9 | + | type: "element"; | |
| 10 | + | tagName: string; | |
| 11 | + | properties: Record<string, unknown>; | |
| 12 | + | children: Node[]; | |
| 13 | + | }; | |
| 14 | + | /** A node of the HTML tree, loosely: only what these plugins read. */ | |
| 15 | + | type Node = { type: string; value?: string; tagName?: string; properties?: Record<string, unknown>; children?: Node[] }; | |
| 16 | + | ||
| 17 | + | /** The repository a document belongs to, so `#12` can name its issues. */ | |
| 18 | + | export type MarkdownRepo = { namespace: string; name: string }; | |
| 19 | + | ||
| 20 | + | /** The repository a site path like `/acme/web` names. */ | |
| 21 | + | export function repoAt(base?: string): MarkdownRepo | undefined { | |
| 22 | + | const [namespace, name] = (base ?? "").split("/").filter(Boolean); | |
| 23 | + | return namespace && name ? { namespace, name } : undefined; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export const ALERTS = ["note", "tip", "important", "warning", "caution"] as const; | |
| 27 | + | export type AlertKind = (typeof ALERTS)[number]; | |
| 28 | + | ||
| 29 | + | const ALERT_MARKER = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i; | |
| 30 | + | ||
| 31 | + | function isElement(node: Node, tag?: string): node is Element { | |
| 32 | + | return node.type === "element" && (tag == null || (node as Element).tagName === tag); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * GitHub's alerts: a quote that starts with `[!NOTE]`, `[!TIP]`, | |
| 37 | + | * `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` becomes a callout. | |
| 38 | + | */ | |
| 39 | + | export function rehypeAlerts() { | |
| 40 | + | return (tree: Node) => { | |
| 41 | + | const walk = (node: Node) => { | |
| 42 | + | for (const child of node.children ?? []) { | |
| 43 | + | if (isElement(child, "blockquote")) { | |
| 44 | + | const first = child.children.find((c): c is Element => isElement(c, "p")); | |
| 45 | + | const text = first?.children[0]; | |
| 46 | + | const match = text?.type === "text" ? ALERT_MARKER.exec((text as Text).value) : null; | |
| 47 | + | if (first && text && match) { | |
| 48 | + | (text as Text).value = (text as Text).value.slice(match[0].length); | |
| 49 | + | // A marker on a line of its own leaves an empty paragraph behind. | |
| 50 | + | if (first.children.length === 1 && !(text as Text).value.trim()) { | |
| 51 | + | child.children = child.children.filter((c) => c !== first); | |
| 52 | + | } | |
| 53 | + | child.properties = { ...child.properties, dataAlert: match[1]!.toLowerCase() }; | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | walk(child); | |
| 57 | + | } | |
| 58 | + | }; | |
| 59 | + | walk(tree); | |
| 60 | + | }; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /** `owner/repo#12`, `#12`, `@name` and commit hashes, in one pass. */ | |
| 64 | + | const REFERENCE = | |
| 65 | + | /(?<![\w/@#])(?:([a-z0-9][a-z0-9-]*\/[a-z0-9._-]+)#(\d+)|#(\d+)|@([a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38})|([0-9a-f]{7,40}))(?![\w-])/gi; | |
| 66 | + | ||
| 67 | + | function link(href: string, text: string, kind: string): Element { | |
| 68 | + | return { | |
| 69 | + | type: "element", | |
| 70 | + | tagName: "a", | |
| 71 | + | properties: { href, dataRef: kind }, | |
| 72 | + | children: [{ type: "text", value: text }], | |
| 73 | + | }; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** | |
| 77 | + | * Links what a forge's text refers to: `#12` to an issue or pull request | |
| 78 | + | * in this repository (the issue page sends a pull request's number on to | |
| 79 | + | * it), `owner/repo#12` to one elsewhere, `@name` to a person or workspace, | |
| 80 | + | * and a commit hash to its commit. Nothing inside code or a link changes. | |
| 81 | + | */ | |
| 82 | + | export function rehypeReferences(options: { repo?: MarkdownRepo }) { | |
| 83 | + | const { repo } = options; | |
| 84 | + | return (tree: Node) => { | |
| 85 | + | const walk = (node: Node) => { | |
| 86 | + | const children = node.children; | |
| 87 | + | if (!children) return; | |
| 88 | + | for (let index = 0; index < children.length; index++) { | |
| 89 | + | const child = children[index]!; | |
| 90 | + | if (isElement(child)) { | |
| 91 | + | if (child.tagName === "a" || child.tagName === "code" || child.tagName === "pre") continue; | |
| 92 | + | walk(child); | |
| 93 | + | continue; | |
| 94 | + | } | |
| 95 | + | if (child.type !== "text") continue; | |
| 96 | + | const value = (child as Text).value; | |
| 97 | + | const parts: Node[] = []; | |
| 98 | + | let last = 0; | |
| 99 | + | for (const match of value.matchAll(REFERENCE)) { | |
| 100 | + | const [whole, other, otherNumber, number, name, hash] = match; | |
| 101 | + | let replacement: Element | null = null; | |
| 102 | + | if (other && otherNumber) { | |
| 103 | + | replacement = link(`/${other}/issues/${otherNumber}`, whole, "issue"); | |
| 104 | + | } else if (number && repo) { | |
| 105 | + | replacement = link(`/${repo.namespace}/${repo.name}/issues/${number}`, whole, "issue"); | |
| 106 | + | } else if (name) { | |
| 107 | + | replacement = link(`/${name.toLowerCase()}`, whole, "mention"); | |
| 108 | + | } else if (hash && repo && /\d/.test(hash) && /[a-f]/i.test(hash)) { | |
| 109 | + | replacement = link(`/${repo.namespace}/${repo.name}/commit/${hash}`, hash.slice(0, 7), "commit"); | |
| 110 | + | } | |
| 111 | + | if (!replacement) continue; | |
| 112 | + | if (match.index! > last) parts.push({ type: "text", value: value.slice(last, match.index) }); | |
| 113 | + | parts.push(replacement); | |
| 114 | + | last = match.index! + whole.length; | |
| 115 | + | } | |
| 116 | + | if (parts.length === 0) continue; | |
| 117 | + | if (last < value.length) parts.push({ type: "text", value: value.slice(last) }); | |
| 118 | + | children.splice(index, 1, ...parts); | |
| 119 | + | index += parts.length - 1; | |
| 120 | + | } | |
| 121 | + | }; | |
| 122 | + | walk(tree); | |
| 123 | + | }; | |
| 124 | + | } |
| 1 | + | import type { Pull, RepoPath, Viewer } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | import { repos, work } from "./services.server"; | |
| 4 | + | ||
| 5 | + | /** How far back the default branch is read to place a commit. */ | |
| 6 | + | const HISTORY_READ = 300; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * The merged pull request a commit arrived in: the one whose head it is, | |
| 10 | + | * or else the one whose stretch of the default branch's history holds it, | |
| 11 | + | * from its head back to where it started. One read of pull requests and at | |
| 12 | + | * most one of history, however many pull requests there are. | |
| 13 | + | */ | |
| 14 | + | export async function pullForCommit(path: RepoPath, viewer: Viewer, hash: string): Promise<Pull | null> { | |
| 15 | + | const closed = await work.listPulls(path, viewer, "closed"); | |
| 16 | + | if (!closed.ok) return null; | |
| 17 | + | const merged = closed.value.filter((pull) => pull.status === "merged" && pull.headCommit); | |
| 18 | + | const direct = merged.find((pull) => pull.headCommit === hash); | |
| 19 | + | if (direct) return direct; | |
| 20 | + | ||
| 21 | + | const log = await repos.log(path, viewer, null, HISTORY_READ); | |
| 22 | + | if (!log.ok) return null; | |
| 23 | + | const position = new Map(log.value.map((commit, index) => [commit.hash, index])); | |
| 24 | + | const at = position.get(hash); | |
| 25 | + | if (at == null) return null; | |
| 26 | + | // Newest first: a pull request's commits sit at or after its head and | |
| 27 | + | // before the commit it started from. | |
| 28 | + | let best: { pull: Pull; head: number } | null = null; | |
| 29 | + | for (const pull of merged) { | |
| 30 | + | const head = position.get(pull.headCommit!); | |
| 31 | + | if (head == null || head > at) continue; | |
| 32 | + | const base = pull.mergeBase ? position.get(pull.mergeBase) : undefined; | |
| 33 | + | if (base != null && base <= at) continue; | |
| 34 | + | if (!best || head > best.head) best = { pull, head }; | |
| 35 | + | } | |
| 36 | + | return best?.pull ?? null; | |
| 37 | + | } |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | − | import { identityClient, reposClient, workClient } from "@g1t/contracts"; | |
| 3 | + | import { billingClient, identityClient, reposClient, workClient } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | export const identity = identityClient(env.IDENTITY); | |
| 6 | 6 | export const repos = reposClient(env.REPOS); | |
| 7 | 7 | export const work = workClient(env.WORK); | |
| 8 | + | export const billing = billingClient(env.BILLING); |
| 6 | 6 | redirect, | |
| 7 | 7 | } from "react-router"; | |
| 8 | 8 | ||
| 9 | − | import { type Result, type User, type Viewer, httpStatus } from "@g1t/contracts"; | |
| 9 | + | import { type Result, type Role, type User, type Viewer, httpStatus } from "@g1t/contracts"; | |
| 10 | 10 | ||
| 11 | 11 | import { identity } from "./services.server"; | |
| 12 | 12 | ||
| 25 | 25 | return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | − | /** Root middleware: resolves the signed-in user once per request. */ | |
| 28 | + | /** Pages a signed-in person can use before they have a workspace. */ | |
| 29 | + | const BEFORE_WORKSPACE = ["/workspaces/new", "/settings", "/verify", "/logout"]; | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Root middleware: resolves the signed-in user once per request. | |
| 33 | + | * | |
| 34 | + | * Everything on g1t lives in a workspace, so a confirmed account with none | |
| 35 | + | * is sent to create one, from wherever it was going, and returned there | |
| 36 | + | * afterwards. | |
| 37 | + | */ | |
| 29 | 38 | export const viewerMiddleware: MiddlewareFunction<Response> = async ({ | |
| 30 | 39 | request, | |
| 31 | 40 | context, | |
| 32 | 41 | }) => { | |
| 33 | 42 | const token = sessionToken(request); | |
| 34 | − | if (token) { | |
| 35 | − | context.set(viewerContext, await identity.userForSession(token)); | |
| 43 | + | if (!token) return; | |
| 44 | + | const viewer = await identity.userForSession(token); | |
| 45 | + | context.set(viewerContext, viewer); | |
| 46 | + | ||
| 47 | + | const { pathname, search } = new URL(request.url); | |
| 48 | + | if ( | |
| 49 | + | request.method === "GET" && | |
| 50 | + | viewer?.verified && | |
| 51 | + | (viewer.workspaces ?? []).length === 0 && | |
| 52 | + | !BEFORE_WORKSPACE.includes(pathname) && | |
| 53 | + | !pathname.endsWith(".data") | |
| 54 | + | ) { | |
| 55 | + | const next = pathname === "/" ? "" : `?next=${encodeURIComponent(pathname + search)}`; | |
| 56 | + | throw redirect(`/workspaces/new${next}`); | |
| 36 | 57 | } | |
| 37 | 58 | }; | |
| 38 | 59 | ||
| 42 | 63 | return context.get(viewerContext); | |
| 43 | 64 | } | |
| 44 | 65 | ||
| 66 | + | /** The viewer's role in a workspace, or null if they are not a member. */ | |
| 67 | + | export function roleIn(viewer: Viewer, slug: string): Role | null { | |
| 68 | + | const wanted = slug.toLowerCase(); | |
| 69 | + | return ( | |
| 70 | + | viewer?.workspaces?.find((membership) => membership.slug === wanted)?.role ?? null | |
| 71 | + | ); | |
| 72 | + | } | |
| 73 | + | ||
| 45 | 74 | export function requireUser(context: Context, request: Request): User { | |
| 46 | 75 | const viewer = getViewer(context); | |
| 47 | 76 | if (!viewer) { |
| 1 | + | import { type HighlighterCore, createHighlighterCore } from "shiki/core"; | |
| 2 | + | import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; | |
| 3 | + | ||
| 4 | + | import type { FileDiff } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { HighlightedFile, HighlightedLine } from "./diff"; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * Syntax highlighting, shared by the server and the browser. Grammars are | |
| 10 | + | * imported on first use, so a page that highlights nothing loads none. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | /** File extension (or whole file name) to Shiki language. */ | |
| 14 | + | const LANGUAGES: Record<string, string> = { | |
| 15 | + | rs: "rust", | |
| 16 | + | ts: "typescript", | |
| 17 | + | tsx: "tsx", | |
| 18 | + | js: "javascript", | |
| 19 | + | mjs: "javascript", | |
| 20 | + | jsx: "jsx", | |
| 21 | + | json: "json", | |
| 22 | + | jsonc: "jsonc", | |
| 23 | + | toml: "toml", | |
| 24 | + | yaml: "yaml", | |
| 25 | + | yml: "yaml", | |
| 26 | + | md: "markdown", | |
| 27 | + | css: "css", | |
| 28 | + | html: "html", | |
| 29 | + | sql: "sql", | |
| 30 | + | sh: "shellscript", | |
| 31 | + | bash: "shellscript", | |
| 32 | + | py: "python", | |
| 33 | + | go: "go", | |
| 34 | + | dockerfile: "docker", | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | export const THEME = "vitesse-dark"; | |
| 38 | + | ||
| 39 | + | /** The language of a file, from its name, if it is one g1t highlights. */ | |
| 40 | + | export function languageOf(path: string): string | null { | |
| 41 | + | const name = path.split("/").pop()!.toLowerCase(); | |
| 42 | + | return LANGUAGES[name.includes(".") ? name.split(".").pop()! : name] ?? null; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /** The language a fenced block names (`rust`, `ts`, `sh`…), if g1t highlights it. */ | |
| 46 | + | export function languageNamed(name: string): string | null { | |
| 47 | + | const wanted = name.toLowerCase(); | |
| 48 | + | if (Object.values(LANGUAGES).includes(wanted)) return wanted; | |
| 49 | + | return LANGUAGES[wanted] ?? (wanted === "shell" || wanted === "console" ? "shellscript" : null); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | let highlighter: Promise<HighlighterCore> | undefined; | |
| 53 | + | ||
| 54 | + | export function getHighlighter(): Promise<HighlighterCore> { | |
| 55 | + | highlighter ??= createHighlighterCore({ | |
| 56 | + | themes: [import("shiki/themes/vitesse-dark.mjs")], | |
| 57 | + | langs: [ | |
| 58 | + | import("shiki/langs/rust.mjs"), | |
| 59 | + | import("shiki/langs/typescript.mjs"), | |
| 60 | + | import("shiki/langs/tsx.mjs"), | |
| 61 | + | import("shiki/langs/javascript.mjs"), | |
| 62 | + | import("shiki/langs/jsx.mjs"), | |
| 63 | + | import("shiki/langs/json.mjs"), | |
| 64 | + | import("shiki/langs/jsonc.mjs"), | |
| 65 | + | import("shiki/langs/toml.mjs"), | |
| 66 | + | import("shiki/langs/yaml.mjs"), | |
| 67 | + | import("shiki/langs/markdown.mjs"), | |
| 68 | + | import("shiki/langs/css.mjs"), | |
| 69 | + | import("shiki/langs/html.mjs"), | |
| 70 | + | import("shiki/langs/sql.mjs"), | |
| 71 | + | import("shiki/langs/shellscript.mjs"), | |
| 72 | + | import("shiki/langs/python.mjs"), | |
| 73 | + | import("shiki/langs/go.mjs"), | |
| 74 | + | import("shiki/langs/docker.mjs"), | |
| 75 | + | ], | |
| 76 | + | engine: createJavaScriptRegexEngine(), | |
| 77 | + | }); | |
| 78 | + | return highlighter; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | function escape(text: string): string { | |
| 82 | + | return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /** Each line of `text` as HTML, coloured. */ | |
| 86 | + | export function linesToHtml(core: HighlighterCore, text: string, lang: string): string[] { | |
| 87 | + | const { tokens } = core.codeToTokens(text, { lang, theme: THEME }); | |
| 88 | + | return tokens.map((row) => | |
| 89 | + | row | |
| 90 | + | .map((token) => | |
| 91 | + | token.color ? `<span style="color:${token.color}">${escape(token.content)}</span>` : escape(token.content), | |
| 92 | + | ) | |
| 93 | + | .join(""), | |
| 94 | + | ); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /** Larger files in a diff are left plain. */ | |
| 98 | + | const MAX_FILE_DIFF_CHARS = 60_000; | |
| 99 | + | ||
| 100 | + | /** | |
| 101 | + | * A file's diff with each line's text highlighted, or null when its | |
| 102 | + | * language is unknown or it is too large. Each hunk is highlighted twice, | |
| 103 | + | * as it was and as it is, so a line's colours come from the side it is on. | |
| 104 | + | */ | |
| 105 | + | export async function highlightFile(file: FileDiff): Promise<HighlightedFile | null> { | |
| 106 | + | const lang = languageOf(file.path); | |
| 107 | + | if (!lang || file.binary) return null; | |
| 108 | + | const size = file.hunks.reduce((sum, hunk) => sum + hunk.lines.reduce((n, line) => n + line.text.length, 0), 0); | |
| 109 | + | if (size > MAX_FILE_DIFF_CHARS) return null; | |
| 110 | + | const core = await getHighlighter(); | |
| 111 | + | try { | |
| 112 | + | const hunks = file.hunks.map((hunk) => { | |
| 113 | + | const lines: HighlightedLine[] = hunk.lines.map((line) => ({ ...line })); | |
| 114 | + | for (const side of ["old", "new"] as const) { | |
| 115 | + | const indexes = lines | |
| 116 | + | .map((line, index) => (line.kind === (side === "old" ? "add" : "delete") ? -1 : index)) | |
| 117 | + | .filter((index) => index >= 0); | |
| 118 | + | const html = linesToHtml(core, indexes.map((index) => lines[index]!.text).join("\n"), lang); | |
| 119 | + | html.forEach((row, n) => { | |
| 120 | + | const line = lines[indexes[n]!]; | |
| 121 | + | // Context lines are the same on both sides; the new side wins. | |
| 122 | + | if (!line || (side === "old" && line.kind === "context")) return; | |
| 123 | + | line.html = row; | |
| 124 | + | }); | |
| 125 | + | } | |
| 126 | + | return { lines }; | |
| 127 | + | }); | |
| 128 | + | return { ...file, hunks }; | |
| 129 | + | } catch { | |
| 130 | + | return null; | |
| 131 | + | } | |
| 132 | + | } |
| 1 | 1 | import { | |
| 2 | 2 | BookOpen, | |
| 3 | − | Building2, | |
| 4 | 3 | ChevronDown, | |
| 5 | 4 | LayoutDashboard, | |
| 6 | 5 | LogOut, | |
| 18 | 17 | Outlet, | |
| 19 | 18 | Scripts, | |
| 20 | 19 | ScrollRestoration, | |
| 20 | + | type ShouldRevalidateFunctionArgs, | |
| 21 | 21 | useRouteLoaderData, | |
| 22 | 22 | useSubmit, | |
| 23 | 23 | } from "react-router"; | |
| 27 | 27 | import type { Route } from "./+types/root"; | |
| 28 | 28 | import "./app.css"; | |
| 29 | 29 | import { Logo } from "./components/logo"; | |
| 30 | − | import { Avatar, ButtonLink } from "./components/ui"; | |
| 30 | + | import { Avatar, ButtonLink, notACredential } from "./components/ui"; | |
| 31 | 31 | import { | |
| 32 | 32 | DropdownMenu, | |
| 33 | 33 | DropdownMenuContent, | |
| 36 | 36 | DropdownMenuSeparator, | |
| 37 | 37 | DropdownMenuTrigger, | |
| 38 | 38 | } from "./components/ui/dropdown-menu"; | |
| 39 | − | import { getViewer, viewerMiddleware } from "./lib/session.server"; | |
| 39 | + | import { AppShell, Progress, type ShellData } from "./components/shell"; | |
| 40 | + | import { billing, repos, work } from "./lib/services.server"; | |
| 41 | + | import { getViewer, roleIn, viewerMiddleware } from "./lib/session.server"; | |
| 40 | 42 | ||
| 41 | 43 | export const links: Route.LinksFunction = () => [ | |
| 42 | 44 | { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, | |
| 54 | 56 | ||
| 55 | 57 | export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware]; | |
| 56 | 58 | ||
| 57 | − | export function loader({ context }: Route.LoaderArgs) { | |
| 58 | − | return { user: getViewer(context) }; | |
| 59 | + | export async function loader({ context, params }: Route.LoaderArgs) { | |
| 60 | + | const user = getViewer(context); | |
| 61 | + | return { user, shell: user ? await shellFor(user, params) : null }; | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 64 | + | /** | |
| 65 | + | * The sidebar only changes with the workspace or repository being looked | |
| 66 | + | * at, or after something was submitted: not on every page within them. | |
| 67 | + | */ | |
| 68 | + | export function shouldRevalidate({ | |
| 69 | + | currentParams, | |
| 70 | + | nextParams, | |
| 71 | + | formMethod, | |
| 72 | + | defaultShouldRevalidate, | |
| 73 | + | }: ShouldRevalidateFunctionArgs) { | |
| 74 | + | if (formMethod && formMethod !== "GET") return defaultShouldRevalidate; | |
| 75 | + | return currentParams.owner !== nextParams.owner || currentParams.repo !== nextParams.repo; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** | |
| 79 | + | * The sidebar: the workspace being looked at if they belong to it, else | |
| 80 | + | * their first; its repositories; and the repository being looked at. | |
| 81 | + | */ | |
| 82 | + | async function shellFor( | |
| 83 | + | user: User, | |
| 84 | + | params: { owner?: string; repo?: string }, | |
| 85 | + | ): Promise<ShellData> { | |
| 86 | + | const memberships = user.workspaces ?? []; | |
| 87 | + | const here = params.owner ? memberships.find((m) => m.slug === params.owner?.toLowerCase()) : undefined; | |
| 88 | + | const workspace = here ?? memberships[0] ?? null; | |
| 89 | + | const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null; | |
| 90 | + | const [listed, counts, account] = await Promise.all([ | |
| 91 | + | workspace ? repos.list(user, { namespace: workspace.slug }) : Promise.resolve([]), | |
| 92 | + | path ? work.counts(path, user) : Promise.resolve(null), | |
| 93 | + | workspace ? billing.account(workspace.slug, user) : Promise.resolve(null), | |
| 94 | + | ]); | |
| 95 | + | return { | |
| 96 | + | workspace, | |
| 97 | + | repos: listed | |
| 98 | + | .filter((repo) => !repo.forkOf) | |
| 99 | + | .map(({ namespace, name, isPrivate }) => ({ namespace, name, isPrivate })), | |
| 100 | + | repo: | |
| 101 | + | path && counts?.ok | |
| 102 | + | ? { | |
| 103 | + | ...path, | |
| 104 | + | member: roleIn(user, path.namespace) != null, | |
| 105 | + | issues: counts.value.issues, | |
| 106 | + | pulls: counts.value.pulls, | |
| 107 | + | } | |
| 108 | + | : null, | |
| 109 | + | creditMicros: | |
| 110 | + | account?.ok && account.value.status.enabled ? account.value.balanceMicros : null, | |
| 111 | + | }; | |
| 112 | + | } | |
| 113 | + | ||
| 61 | 114 | function HeaderLink({ to, children }: { to: string; children: React.ReactNode }) { | |
| 62 | 115 | return ( | |
| 63 | 116 | <NavLink | |
| 88 | 141 | /> | |
| 89 | 142 | <input | |
| 90 | 143 | name="q" | |
| 144 | + | {...notACredential()} | |
| 91 | 145 | placeholder="Search repositories" | |
| 92 | 146 | aria-label="Search repositories" | |
| 93 | 147 | className="w-full rounded-md border border-line bg-bg py-1.5 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim" | |
| 126 | 180 | {(user.workspaces ?? []).map((membership) => ( | |
| 127 | 181 | <DropdownMenuItem asChild key={membership.slug}> | |
| 128 | 182 | <Link to={`/${membership.slug}`}> | |
| 129 | − | <Building2 /> | |
| 183 | + | <Avatar name={membership.slug} size={16} square /> | |
| 130 | 184 | {membership.slug} | |
| 131 | 185 | </Link> | |
| 132 | 186 | </DropdownMenuItem> | |
| 256 | 310 | ||
| 257 | 311 | export function Layout({ children }: { children: React.ReactNode }) { | |
| 258 | 312 | // Undefined when the root loader itself failed. | |
| 259 | − | const user = useRouteLoaderData<typeof loader>("root")?.user; | |
| 313 | + | const root = useRouteLoaderData<typeof loader>("root"); | |
| 314 | + | const user = root?.user; | |
| 315 | + | const banner = user && !user.verified && ( | |
| 316 | + | <Form | |
| 317 | + | method="post" | |
| 318 | + | action="/verify" | |
| 319 | + | className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warn/30 bg-warn/10 px-4 py-2 text-sm" | |
| 320 | + | > | |
| 321 | + | <span> | |
| 322 | + | Confirm your email address to create repositories and push. We | |
| 323 | + | sent you a link. | |
| 324 | + | </span> | |
| 325 | + | <button type="submit" className="font-medium underline underline-offset-4"> | |
| 326 | + | Send it again | |
| 327 | + | </button> | |
| 328 | + | </Form> | |
| 329 | + | ); | |
| 260 | 330 | return ( | |
| 261 | 331 | <html lang="en"> | |
| 262 | 332 | <head> | |
| 267 | 337 | <Links /> | |
| 268 | 338 | </head> | |
| 269 | 339 | <body className="flex min-h-screen flex-col"> | |
| 270 | − | <Header user={user} /> | |
| 271 | − | {user && !user.verified && ( | |
| 272 | − | <Form | |
| 273 | − | method="post" | |
| 274 | − | action="/verify" | |
| 275 | − | className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warn/30 bg-warn/10 px-4 py-2 text-sm" | |
| 276 | − | > | |
| 277 | − | <span> | |
| 278 | − | Confirm your email address to create repositories and push. We | |
| 279 | − | sent you a link. | |
| 280 | − | </span> | |
| 281 | − | <button type="submit" className="font-medium underline underline-offset-4"> | |
| 282 | − | Send it again | |
| 283 | − | </button> | |
| 284 | − | </Form> | |
| 340 | + | {user && root?.shell ? ( | |
| 341 | + | <AppShell user={user} shell={root.shell} banner={banner}> | |
| 342 | + | {children} | |
| 343 | + | </AppShell> | |
| 344 | + | ) : ( | |
| 345 | + | <> | |
| 346 | + | <Progress /> | |
| 347 | + | <Header user={user} /> | |
| 348 | + | {banner} | |
| 349 | + | <div className="grow">{children}</div> | |
| 350 | + | <Footer /> | |
| 351 | + | </> | |
| 285 | 352 | )} | |
| 286 | − | <div className="grow">{children}</div> | |
| 287 | − | <Footer /> | |
| 288 | 353 | <ScrollRestoration /> | |
| 289 | 354 | <Scripts /> | |
| 290 | 355 | </body> |
| 14 | 14 | route("settings", "routes/settings.tsx"), | |
| 15 | 15 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 16 | 16 | route("search", "routes/explore.tsx", { id: "search" }), | |
| 17 | − | route("workspaces/new", "routes/workspace-new.tsx"), | |
| 18 | − | route(":owner", "routes/workspace.tsx"), | |
| 17 | + | route("workspaces/new", "routes/workspace/new.tsx"), | |
| 18 | + | // A workspace's own pages sit under `-`, which no repository can be named. | |
| 19 | + | route(":owner", "routes/workspace/layout.tsx", [ | |
| 20 | + | index("routes/workspace/overview.tsx"), | |
| 21 | + | route("-/people", "routes/workspace/people.tsx"), | |
| 22 | + | route("-/tokens", "routes/workspace/tokens.tsx"), | |
| 23 | + | route("-/billing", "routes/workspace/billing.tsx"), | |
| 24 | + | route("-/settings", "routes/workspace/settings.tsx"), | |
| 25 | + | ]), | |
| 26 | + | // Why a line is the way it is, fetched by the blame view. | |
| 27 | + | route(":owner/:repo/why/:hash", "routes/repo/why.ts"), | |
| 19 | 28 | route(":owner/:repo", "routes/repo/layout.tsx", [ | |
| 20 | 29 | index("routes/repo/code.tsx"), | |
| 21 | 30 | route("tree/:ref/*", "routes/repo/tree.tsx"), | |
| 22 | 31 | route("blob/:ref/*", "routes/repo/blob.tsx"), | |
| 23 | 32 | route("commits", "routes/repo/commits.tsx"), | |
| 33 | + | route("commit/:hash", "routes/repo/commit.tsx"), | |
| 24 | 34 | route("issues", "routes/repo/issues.tsx"), | |
| 25 | 35 | route("issues/new", "routes/repo/issue-new.tsx"), | |
| 26 | 36 | route("issues/:number", "routes/repo/issue.tsx"), | |
| 27 | 37 | route("pulls", "routes/repo/pulls.tsx"), | |
| 28 | 38 | route("pulls/new", "routes/repo/pull-new.tsx"), | |
| 29 | 39 | route("pull/:number", "routes/repo/pull.tsx"), | |
| 40 | + | route("queue", "routes/repo/queue.tsx"), | |
| 41 | + | route("plans", "routes/repo/plans.tsx"), | |
| 42 | + | route("plans/:id", "routes/repo/plan.tsx"), | |
| 43 | + | route("settings", "routes/repo/settings.tsx"), | |
| 30 | 44 | ]), | |
| 31 | 45 | ] satisfies RouteConfig; |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/explore"; | |
| 5 | 5 | import { RepoList } from "../components/repo-list"; | |
| 6 | + | import { notACredential } from "../components/ui"; | |
| 6 | 7 | import { repos } from "../lib/services.server"; | |
| 7 | 8 | import { getViewer } from "../lib/session.server"; | |
| 8 | 9 | ||
| 37 | 38 | /> | |
| 38 | 39 | <input | |
| 39 | 40 | name="q" | |
| 41 | + | {...notACredential()} | |
| 40 | 42 | defaultValue={query} | |
| 41 | 43 | placeholder="Search by name or description" | |
| 42 | 44 | aria-label="Search repositories" |
| 1 | − | import { ArrowRight, Plus } from "lucide-react"; | |
| 2 | − | import { Link, redirect } from "react-router"; | |
| 1 | + | import { ArrowRight, Hand, Plus } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useEffect } from "react"; | |
| 3 | + | import { Link, data, useRevalidator } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Lifecycle, Pull, Repo } from "@g1t/contracts"; | |
| 3 | 6 | ||
| 4 | 7 | import type { Route } from "./+types/home"; | |
| 5 | 8 | import { Landing } from "../components/landing"; | |
| 9 | + | import { STAGE_LABEL, StageDots } from "../components/lifecycle"; | |
| 6 | 10 | import { RepoList } from "../components/repo-list"; | |
| 7 | 11 | import { | |
| 8 | 12 | Avatar, | |
| 11 | 15 | EmptyState, | |
| 12 | 16 | TimeAgo, | |
| 13 | 17 | } from "../components/ui"; | |
| 14 | − | import { PullIcon } from "../components/work"; | |
| 18 | + | import { Assignee, IssueIcon, PullIcon } from "../components/work"; | |
| 15 | 19 | import { repos as reposApi, work } from "../lib/services.server"; | |
| 16 | 20 | import { getViewer } from "../lib/session.server"; | |
| 17 | 21 | ||
| 22 | + | const REFRESH_MS = 5000; | |
| 23 | + | ||
| 18 | 24 | export function meta({}: Route.MetaArgs) { | |
| 19 | 25 | return [ | |
| 20 | 26 | { title: "g1t — Git for AI scale" }, | |
| 26 | 32 | ]; | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 35 | + | export function headers({ loaderHeaders }: Route.HeadersArgs) { | |
| 36 | + | return { "Server-Timing": loaderHeaders.get("Server-Timing") ?? "" }; | |
| 37 | + | } | |
| 38 | + | ||
| 29 | 39 | export async function loader({ context }: Route.LoaderArgs) { | |
| 30 | 40 | const viewer = getViewer(context); | |
| 31 | − | // Nothing can be created outside a workspace, so a new account starts there. | |
| 32 | − | if (viewer?.verified && (viewer.workspaces ?? []).length === 0) { | |
| 33 | − | throw redirect("/workspaces/new"); | |
| 34 | − | } | |
| 35 | − | const [repos, pulls] = await Promise.all([ | |
| 36 | − | reposApi.list(viewer, { memberOnly: Boolean(viewer) }), | |
| 37 | − | work.listActivePulls(viewer), | |
| 41 | + | const started = Date.now(); | |
| 42 | + | const times: Record<string, number> = {}; | |
| 43 | + | const timed = <T,>(name: string, promise: Promise<T>) => | |
| 44 | + | promise.then((value) => ((times[name] = Date.now() - started), value)); | |
| 45 | + | const [repos, pulls, assigned] = await Promise.all([ | |
| 46 | + | timed("repos", reposApi.list(viewer, { memberOnly: Boolean(viewer) })), | |
| 47 | + | timed("pulls", work.listActivePulls(viewer)), | |
| 48 | + | timed("assigned", work.listAssignedIssues(viewer)), | |
| 38 | 49 | ]); | |
| 39 | − | // Mission control links to each pull request under its repo. | |
| 40 | − | const pullRepos = await Promise.all( | |
| 41 | − | pulls.map(({ pull }) => reposApi.getById(pull.repoId, viewer)), | |
| 42 | − | ); | |
| 43 | − | return { | |
| 50 | + | // Each issue and pull request is shown under its repository. Most are in | |
| 51 | + | // the viewer's own, already listed; the rest are looked up once each, all | |
| 52 | + | // at once. One the viewer can no longer see drops out. | |
| 53 | + | const known = new Map<string, Repo>(repos.map((repo) => [repo.id, repo])); | |
| 54 | + | const missing = [ | |
| 55 | + | ...new Set([...assigned.map((issue) => issue.repoId), ...pulls.map(({ pull }) => pull.repoId)]), | |
| 56 | + | ].filter((id) => !known.has(id)); | |
| 57 | + | const looked = await Promise.all(missing.map((id) => reposApi.getById(id, viewer))); | |
| 58 | + | for (const found of looked) if (found.ok) known.set(found.value.id, found.value); | |
| 59 | + | times.lookups = Date.now() - started; | |
| 60 | + | const serverTiming = Object.entries(times) | |
| 61 | + | .map(([name, ms]) => `${name};dur=${ms}`) | |
| 62 | + | .join(", "); | |
| 63 | + | return data({ | |
| 44 | 64 | viewer, | |
| 45 | 65 | repos, | |
| 46 | − | active: pulls.flatMap((item, i) => { | |
| 47 | − | const repo = pullRepos[i]; | |
| 48 | − | return repo.ok ? [{ ...item, repo: repo.value }] : []; | |
| 66 | + | assigned: assigned.flatMap((issue) => { | |
| 67 | + | const repo = known.get(issue.repoId); | |
| 68 | + | return repo ? [{ issue, repo }] : []; | |
| 49 | 69 | }), | |
| 50 | − | }; | |
| 70 | + | active: pulls.flatMap((item) => { | |
| 71 | + | const repo = known.get(item.pull.repoId); | |
| 72 | + | return repo ? [{ ...item, repo }] : []; | |
| 73 | + | }), | |
| 74 | + | }, { headers: { "Server-Timing": serverTiming } }); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | type Active = { pull: Pull; lifecycle: Lifecycle | null; repo: Repo }; | |
| 78 | + | ||
| 79 | + | /** What a pull request is waiting on, in a word or two. */ | |
| 80 | + | function standing({ pull, lifecycle }: Active): string { | |
| 81 | + | if (lifecycle) return STAGE_LABEL[lifecycle.stage]; | |
| 82 | + | return pull.status === "draft" ? "In progress" : "Ready for review"; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | function PullRows({ items, detail }: { items: Active[]; detail?: boolean }) { | |
| 86 | + | return ( | |
| 87 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 88 | + | {items.map((item) => { | |
| 89 | + | const { pull, lifecycle, repo } = item; | |
| 90 | + | return ( | |
| 91 | + | <li key={pull.id}> | |
| 92 | + | <Link | |
| 93 | + | prefetch="intent" | |
| 94 | + | to={`/${repo.namespace}/${repo.name}/pull/${pull.number}`} | |
| 95 | + | className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised" | |
| 96 | + | > | |
| 97 | + | <PullIcon status={pull.status} /> | |
| 98 | + | <span className="min-w-0 grow"> | |
| 99 | + | <span className="block truncate font-medium">{pull.title}</span> | |
| 100 | + | <span className="block truncate font-mono text-xs text-muted"> | |
| 101 | + | {repo.namespace}/{repo.name}#{pull.number} | |
| 102 | + | {pull.issue != null && ` · for #${pull.issue}`} · {pull.agent} | |
| 103 | + | </span> | |
| 104 | + | {detail && lifecycle && ( | |
| 105 | + | <span className="mt-1 block text-xs text-muted">{lifecycle.detail}</span> | |
| 106 | + | )} | |
| 107 | + | </span> | |
| 108 | + | {lifecycle && ( | |
| 109 | + | <span className="hidden shrink-0 sm:block"> | |
| 110 | + | <StageDots stage={lifecycle.stage} /> | |
| 111 | + | </span> | |
| 112 | + | )} | |
| 113 | + | <span className="w-28 shrink-0 text-right text-xs text-muted"> | |
| 114 | + | {standing(item)} | |
| 115 | + | </span> | |
| 116 | + | <span className="hidden w-14 shrink-0 text-right text-xs text-faint sm:block"> | |
| 117 | + | <TimeAgo at={pull.updatedAt} /> | |
| 118 | + | </span> | |
| 119 | + | </Link> | |
| 120 | + | </li> | |
| 121 | + | ); | |
| 122 | + | })} | |
| 123 | + | </ul> | |
| 124 | + | ); | |
| 51 | 125 | } | |
| 52 | 126 | ||
| 127 | + | function Section({ title, children }: { title: ReactNode; children: ReactNode }) { | |
| 128 | + | return ( | |
| 129 | + | <section> | |
| 130 | + | <h2 className="flex items-center gap-2 text-sm font-medium text-muted">{title}</h2> | |
| 131 | + | <div className="mt-3">{children}</div> | |
| 132 | + | </section> | |
| 133 | + | ); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | function plural(count: number, one: string, many: string): string { | |
| 137 | + | return `${count} ${count === 1 ? one : many}`; | |
| 138 | + | } | |
| 139 | + | ||
| 53 | 140 | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 54 | − | const { viewer, repos, active } = loaderData; | |
| 141 | + | const { viewer, repos, active, assigned } = loaderData; | |
| 142 | + | ||
| 143 | + | const needsYou = active.filter((item) => item.lifecycle?.stage === "needs_you"); | |
| 144 | + | const ready = active.filter((item) => item.lifecycle?.stage === "ready"); | |
| 145 | + | const moving = active.filter( | |
| 146 | + | (item) => item.lifecycle?.stage !== "needs_you" && item.lifecycle?.stage !== "ready", | |
| 147 | + | ); | |
| 148 | + | // Agents are at work, so the page changes without anyone touching it. | |
| 149 | + | const changing = moving.some((item) => item.lifecycle || item.pull.status === "draft"); | |
| 150 | + | const revalidator = useRevalidator(); | |
| 151 | + | useEffect(() => { | |
| 152 | + | if (!changing) return; | |
| 153 | + | const timer = setInterval(() => { | |
| 154 | + | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 155 | + | }, REFRESH_MS); | |
| 156 | + | return () => clearInterval(timer); | |
| 157 | + | }, [changing, revalidator]); | |
| 158 | + | ||
| 55 | 159 | if (!viewer) return <Landing repos={repos} />; | |
| 56 | 160 | ||
| 57 | − | const working = active.filter(({ pull }) => pull.status === "draft"); | |
| 161 | + | const summary = [ | |
| 162 | + | moving.length > 0 && `${moving.length} in progress`, | |
| 163 | + | ready.length > 0 && `${ready.length} ready to merge`, | |
| 164 | + | needsYou.length > 0 && plural(needsYou.length, "needs you", "need you"), | |
| 165 | + | assigned.length > 0 && `${assigned.length} assigned to you`, | |
| 166 | + | ].filter(Boolean); | |
| 58 | 167 | return ( | |
| 59 | 168 | <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_20rem]"> | |
| 60 | 169 | <div className="min-w-0 space-y-10"> | |
| 66 | 175 | Mission control | |
| 67 | 176 | </h1> | |
| 68 | 177 | <p className="text-sm text-muted"> | |
| 69 | − | {working.length === 0 | |
| 178 | + | {summary.length === 0 | |
| 70 | 179 | ? "Nothing is being worked on right now." | |
| 71 | − | : `${working.length} ${working.length === 1 ? "pull request is" : "pull requests are"} being worked on.`} | |
| 180 | + | : `${summary.join(" · ")}.`} | |
| 72 | 181 | </p> | |
| 73 | 182 | </div> | |
| 74 | 183 | </div> | |
| 75 | 184 | </section> | |
| 76 | 185 | ||
| 77 | − | <section> | |
| 78 | − | <h2 className="text-sm font-medium text-muted">In progress</h2> | |
| 79 | − | <div className="mt-3"> | |
| 80 | − | {active.length === 0 ? ( | |
| 81 | − | <EmptyState title="No pull requests in progress"> | |
| 82 | − | Open an issue on a repository and put an agent on it, or point | |
| 83 | − | your own agent at one. | |
| 84 | − | </EmptyState> | |
| 85 | − | ) : ( | |
| 86 | − | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 87 | − | {active.map(({ pull, repo }) => ( | |
| 88 | − | <li key={pull.id}> | |
| 89 | − | <Link | |
| 90 | − | to={`/${repo.namespace}/${repo.name}/pull/${pull.number}`} | |
| 91 | − | className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised" | |
| 92 | − | > | |
| 93 | − | <PullIcon status={pull.status} /> | |
| 94 | − | <span className="min-w-0 grow"> | |
| 95 | − | <span className="block truncate font-medium">{pull.title}</span> | |
| 96 | − | <span className="font-mono text-xs text-muted"> | |
| 97 | − | {repo.namespace}/{repo.name}#{pull.number} | |
| 98 | − | {pull.issue != null && ` · for #${pull.issue}`} · {pull.agent} | |
| 99 | − | </span> | |
| 100 | − | </span> | |
| 101 | − | <span className="shrink-0 text-xs text-muted"> | |
| 102 | − | {pull.status === "draft" ? "In progress" : "Ready for review"} | |
| 103 | − | </span> | |
| 104 | − | <span className="w-14 shrink-0 text-right text-xs text-faint"> | |
| 105 | − | <TimeAgo at={pull.updatedAt} /> | |
| 186 | + | {needsYou.length > 0 && ( | |
| 187 | + | <Section | |
| 188 | + | title={ | |
| 189 | + | <> | |
| 190 | + | <Hand size={14} className="text-warn" /> | |
| 191 | + | Needs you | |
| 192 | + | </> | |
| 193 | + | } | |
| 194 | + | > | |
| 195 | + | <PullRows items={needsYou} detail /> | |
| 196 | + | </Section> | |
| 197 | + | )} | |
| 198 | + | ||
| 199 | + | {ready.length > 0 && ( | |
| 200 | + | <Section title="Ready to merge"> | |
| 201 | + | <PullRows items={ready} /> | |
| 202 | + | </Section> | |
| 203 | + | )} | |
| 204 | + | ||
| 205 | + | {assigned.length > 0 && ( | |
| 206 | + | <Section title="Assigned to you"> | |
| 207 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 208 | + | {assigned.map(({ issue, repo }) => ( | |
| 209 | + | <li key={issue.id}> | |
| 210 | + | <Link | |
| 211 | + | prefetch="intent" | |
| 212 | + | to={`/${repo.namespace}/${repo.name}/issues/${issue.number}`} | |
| 213 | + | className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised" | |
| 214 | + | > | |
| 215 | + | <IssueIcon issue={issue} /> | |
| 216 | + | <span className="min-w-0 grow"> | |
| 217 | + | <span className="block truncate font-medium">{issue.title}</span> | |
| 218 | + | <span className="block truncate font-mono text-xs text-muted"> | |
| 219 | + | {repo.namespace}/{repo.name}#{issue.number} | |
| 106 | 220 | </span> | |
| 107 | − | </Link> | |
| 108 | − | </li> | |
| 109 | − | ))} | |
| 110 | − | </ul> | |
| 111 | − | )} | |
| 112 | − | </div> | |
| 113 | − | </section> | |
| 221 | + | </span> | |
| 222 | + | {issue.agent && <Assignee agent={issue.agent} />} | |
| 223 | + | <span className="hidden w-14 shrink-0 text-right text-xs text-faint sm:block"> | |
| 224 | + | <TimeAgo at={issue.updatedAt} /> | |
| 225 | + | </span> | |
| 226 | + | </Link> | |
| 227 | + | </li> | |
| 228 | + | ))} | |
| 229 | + | </ul> | |
| 230 | + | </Section> | |
| 231 | + | )} | |
| 232 | + | ||
| 233 | + | <Section title="In progress"> | |
| 234 | + | {moving.length === 0 ? ( | |
| 235 | + | <EmptyState title="No pull requests in progress"> | |
| 236 | + | Assign issues to the g1t agent from a repository's Issues tab, | |
| 237 | + | or point your own agent at one. | |
| 238 | + | </EmptyState> | |
| 239 | + | ) : ( | |
| 240 | + | <PullRows items={moving} /> | |
| 241 | + | )} | |
| 242 | + | </Section> | |
| 114 | 243 | ||
| 115 | 244 | <section> | |
| 116 | 245 | <div className="flex items-center justify-between"> |
| 30 | 30 | name: String(form.get("name") ?? ""), | |
| 31 | 31 | description: String(form.get("description") ?? ""), | |
| 32 | 32 | isPrivate: form.get("visibility") === "private", | |
| 33 | + | importUrl: String(form.get("importUrl") ?? "").trim() || undefined, | |
| 33 | 34 | }); | |
| 34 | 35 | if (!result.ok) return { error: result.error.message }; | |
| 35 | 36 | throw redirect(`/${result.value.namespace}/${result.value.name}`); | |
| 64 | 65 | <Field label="Description (optional)"> | |
| 65 | 66 | <Input name="description" maxLength={200} /> | |
| 66 | 67 | </Field> | |
| 68 | + | <Field | |
| 69 | + | label="Import from (optional)" | |
| 70 | + | hint="The address of a public repository on GitHub or any git host. Its default branch is copied, up to 40 MB. Leave empty to start with nothing." | |
| 71 | + | > | |
| 72 | + | <Input | |
| 73 | + | name="importUrl" | |
| 74 | + | type="url" | |
| 75 | + | placeholder="https://github.com/owner/repo" | |
| 76 | + | /> | |
| 77 | + | </Field> | |
| 67 | 78 | <fieldset className="space-y-2 text-sm"> | |
| 68 | 79 | <label className="flex items-center gap-2"> | |
| 69 | 80 | <input type="radio" name="visibility" value="public" defaultChecked /> | |
| 71 | 82 | </label> | |
| 72 | 83 | <label className="flex items-center gap-2"> | |
| 73 | 84 | <input type="radio" name="visibility" value="private" /> | |
| 74 | − | Private — only you | |
| 85 | + | Private — only members of the workspace | |
| 75 | 86 | </label> | |
| 76 | 87 | </fieldset> | |
| 77 | 88 | <ErrorText>{actionData?.error}</ErrorText> |
| 51 | 51 | <Form method="post" className="space-y-4"> | |
| 52 | 52 | <Field | |
| 53 | 53 | label="Username" | |
| 54 | − | hint="Lowercase letters, digits and hyphens. This is your namespace: g1t.sh/username." | |
| 54 | + | hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you." | |
| 55 | 55 | > | |
| 56 | 56 | <Input | |
| 57 | 57 | name="username" |
| 1 | 1 | import type { Route } from "./+types/blob"; | |
| 2 | 2 | import { BlobView } from "../../components/repo-view"; | |
| 3 | − | import { highlight } from "../../lib/highlight.server"; | |
| 3 | + | import { highlight, highlightLines } from "../../lib/highlight.server"; | |
| 4 | 4 | import { repos } from "../../lib/services.server"; | |
| 5 | 5 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 6 | 6 | ||
| 8 | 8 | return [{ title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }]; | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 11 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 12 | + | const viewer = getViewer(context); | |
| 12 | 13 | const path = { namespace: params.owner, name: params.repo }; | |
| 13 | − | const blob = unwrap( | |
| 14 | − | await repos.blob(path, getViewer(context), params.ref, params["*"] ?? ""), | |
| 15 | − | ); | |
| 14 | + | const file = params["*"] ?? ""; | |
| 15 | + | const wantsBlame = new URL(request.url).searchParams.has("blame"); | |
| 16 | + | const [found, blame] = await Promise.all([ | |
| 17 | + | repos.blob(path, viewer, params.ref, file), | |
| 18 | + | wantsBlame ? repos.blame(path, viewer, params.ref, file) : null, | |
| 19 | + | ]); | |
| 20 | + | const blob = unwrap(found); | |
| 21 | + | if (blame?.ok && blob.text != null) { | |
| 22 | + | return { | |
| 23 | + | blob, | |
| 24 | + | html: null, | |
| 25 | + | blame: { blame: blame.value, lines: await highlightLines(blob.path, blob.text) }, | |
| 26 | + | }; | |
| 27 | + | } | |
| 16 | 28 | return { | |
| 17 | 29 | blob, | |
| 18 | 30 | html: blob.text == null ? null : await highlight(blob.path, blob.text), | |
| 31 | + | blame: null, | |
| 19 | 32 | }; | |
| 20 | 33 | } | |
| 21 | 34 | ||
| 22 | 35 | export default function Blob({ loaderData }: Route.ComponentProps) { | |
| 23 | − | return <BlobView blob={loaderData.blob} html={loaderData.html} />; | |
| 36 | + | return <BlobView blob={loaderData.blob} html={loaderData.html} blame={loaderData.blame} />; | |
| 24 | 37 | } |
| 1 | + | import { Check, Copy, GitCommitHorizontal, GitPullRequest, MessagesSquare, Sparkles } from "lucide-react"; | |
| 2 | + | import { Suspense, useState } from "react"; | |
| 3 | + | import { Await, Link, data, redirect } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Comparison, Pull } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import type { Route } from "./+types/commit"; | |
| 8 | + | import { DiffView } from "../../components/diff-view"; | |
| 9 | + | import { Avatar, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { immutable } from "../../lib/immutable.server"; | |
| 11 | + | import { pullForCommit } from "../../lib/provenance.server"; | |
| 12 | + | import { repos } from "../../lib/services.server"; | |
| 13 | + | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function headers({ loaderHeaders }: Route.HeadersArgs) { | |
| 16 | + | return { "Server-Timing": loaderHeaders.get("Server-Timing") ?? "" }; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 20 | + | const subject = loaderData?.commit.message.split("\n")[0]; | |
| 21 | + | return [ | |
| 22 | + | { title: `${subject ? `${subject} · ` : ""}${params.hash.slice(0, 7)} · ${params.owner}/${params.repo} · g1t` }, | |
| 23 | + | ]; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /** How far back a short hash is looked for. */ | |
| 27 | + | const SHORT_HASH_SEARCH = 300; | |
| 28 | + | ||
| 29 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 30 | + | const viewer = getViewer(context); | |
| 31 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 32 | + | const hash = params.hash.toLowerCase(); | |
| 33 | + | if (!/^[0-9a-f]{4,40}$/.test(hash)) throw data(null, { status: 404 }); | |
| 34 | + | if (hash.length < 40) { | |
| 35 | + | // A short hash: find the commit it names and go to its full address. | |
| 36 | + | const log = await repos.log(path, viewer, null, SHORT_HASH_SEARCH); | |
| 37 | + | const matches = log.ok ? log.value.filter((commit) => commit.hash.startsWith(hash)) : []; | |
| 38 | + | if (matches.length === 1) throw redirect(`/${params.owner}/${params.repo}/commit/${matches[0]!.hash}`); | |
| 39 | + | throw new Response("Commit not found.", { status: 404 }); | |
| 40 | + | } | |
| 41 | + | const started = Date.now(); | |
| 42 | + | const found = unwrap(await repos.get(path, viewer)); | |
| 43 | + | // The viewer may read the repository, and a commit never changes: after | |
| 44 | + | // the first visit it and its diff come from the cache. | |
| 45 | + | let cached = true; | |
| 46 | + | const loaded = await immutable(`commit:${found.id}:${hash}`, async () => { | |
| 47 | + | cached = false; | |
| 48 | + | // At once: a commit is compared with its first parent, so the diff | |
| 49 | + | // does not wait for the commit. | |
| 50 | + | const [log, compared] = await Promise.all([ | |
| 51 | + | repos.log(path, viewer, hash, 1), | |
| 52 | + | repos.compare(found.id, viewer, null, hash), | |
| 53 | + | ]); | |
| 54 | + | const commit = log.ok ? log.value[0] : undefined; | |
| 55 | + | if (!commit) return null; | |
| 56 | + | const comparison: Comparison = compared.ok | |
| 57 | + | ? compared.value | |
| 58 | + | : { base: null, head: commit.hash, files: [], truncated: false }; | |
| 59 | + | return { commit, comparison }; | |
| 60 | + | }); | |
| 61 | + | if (!loaded) throw new Response("Commit not found.", { status: 404 }); | |
| 62 | + | return data( | |
| 63 | + | { | |
| 64 | + | ...loaded, | |
| 65 | + | // Streamed: the page shows the commit while this is worked out. | |
| 66 | + | pull: pullForCommit(path, viewer, loaded.commit.hash), | |
| 67 | + | }, | |
| 68 | + | { headers: { "Server-Timing": `load;dur=${Date.now() - started};desc="${cached ? "cached" : "read"}"` } }, | |
| 69 | + | ); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | function CopyHash({ hash }: { hash: string }) { | |
| 73 | + | const [copied, setCopied] = useState(false); | |
| 74 | + | return ( | |
| 75 | + | <button | |
| 76 | + | type="button" | |
| 77 | + | onClick={() => { | |
| 78 | + | void navigator.clipboard?.writeText(hash).then(() => { | |
| 79 | + | setCopied(true); | |
| 80 | + | setTimeout(() => setCopied(false), 1500); | |
| 81 | + | }); | |
| 82 | + | }} | |
| 83 | + | className="flex items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1 font-mono text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 84 | + | aria-label="Copy the full hash" | |
| 85 | + | > | |
| 86 | + | {hash.slice(0, 12)} | |
| 87 | + | {copied ? <Check size={12} className="text-accent" /> : <Copy size={12} />} | |
| 88 | + | </button> | |
| 89 | + | ); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /** The pull request a commit arrived in, once it has been worked out. */ | |
| 93 | + | function MergedIn({ base, pull }: { base: string; pull: Pull }) { | |
| 94 | + | const byAgent = pull.runtime === "hosted"; | |
| 95 | + | return ( | |
| 96 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-line px-5 py-3 text-sm"> | |
| 97 | + | <span className="flex items-center gap-2"> | |
| 98 | + | <GitPullRequest size={15} className="text-merged" /> | |
| 99 | + | <span className="text-muted">Merged in</span> | |
| 100 | + | <Link to={`${base}/pull/${pull.number}`} className="font-medium hover:underline"> | |
| 101 | + | {pull.title} <span className="font-normal text-faint">#{pull.number}</span> | |
| 102 | + | </Link> | |
| 103 | + | </span> | |
| 104 | + | {byAgent && ( | |
| 105 | + | <span className="flex items-center gap-1.5 rounded-full border border-merged/30 bg-merged/10 px-2 py-0.5 text-xs text-merged"> | |
| 106 | + | <Sparkles size={12} /> | |
| 107 | + | written by {pull.agent} | |
| 108 | + | </span> | |
| 109 | + | )} | |
| 110 | + | <Link | |
| 111 | + | to={`${base}/pull/${pull.number}?tab=session`} | |
| 112 | + | className="ml-auto flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 113 | + | > | |
| 114 | + | <MessagesSquare size={13} /> | |
| 115 | + | Why it was written | |
| 116 | + | </Link> | |
| 117 | + | </div> | |
| 118 | + | ); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | export default function CommitPage({ loaderData, params }: Route.ComponentProps) { | |
| 122 | + | const { commit, pull, comparison } = loaderData; | |
| 123 | + | const base = `/${params.owner}/${params.repo}`; | |
| 124 | + | const [subject, ...rest] = commit.message.split("\n"); | |
| 125 | + | const body = rest.join("\n").trim(); | |
| 126 | + | return ( | |
| 127 | + | <div> | |
| 128 | + | <section className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 129 | + | <div className="p-5"> | |
| 130 | + | <p className="flex items-center gap-2 text-xs text-faint"> | |
| 131 | + | <GitCommitHorizontal size={14} /> | |
| 132 | + | <Link to={`${base}/commits`} className="hover:text-fg"> | |
| 133 | + | Commit | |
| 134 | + | </Link> | |
| 135 | + | </p> | |
| 136 | + | <h2 className="mt-2 text-xl font-semibold tracking-tight text-balance">{subject}</h2> | |
| 137 | + | {body && <p className="mt-3 max-w-3xl text-sm whitespace-pre-wrap text-muted">{body}</p>} | |
| 138 | + | </div> | |
| 139 | + | <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm"> | |
| 140 | + | <span className="flex items-center gap-2"> | |
| 141 | + | <Avatar name={commit.author.name} size={20} /> | |
| 142 | + | <span className="font-medium">{commit.author.name}</span> | |
| 143 | + | <span className="text-muted"> | |
| 144 | + | committed <TimeAgo at={commit.authoredAt} /> | |
| 145 | + | </span> | |
| 146 | + | </span> | |
| 147 | + | <span className="flex items-center gap-2 text-xs text-muted"> | |
| 148 | + | {commit.parents.length === 0 ? ( | |
| 149 | + | "The first commit" | |
| 150 | + | ) : ( | |
| 151 | + | <> | |
| 152 | + | {commit.parents.length === 1 ? "Parent" : "Parents"} | |
| 153 | + | {commit.parents.map((parent) => ( | |
| 154 | + | <Link | |
| 155 | + | key={parent} | |
| 156 | + | to={`${base}/commit/${parent}`} | |
| 157 | + | prefetch="intent" | |
| 158 | + | className="font-mono text-fg/80 hover:text-accent hover:underline" | |
| 159 | + | > | |
| 160 | + | {parent.slice(0, 7)} | |
| 161 | + | </Link> | |
| 162 | + | ))} | |
| 163 | + | </> | |
| 164 | + | )} | |
| 165 | + | </span> | |
| 166 | + | <span className="ml-auto flex items-center gap-2"> | |
| 167 | + | <Link | |
| 168 | + | to={`${base}/tree/${commit.hash}/`} | |
| 169 | + | className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg" | |
| 170 | + | > | |
| 171 | + | Browse files | |
| 172 | + | </Link> | |
| 173 | + | <CopyHash hash={commit.hash} /> | |
| 174 | + | </span> | |
| 175 | + | </div> | |
| 176 | + | <Suspense fallback={<div className="h-12 animate-pulse border-t border-line bg-raised/30" />}> | |
| 177 | + | <Await resolve={pull}>{(pull) => (pull ? <MergedIn base={base} pull={pull} /> : null)}</Await> | |
| 178 | + | </Suspense> | |
| 179 | + | </section> | |
| 180 | + | <div className="mt-6"> | |
| 181 | + | <DiffView comparison={comparison} empty="This commit changes no files." fileBase={`${base}/blob/${commit.hash}`} /> | |
| 182 | + | </div> | |
| 183 | + | </div> | |
| 184 | + | ); | |
| 185 | + | } |
| 1 | + | import { GitCommitHorizontal } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Commit } from "@g1t/contracts"; | |
| 5 | + | ||
| 1 | 6 | import type { Route } from "./+types/commits"; | |
| 2 | 7 | import { Avatar, EmptyState, TimeAgo } from "../../components/ui"; | |
| 3 | 8 | import { repos } from "../../lib/services.server"; | |
| 12 | 17 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 13 | 18 | const path = { namespace: params.owner, name: params.repo }; | |
| 14 | 19 | return { | |
| 15 | − | commits: unwrap( | |
| 16 | − | await repos.log(path, getViewer(context), null, PAGE_SIZE), | |
| 17 | − | ), | |
| 20 | + | commits: unwrap(await repos.log(path, getViewer(context), null, PAGE_SIZE)), | |
| 18 | 21 | }; | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 21 | − | export default function Commits({ loaderData }: Route.ComponentProps) { | |
| 24 | + | /** Commits by the day they were made, newest first. */ | |
| 25 | + | function byDay(commits: Commit[]): [string, Commit[]][] { | |
| 26 | + | const days = new Map<string, Commit[]>(); | |
| 27 | + | for (const commit of commits) { | |
| 28 | + | const day = commit.authoredAt.slice(0, 10); | |
| 29 | + | days.set(day, [...(days.get(day) ?? []), commit]); | |
| 30 | + | } | |
| 31 | + | return [...days]; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | function dayLabel(day: string): string { | |
| 35 | + | const date = new Date(`${day}T12:00:00Z`); | |
| 36 | + | return date.toLocaleDateString("en-US", { | |
| 37 | + | weekday: "long", | |
| 38 | + | month: "long", | |
| 39 | + | day: "numeric", | |
| 40 | + | year: "numeric", | |
| 41 | + | timeZone: "UTC", | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | export default function Commits({ loaderData, params }: Route.ComponentProps) { | |
| 22 | 46 | const { commits } = loaderData; | |
| 47 | + | const base = `/${params.owner}/${params.repo}`; | |
| 23 | 48 | if (commits.length === 0) { | |
| 24 | 49 | return <EmptyState title="No commits yet" />; | |
| 25 | 50 | } | |
| 26 | 51 | return ( | |
| 27 | − | <ol className="divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 28 | − | {commits.map((commit) => { | |
| 29 | − | const [subject, ...body] = commit.message.split("\n"); | |
| 30 | − | return ( | |
| 31 | − | <li key={commit.hash} className="flex items-start gap-3 px-4 py-3"> | |
| 32 | − | <Avatar name={commit.author.name} size={24} /> | |
| 33 | − | <div className="min-w-0 grow"> | |
| 34 | − | <p className="truncate font-medium">{subject}</p> | |
| 35 | − | {body.join("\n").trim() && ( | |
| 36 | − | <p className="mt-1 line-clamp-2 text-sm whitespace-pre-line text-muted"> | |
| 37 | − | {body.join("\n").trim()} | |
| 38 | − | </p> | |
| 39 | − | )} | |
| 40 | − | <p className="mt-1 text-xs text-faint"> | |
| 41 | − | {commit.author.name} committed <TimeAgo at={commit.authoredAt} /> | |
| 42 | − | </p> | |
| 43 | − | </div> | |
| 44 | − | <span className="shrink-0 rounded-md border border-line px-2 py-0.5 font-mono text-xs text-muted"> | |
| 45 | − | {commit.hash.slice(0, 7)} | |
| 46 | − | </span> | |
| 47 | − | </li> | |
| 48 | − | ); | |
| 49 | − | })} | |
| 50 | − | </ol> | |
| 52 | + | <div className="relative space-y-8 before:absolute before:top-3 before:bottom-3 before:left-1.75 before:w-px before:bg-line"> | |
| 53 | + | {byDay(commits).map(([day, list]) => ( | |
| 54 | + | <section key={day} className="relative pl-7"> | |
| 55 | + | <h2 className="flex items-center gap-2 text-xs font-medium text-muted"> | |
| 56 | + | <GitCommitHorizontal size={15} className="absolute left-0 bg-bg text-faint" /> | |
| 57 | + | {dayLabel(day)} | |
| 58 | + | </h2> | |
| 59 | + | <ol className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 60 | + | {list.map((commit) => { | |
| 61 | + | const [subject, ...body] = commit.message.split("\n"); | |
| 62 | + | const rest = body.join("\n").trim(); | |
| 63 | + | const to = `${base}/commit/${commit.hash}`; | |
| 64 | + | return ( | |
| 65 | + | <li key={commit.hash} className="group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface"> | |
| 66 | + | <Avatar name={commit.author.name} size={24} /> | |
| 67 | + | <div className="min-w-0 grow"> | |
| 68 | + | <Link to={to} prefetch="intent" className="block truncate font-medium after:absolute after:inset-0 group-hover:text-accent"> | |
| 69 | + | {subject} | |
| 70 | + | </Link> | |
| 71 | + | {rest && <p className="mt-1 line-clamp-1 text-sm text-muted">{rest}</p>} | |
| 72 | + | <p className="mt-1 text-xs text-faint"> | |
| 73 | + | {commit.author.name} committed <TimeAgo at={commit.authoredAt} /> | |
| 74 | + | {commit.parents.length > 1 && " · merge"} | |
| 75 | + | </p> | |
| 76 | + | </div> | |
| 77 | + | <span className="shrink-0 rounded-md border border-line px-2 py-0.5 font-mono text-xs text-muted group-hover:border-line-strong group-hover:text-fg"> | |
| 78 | + | {commit.hash.slice(0, 7)} | |
| 79 | + | </span> | |
| 80 | + | </li> | |
| 81 | + | ); | |
| 82 | + | })} | |
| 83 | + | </ol> | |
| 84 | + | </section> | |
| 85 | + | ))} | |
| 86 | + | </div> | |
| 51 | 87 | ); | |
| 52 | 88 | } |
| 18 | 18 | TimeAgo, | |
| 19 | 19 | } from "../../components/ui"; | |
| 20 | 20 | import { CheckBadge } from "../../components/checks"; | |
| 21 | − | import { Comments, IssueState, Label, PullIcon } from "../../components/work"; | |
| 22 | − | import { work } from "../../lib/services.server"; | |
| 23 | − | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 21 | + | import { | |
| 22 | + | Assignee, | |
| 23 | + | AssigneeStack, | |
| 24 | + | ChangeSize, | |
| 25 | + | CommentForm, | |
| 26 | + | CommentList, | |
| 27 | + | PeoplePicker, | |
| 28 | + | IssueState, | |
| 29 | + | Label, | |
| 30 | + | PullIcon, | |
| 31 | + | plainText, | |
| 32 | + | } from "../../components/work"; | |
| 33 | + | import { identity, work } from "../../lib/services.server"; | |
| 34 | + | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 24 | 35 | ||
| 25 | 36 | const REFRESH_MS = 4000; | |
| 26 | 37 | ||
| 33 | 44 | const viewer = getViewer(context); | |
| 34 | 45 | const path = { namespace: params.owner, name: params.repo }; | |
| 35 | 46 | const number = Number(params.number); | |
| 36 | − | const found = await work.getIssue(path, number, viewer); | |
| 47 | + | // At once: none of these depends on another. | |
| 48 | + | const [found, labels, agentsEnabled, members] = await Promise.all([ | |
| 49 | + | work.getIssue(path, number, viewer), | |
| 50 | + | work.listLabels(path, viewer), | |
| 51 | + | env.RUNNER.enabled(viewer), | |
| 52 | + | // A member picks assignees from the workspace's people. | |
| 53 | + | roleIn(viewer, params.owner) ? identity.listMembers(params.owner, viewer) : null, | |
| 54 | + | ]); | |
| 37 | 55 | if (!found.ok) { | |
| 38 | 56 | // Issues and pull requests share numbers; this one may be a pull request. | |
| 39 | 57 | const pull = await work.getPull(path, number, viewer); | |
| 40 | 58 | if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`); | |
| 41 | 59 | throw new Response("Issue not found.", { status: 404 }); | |
| 42 | 60 | } | |
| 43 | − | const [labels, agentModels] = await Promise.all([ | |
| 44 | − | work.listLabels(path, viewer), | |
| 45 | − | env.RUNNER.models(viewer), | |
| 46 | − | ]); | |
| 47 | 61 | const { issue } = found.value; | |
| 48 | 62 | return { | |
| 49 | 63 | ...found.value, | |
| 50 | 64 | viewer, | |
| 51 | 65 | labels: labels.ok ? labels.value : [], | |
| 52 | − | agentModels, | |
| 66 | + | agentsEnabled, | |
| 67 | + | members: members?.ok ? members.value.map((member) => member.username) : [], | |
| 53 | 68 | // The author and members of the workspace can change an issue. | |
| 54 | 69 | canManage: | |
| 55 | 70 | viewer != null && | |
| 68 | 83 | switch (form.get("action")) { | |
| 69 | 84 | case "run-hosted": { | |
| 70 | 85 | const result = await env.RUNNER.run(user, path, number, { | |
| 71 | − | count: Number(form.get("count")), | |
| 72 | 86 | instructions: String(form.get("instructions") ?? ""), | |
| 73 | − | model: String(form.get("model") ?? "") || undefined, | |
| 74 | 87 | }); | |
| 75 | 88 | return result.ok ? null : { error: result.error.message }; | |
| 76 | 89 | } | |
| 89 | 102 | }); | |
| 90 | 103 | return result.ok ? null : { error: result.error.message }; | |
| 91 | 104 | } | |
| 105 | + | case "assign": { | |
| 106 | + | const result = await work.updateIssue(user, path, number, { | |
| 107 | + | assignees: [ | |
| 108 | + | ...form.getAll("assignee").map(String), | |
| 109 | + | ...String(form.get("others") ?? "").split(/[\s,]+/), | |
| 110 | + | ], | |
| 111 | + | }); | |
| 112 | + | return result.ok ? null : { error: result.error.message }; | |
| 113 | + | } | |
| 92 | 114 | case "labels": { | |
| 93 | 115 | const result = await work.updateIssue(user, path, number, { | |
| 94 | 116 | labels: [ | |
| 144 | 166 | <span className="size-1.5 animate-pulse rounded-full bg-accent" /> | |
| 145 | 167 | )} | |
| 146 | 168 | <span className="ml-auto flex items-center gap-3 text-xs text-faint"> | |
| 169 | + | <ChangeSize files={pull.files} /> | |
| 147 | 170 | <CheckBadge status={pull.checkStatus} /> | |
| 148 | 171 | <span className="flex items-center gap-1 font-mono"> | |
| 149 | 172 | <GitCommitHorizontal size={13} /> | |
| 162 | 185 | {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>} | |
| 163 | 186 | <span className="text-faint">· opened by {pull.author.username}</span> | |
| 164 | 187 | </p> | |
| 165 | − | {pull.body && <p className="mt-2 line-clamp-2 text-sm text-muted">{pull.body}</p>} | |
| 188 | + | {pull.body && ( | |
| 189 | + | <p className="mt-2 line-clamp-2 text-sm text-muted">{plainText(pull.body)}</p> | |
| 190 | + | )} | |
| 166 | 191 | </Link> | |
| 167 | 192 | </li> | |
| 168 | 193 | ); | |
| 169 | 194 | } | |
| 170 | 195 | ||
| 171 | 196 | export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 172 | − | const { issue, pulls, comments, viewer, labels, agentModels, canManage } = loaderData; | |
| 197 | + | const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage } = loaderData; | |
| 173 | 198 | ||
| 174 | 199 | // Follow agents at work without a manual reload. | |
| 175 | 200 | const revalidator = useRevalidator(); | |
| 180 | 205 | pull.checkStatus === "queued" || | |
| 181 | 206 | pull.checkStatus === "running", | |
| 182 | 207 | ); | |
| 208 | + | // The pull request g1t's agent has in progress for this issue, if any. | |
| 209 | + | const assigned = [...pulls] | |
| 210 | + | .reverse() | |
| 211 | + | .find( | |
| 212 | + | (pull) => | |
| 213 | + | pull.agent === "g1t-agent" && | |
| 214 | + | pull.runtime === "hosted" && | |
| 215 | + | (pull.status === "draft" || pull.status === "open"), | |
| 216 | + | ); | |
| 183 | 217 | useEffect(() => { | |
| 184 | − | if (!running) return; | |
| 218 | + | if (!running && !assigned) return; | |
| 185 | 219 | const timer = setInterval(() => { | |
| 186 | 220 | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 187 | 221 | }, REFRESH_MS); | |
| 188 | 222 | return () => clearInterval(timer); | |
| 189 | − | }, [running, revalidator]); | |
| 223 | + | }, [running, assigned, revalidator]); | |
| 190 | 224 | ||
| 191 | 225 | const starting = navigation.formData?.get("action") === "run-hosted"; | |
| 192 | 226 | const base = `/${params.owner}/${params.repo}`; | |
| 217 | 251 | {issue.labels.map((name) => ( | |
| 218 | 252 | <Label key={name} name={name} /> | |
| 219 | 253 | ))} | |
| 254 | + | {open && issue.agent && <Assignee agent={issue.agent} />} | |
| 220 | 255 | </div> | |
| 221 | 256 | ||
| 222 | 257 | {issue.resolvedBy != null && ( | |
| 242 | 277 | ||
| 243 | 278 | {issue.body && ( | |
| 244 | 279 | <div className="mt-5 rounded-xl border border-line bg-surface p-5"> | |
| 245 | − | <Markdown source={issue.body} /> | |
| 280 | + | <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 246 | 281 | </div> | |
| 247 | 282 | )} | |
| 248 | 283 | ||
| 273 | 308 | ||
| 274 | 309 | <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3> | |
| 275 | 310 | <div className="mt-3"> | |
| 276 | − | <Comments comments={comments} canComment={Boolean(viewer)} /> | |
| 311 | + | <div className="space-y-4"> | |
| 312 | + | <CommentList comments={comments} base={base} /> | |
| 313 | + | <CommentForm author={viewer?.username ?? null} resetKey={comments.length} /> | |
| 314 | + | {canManage && ( | |
| 315 | + | <Form method="post" className="flex flex-wrap justify-end gap-2"> | |
| 316 | + | {open ? ( | |
| 317 | + | <> | |
| 318 | + | <Button variant="quiet" type="submit" name="action" value="close-not-planned"> | |
| 319 | + | Close as not planned | |
| 320 | + | </Button> | |
| 321 | + | <Button variant="quiet" type="submit" name="action" value="close-completed"> | |
| 322 | + | Close issue | |
| 323 | + | </Button> | |
| 324 | + | </> | |
| 325 | + | ) : ( | |
| 326 | + | <Button variant="quiet" type="submit" name="action" value="reopen"> | |
| 327 | + | Reopen issue | |
| 328 | + | </Button> | |
| 329 | + | )} | |
| 330 | + | </Form> | |
| 331 | + | )} | |
| 332 | + | </div> | |
| 277 | 333 | </div> | |
| 278 | 334 | <div className="mt-2"> | |
| 279 | 335 | <ErrorText>{actionData?.error}</ErrorText> | |
| 281 | 337 | </div> | |
| 282 | 338 | ||
| 283 | 339 | <aside className="space-y-6"> | |
| 284 | − | {open && agentModels.length > 0 && ( | |
| 285 | − | <section className="rounded-xl border border-accent/30 bg-accent/5 p-4"> | |
| 286 | − | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 287 | − | <Sparkles size={15} className="text-accent" /> | |
| 288 | − | Assign g1t agents | |
| 289 | − | </h3> | |
| 290 | − | <p className="mt-1 text-xs text-muted"> | |
| 291 | − | Each agent opens its own pull request for this issue and works in | |
| 292 | − | its own sandbox, in parallel. Merge the one you want. | |
| 340 | + | <section> | |
| 341 | + | <h3 className="text-sm font-medium">Assignees</h3> | |
| 342 | + | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 343 | + | {open && assigned && ( | |
| 344 | + | <li> | |
| 345 | + | <Link | |
| 346 | + | to={`${base}/pull/${assigned.number}`} | |
| 347 | + | className="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-2.5 py-2 transition-colors hover:border-accent/60" | |
| 348 | + | > | |
| 349 | + | <Sparkles size={15} className="shrink-0 text-accent" /> | |
| 350 | + | <span className="min-w-0 grow"> | |
| 351 | + | <span className="block font-mono text-xs font-medium">g1t-agent</span> | |
| 352 | + | <span className="block truncate text-xs text-muted"> | |
| 353 | + | {assigned.status === "draft" | |
| 354 | + | ? "Making the change" | |
| 355 | + | : "Seeing it through checks and review"}{" "} | |
| 356 | + | · #{assigned.number} | |
| 357 | + | </span> | |
| 358 | + | </span> | |
| 359 | + | <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-accent" /> | |
| 360 | + | </Link> | |
| 361 | + | </li> | |
| 362 | + | )} | |
| 363 | + | {issue.assignees.map((name) => ( | |
| 364 | + | <li key={name} className="flex items-center gap-2 px-1"> | |
| 365 | + | <Avatar name={name} size={20} /> | |
| 366 | + | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 367 | + | </li> | |
| 368 | + | ))} | |
| 369 | + | {open && issue.queued && !assigned && ( | |
| 370 | + | <li className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2"> | |
| 371 | + | <Sparkles size={15} className="shrink-0 text-faint" /> | |
| 372 | + | <span className="min-w-0 grow"> | |
| 373 | + | <span className="block font-mono text-xs font-medium">g1t-agent</span> | |
| 374 | + | <span className="block text-xs text-muted"> | |
| 375 | + | {issue.blockedBy.length > 0 | |
| 376 | + | ? "Queued. Starts when what this depends on has merged." | |
| 377 | + | : "Queued. Starts as soon as there is room."} | |
| 378 | + | </span> | |
| 379 | + | </span> | |
| 380 | + | </li> | |
| 381 | + | )} | |
| 382 | + | {!open && resolver && resolver.agent === "g1t-agent" && ( | |
| 383 | + | <li> | |
| 384 | + | <Link | |
| 385 | + | to={`${base}/pull/${resolver.number}`} | |
| 386 | + | className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2 transition-colors hover:border-line-strong" | |
| 387 | + | > | |
| 388 | + | <Sparkles size={15} className="shrink-0 text-merged" /> | |
| 389 | + | <span className="min-w-0 grow"> | |
| 390 | + | <span className="block font-mono text-xs font-medium">g1t-agent</span> | |
| 391 | + | <span className="block truncate text-xs text-muted"> | |
| 392 | + | Resolved it with #{resolver.number} | |
| 393 | + | </span> | |
| 394 | + | </span> | |
| 395 | + | </Link> | |
| 396 | + | </li> | |
| 397 | + | )} | |
| 398 | + | {issue.assignees.length === 0 && | |
| 399 | + | !(open && (assigned || issue.queued)) && | |
| 400 | + | !(!open && resolver?.agent === "g1t-agent") && ( | |
| 401 | + | <li className="px-1 text-xs text-faint">No one yet.</li> | |
| 402 | + | )} | |
| 403 | + | </ul> | |
| 404 | + | {issue.blockedBy.length > 0 && ( | |
| 405 | + | <p className="mt-3 text-xs text-muted"> | |
| 406 | + | Depends on{" "} | |
| 407 | + | {issue.blockedBy.map((number, index) => ( | |
| 408 | + | <span key={number}> | |
| 409 | + | {index > 0 && ", "} | |
| 410 | + | <Link | |
| 411 | + | to={`${base}/issues/${number}`} | |
| 412 | + | className="font-medium text-fg hover:underline" | |
| 413 | + | > | |
| 414 | + | #{number} | |
| 415 | + | </Link> | |
| 416 | + | </span> | |
| 417 | + | ))} | |
| 418 | + | {open ? ", which has to merge first." : "."} | |
| 293 | 419 | </p> | |
| 420 | + | )} | |
| 421 | + | ||
| 422 | + | {open && agentsEnabled && !assigned && !issue.queued && ( | |
| 294 | 423 | <Form method="post" className="mt-3 space-y-2"> | |
| 295 | 424 | <input type="hidden" name="action" value="run-hosted" /> | |
| 296 | − | <label className="flex items-center justify-between gap-3 text-sm"> | |
| 297 | − | <span className="text-muted">Agents</span> | |
| 298 | − | <select | |
| 299 | − | name="count" | |
| 300 | − | defaultValue="1" | |
| 301 | − | className="rounded-md border border-line bg-bg px-2 py-1 text-sm" | |
| 302 | − | > | |
| 303 | − | {[1, 2, 3, 4, 5].map((count) => ( | |
| 304 | − | <option key={count} value={count}> | |
| 305 | − | {count} | |
| 306 | − | </option> | |
| 307 | − | ))} | |
| 308 | − | </select> | |
| 309 | − | </label> | |
| 310 | − | <label className="flex items-center justify-between gap-3 text-sm"> | |
| 311 | − | <span className="text-muted">Model</span> | |
| 312 | − | <select | |
| 313 | − | name="model" | |
| 314 | − | className="rounded-md border border-line bg-bg px-2 py-1 text-sm" | |
| 315 | − | > | |
| 316 | − | {agentModels.map((model) => ( | |
| 317 | − | <option key={model.id} value={model.id} title={model.description}> | |
| 318 | − | {model.label} · {model.modelName} | |
| 319 | − | </option> | |
| 320 | − | ))} | |
| 321 | − | </select> | |
| 322 | − | </label> | |
| 323 | − | <Textarea name="instructions" rows={2} placeholder="Extra guidance (optional)" /> | |
| 324 | 425 | <div className="*:w-full"> | |
| 325 | 426 | <Button variant="accent" type="submit" disabled={starting}> | |
| 326 | − | <Play size={14} /> | |
| 327 | − | {starting ? "Starting sandboxes…" : "Start"} | |
| 427 | + | <Sparkles size={14} /> | |
| 428 | + | {starting ? "Starting a sandbox…" : "Assign to g1t agent"} | |
| 328 | 429 | </Button> | |
| 329 | 430 | </div> | |
| 431 | + | <details> | |
| 432 | + | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 433 | + | Add guidance for this run | |
| 434 | + | </summary> | |
| 435 | + | <div className="mt-2"> | |
| 436 | + | <Textarea | |
| 437 | + | name="instructions" | |
| 438 | + | rows={2} | |
| 439 | + | placeholder="On top of the issue's description" | |
| 440 | + | /> | |
| 441 | + | </div> | |
| 442 | + | </details> | |
| 443 | + | <p className="text-xs text-muted"> | |
| 444 | + | It opens a pull request and sees it through checks, a review by another | |
| 445 | + | agent and fixes. You get it back ready to merge. | |
| 446 | + | </p> | |
| 330 | 447 | </Form> | |
| 331 | − | </section> | |
| 332 | − | )} | |
| 448 | + | )} | |
| 449 | + | ||
| 450 | + | {viewer && canManage && open && ( | |
| 451 | + | <div className="mt-3 space-y-2"> | |
| 452 | + | {!issue.assignees.includes(viewer.username) && ( | |
| 453 | + | <Form method="post"> | |
| 454 | + | <input type="hidden" name="action" value="assign" /> | |
| 455 | + | {issue.assignees.map((name) => ( | |
| 456 | + | <input key={name} type="hidden" name="assignee" value={name} /> | |
| 457 | + | ))} | |
| 458 | + | <input type="hidden" name="assignee" value={viewer.username} /> | |
| 459 | + | <div className="*:w-full"> | |
| 460 | + | <Button variant="quiet" type="submit"> | |
| 461 | + | Assign yourself | |
| 462 | + | </Button> | |
| 463 | + | </div> | |
| 464 | + | </Form> | |
| 465 | + | )} | |
| 466 | + | <details className="group"> | |
| 467 | + | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 468 | + | Assign people | |
| 469 | + | </summary> | |
| 470 | + | <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}> | |
| 471 | + | <input type="hidden" name="action" value="assign" /> | |
| 472 | + | <PeoplePicker name="assignee" members={members} chosen={issue.assignees} /> | |
| 473 | + | <Button variant="quiet" type="submit"> | |
| 474 | + | Save assignees | |
| 475 | + | </Button> | |
| 476 | + | </Form> | |
| 477 | + | </details> | |
| 478 | + | </div> | |
| 479 | + | )} | |
| 480 | + | </section> | |
| 333 | 481 | ||
| 334 | 482 | {open && ( | |
| 335 | 483 | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 418 | 566 | </details> | |
| 419 | 567 | )} | |
| 420 | 568 | ||
| 421 | − | {canManage && ( | |
| 422 | − | <Form method="post" className="flex flex-wrap gap-2 border-t border-line pt-5"> | |
| 423 | − | {open ? ( | |
| 424 | − | <> | |
| 425 | − | <Button variant="quiet" type="submit" name="action" value="close-completed"> | |
| 426 | − | Close issue | |
| 427 | − | </Button> | |
| 428 | − | <Button variant="quiet" type="submit" name="action" value="close-not-planned"> | |
| 429 | − | Close as not planned | |
| 430 | − | </Button> | |
| 431 | − | </> | |
| 432 | − | ) : ( | |
| 433 | − | <Button variant="quiet" type="submit" name="action" value="reopen"> | |
| 434 | − | Reopen issue | |
| 435 | − | </Button> | |
| 436 | − | )} | |
| 437 | − | </Form> | |
| 438 | − | )} | |
| 439 | 569 | </aside> | |
| 440 | 570 | </div> | |
| 441 | 571 | ); |
| 1 | − | import { GitPullRequest, MessageSquare, Plus, X } from "lucide-react"; | |
| 2 | − | import { Link } from "react-router"; | |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | import { GitPullRequest, MessageSquare, Plus, Sparkles, X } from "lucide-react"; | |
| 3 | + | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | 4 | ||
| 4 | 5 | import type { Route } from "./+types/issues"; | |
| 5 | − | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 6 | − | import { IssueIcon, Label, StateTabs } from "../../components/work"; | |
| 6 | + | import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 7 | + | import { | |
| 8 | + | Assignee, | |
| 9 | + | AssigneeStack, | |
| 10 | + | IssueIcon, | |
| 11 | + | Label, | |
| 12 | + | StateTabs, | |
| 13 | + | } from "../../components/work"; | |
| 7 | 14 | import { work } from "../../lib/services.server"; | |
| 8 | − | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 15 | + | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 16 | + | ||
| 17 | + | /** As many sandboxes as one request may start. */ | |
| 18 | + | const MAX_ASSIGNED_AT_ONCE = 10; | |
| 9 | 19 | ||
| 10 | 20 | export function meta({ params }: Route.MetaArgs) { | |
| 11 | 21 | return [{ title: `Issues · ${params.owner}/${params.repo} · g1t` }]; | |
| 17 | 27 | const query = new URL(request.url).searchParams; | |
| 18 | 28 | const state = query.get("state") === "closed" ? "closed" : "open"; | |
| 19 | 29 | const label = query.get("label") ?? ""; | |
| 20 | − | const [issues, labels] = await Promise.all([ | |
| 30 | + | const [issues, labels, agentsEnabled] = await Promise.all([ | |
| 21 | 31 | work.listIssues(path, viewer, { state, label: label || undefined }), | |
| 22 | 32 | work.listLabels(path, viewer), | |
| 33 | + | env.RUNNER.enabled(viewer), | |
| 23 | 34 | ]); | |
| 24 | − | return { issues: unwrap(issues), labels: unwrap(labels), state, label } as const; | |
| 35 | + | return { | |
| 36 | + | issues: unwrap(issues), | |
| 37 | + | labels: unwrap(labels), | |
| 38 | + | state, | |
| 39 | + | label, | |
| 40 | + | agentsEnabled, | |
| 41 | + | } as const; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** Assigns each selected issue to a g1t agent of its own. */ | |
| 45 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 46 | + | assertSameOrigin(request); | |
| 47 | + | const user = requireUser(context, request); | |
| 48 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 49 | + | const numbers = (await request.formData()) | |
| 50 | + | .getAll("issue") | |
| 51 | + | .map(Number) | |
| 52 | + | .filter((number) => Number.isInteger(number) && number > 0); | |
| 53 | + | if (numbers.length === 0) return { error: "Select the issues to assign first." }; | |
| 54 | + | if (numbers.length > MAX_ASSIGNED_AT_ONCE) { | |
| 55 | + | return { error: `Assign at most ${MAX_ASSIGNED_AT_ONCE} issues at a time.` }; | |
| 56 | + | } | |
| 57 | + | const results = await Promise.all( | |
| 58 | + | numbers.map((number) => env.RUNNER.run(user, path, number)), | |
| 59 | + | ); | |
| 60 | + | const refused = results.find((result) => !result.ok); | |
| 61 | + | const started = results.filter((result) => result.ok).length; | |
| 62 | + | return { | |
| 63 | + | started, | |
| 64 | + | error: | |
| 65 | + | refused && !refused.ok | |
| 66 | + | ? `${started} of ${numbers.length} assigned. ${refused.error.message}` | |
| 67 | + | : null, | |
| 68 | + | }; | |
| 25 | 69 | } | |
| 26 | 70 | ||
| 27 | − | export default function Issues({ loaderData, params }: Route.ComponentProps) { | |
| 28 | − | const { issues, labels, state, label } = loaderData; | |
| 71 | + | export default function Issues({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 72 | + | const { issues, labels, state, label, agentsEnabled } = loaderData; | |
| 29 | 73 | const repo = `/${params.owner}/${params.repo}`; | |
| 30 | 74 | const base = `${repo}/issues`; | |
| 31 | 75 | const stateQuery = state === "closed" ? "state=closed" : ""; | |
| 76 | + | const assignable = agentsEnabled && state === "open" && issues.length > 0; | |
| 77 | + | const assigning = useNavigation().state === "submitting"; | |
| 32 | 78 | return ( | |
| 33 | 79 | <div> | |
| 34 | 80 | <StateTabs | |
| 62 | 108 | ); | |
| 63 | 109 | })} | |
| 64 | 110 | </div> | |
| 65 | − | <div className="mt-4"> | |
| 111 | + | <Form method="post" className="mt-4"> | |
| 112 | + | {assignable && ( | |
| 113 | + | <div className="mb-3 flex flex-wrap items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-2.5"> | |
| 114 | + | <Sparkles size={15} className="shrink-0 text-accent" /> | |
| 115 | + | <p className="min-w-0 grow text-sm text-muted"> | |
| 116 | + | Tick the issues to hand over. Each gets a g1t agent of its own, | |
| 117 | + | and they all work at once. | |
| 118 | + | </p> | |
| 119 | + | <Button variant="accent" type="submit" disabled={assigning}> | |
| 120 | + | {assigning ? "Starting sandboxes…" : "Assign to g1t agent"} | |
| 121 | + | </Button> | |
| 122 | + | </div> | |
| 123 | + | )} | |
| 124 | + | {actionData?.error ? ( | |
| 125 | + | <div className="mb-3"> | |
| 126 | + | <ErrorText>{actionData.error}</ErrorText> | |
| 127 | + | </div> | |
| 128 | + | ) : ( | |
| 129 | + | actionData?.started != null && ( | |
| 130 | + | <p className="mb-3 text-sm text-muted"> | |
| 131 | + | {actionData.started}{" "} | |
| 132 | + | {actionData.started === 1 ? "agent is" : "agents are"} starting. Each | |
| 133 | + | issue shows its pull request as it appears. | |
| 134 | + | </p> | |
| 135 | + | ) | |
| 136 | + | )} | |
| 66 | 137 | {issues.length === 0 ? ( | |
| 67 | 138 | <EmptyState | |
| 68 | 139 | title={ | |
| 79 | 150 | ) : ( | |
| 80 | 151 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 81 | 152 | {issues.map((issue) => ( | |
| 82 | − | <li key={issue.id}> | |
| 153 | + | <li key={issue.id} className="flex items-start transition-colors hover:bg-surface"> | |
| 154 | + | {assignable && ( | |
| 155 | + | <input | |
| 156 | + | type="checkbox" | |
| 157 | + | name="issue" | |
| 158 | + | value={issue.number} | |
| 159 | + | // Already being worked on: nothing more to hand over. | |
| 160 | + | disabled={issue.agent != null || issue.queued} | |
| 161 | + | aria-label={`Select issue #${issue.number}`} | |
| 162 | + | className="mt-4 ml-4 accent-accent disabled:opacity-30" | |
| 163 | + | /> | |
| 164 | + | )} | |
| 83 | 165 | <Link | |
| 166 | + | prefetch="intent" | |
| 84 | 167 | to={`${base}/${issue.number}`} | |
| 85 | − | className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface" | |
| 168 | + | className="flex min-w-0 grow items-start gap-3 px-4 py-3" | |
| 86 | 169 | > | |
| 87 | 170 | <span className="mt-0.5"> | |
| 88 | 171 | <IssueIcon issue={issue} /> | |
| 93 | 176 | {issue.labels.map((name) => ( | |
| 94 | 177 | <Label key={name} name={name} /> | |
| 95 | 178 | ))} | |
| 179 | + | {issue.state === "open" && issue.agent && ( | |
| 180 | + | <Assignee agent={issue.agent} /> | |
| 181 | + | )} | |
| 182 | + | {issue.state === "open" && issue.queued && !issue.agent && ( | |
| 183 | + | <span className="rounded-full border border-line px-2 py-px text-xs text-muted"> | |
| 184 | + | queued for g1t-agent | |
| 185 | + | {issue.blockedBy.length > 0 && | |
| 186 | + | `, after ${issue.blockedBy.map((number) => `#${number}`).join(", ")}`} | |
| 187 | + | </span> | |
| 188 | + | )} | |
| 96 | 189 | </span> | |
| 97 | 190 | <span className="mt-0.5 block text-xs text-faint"> | |
| 98 | 191 | #{issue.number} opened <TimeAgo at={issue.createdAt} /> by{" "} | |
| 103 | 196 | </span> | |
| 104 | 197 | </span> | |
| 105 | 198 | <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted"> | |
| 199 | + | <AssigneeStack people={issue.assignees} /> | |
| 106 | 200 | {issue.pullCount > 0 && ( | |
| 107 | 201 | <span | |
| 108 | 202 | className="flex items-center gap-1" | |
| 124 | 218 | ))} | |
| 125 | 219 | </ul> | |
| 126 | 220 | )} | |
| 127 | − | </div> | |
| 221 | + | </Form> | |
| 128 | 222 | </div> | |
| 129 | 223 | ); | |
| 130 | 224 | } |
| 1 | − | import { BookMarked, CircleDot, Code2, GitPullRequest, History, Lock } from "lucide-react"; | |
| 2 | − | import type { ReactNode } from "react"; | |
| 3 | − | import { Link, NavLink, Outlet, useLocation } from "react-router"; | |
| 1 | + | import { | |
| 2 | + | BookMarked, | |
| 3 | + | CircleDot, | |
| 4 | + | Code2, | |
| 5 | + | GitPullRequest, | |
| 6 | + | History, | |
| 7 | + | ListTree, | |
| 8 | + | Lock, | |
| 9 | + | Settings, | |
| 10 | + | } from "lucide-react"; | |
| 11 | + | import { Link, Outlet, useRouteLoaderData } from "react-router"; | |
| 4 | 12 | ||
| 5 | 13 | import type { Route } from "./+types/layout"; | |
| 6 | − | import { Pill } from "../../components/ui"; | |
| 14 | + | import { Pill, TabLink as Tab } from "../../components/ui"; | |
| 7 | 15 | import { repos, work } from "../../lib/services.server"; | |
| 8 | − | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 16 | + | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 9 | 17 | ||
| 10 | 18 | export function meta({ params }: Route.MetaArgs) { | |
| 11 | 19 | return [{ title: `${params.owner}/${params.repo} · g1t` }]; | |
| 21 | 29 | return { | |
| 22 | 30 | repo: unwrap(repo), | |
| 23 | 31 | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 32 | + | member: roleIn(viewer, params.owner) != null, | |
| 24 | 33 | }; | |
| 25 | 34 | } | |
| 26 | 35 | ||
| 27 | − | function Tab({ | |
| 28 | − | to, | |
| 29 | − | also, | |
| 30 | − | end, | |
| 31 | − | icon, | |
| 32 | − | count, | |
| 33 | − | children, | |
| 34 | − | }: { | |
| 35 | − | to: string; | |
| 36 | − | /** Another path prefix under which this tab is the current one. */ | |
| 37 | − | also?: string; | |
| 38 | − | end?: boolean; | |
| 39 | − | icon: ReactNode; | |
| 40 | − | count?: number; | |
| 41 | − | children: ReactNode; | |
| 42 | − | }) { | |
| 43 | − | const { pathname } = useLocation(); | |
| 44 | − | return ( | |
| 45 | − | <NavLink | |
| 46 | − | to={to} | |
| 47 | − | end={end} | |
| 48 | − | className={({ isActive }) => | |
| 49 | − | `-mb-px flex items-center gap-2 border-b-2 px-1 pb-3 text-sm transition-colors ${ | |
| 50 | − | isActive || (also && pathname.startsWith(also + "/")) | |
| 51 | − | ? "border-accent font-medium text-fg" | |
| 52 | − | : "border-transparent text-muted hover:text-fg" | |
| 53 | − | }` | |
| 54 | − | } | |
| 55 | − | > | |
| 56 | − | {icon} | |
| 57 | − | {children} | |
| 58 | − | {count != null && count > 0 && ( | |
| 59 | − | <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted"> | |
| 60 | − | {count} | |
| 61 | − | </span> | |
| 62 | − | )} | |
| 63 | − | </NavLink> | |
| 64 | − | ); | |
| 65 | − | } | |
| 66 | − | ||
| 67 | 36 | export default function RepoLayout({ loaderData }: Route.ComponentProps) { | |
| 68 | − | const { repo, open } = loaderData; | |
| 37 | + | const { repo, open, member } = loaderData; | |
| 69 | 38 | const base = `/${repo.namespace}/${repo.name}`; | |
| 39 | + | const signedIn = useRouteLoaderData("root")?.user != null; | |
| 40 | + | if (signedIn) { | |
| 41 | + | return ( | |
| 42 | + | <> | |
| 43 | + | <div className="border-b border-line"> | |
| 44 | + | <div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 sm:px-6"> | |
| 45 | + | {repo.isPrivate ? ( | |
| 46 | + | <Lock size={15} className="text-faint" /> | |
| 47 | + | ) : ( | |
| 48 | + | <BookMarked size={15} className="text-faint" /> | |
| 49 | + | )} | |
| 50 | + | <h1 className="font-mono text-[0.9375rem]"> | |
| 51 | + | <Link to={`/${repo.namespace}`} className="text-muted hover:text-fg"> | |
| 52 | + | {repo.namespace} | |
| 53 | + | </Link> | |
| 54 | + | <span className="mx-1 text-faint">/</span> | |
| 55 | + | <Link to={base} className="font-semibold hover:underline"> | |
| 56 | + | {repo.name} | |
| 57 | + | </Link> | |
| 58 | + | </h1> | |
| 59 | + | <Pill>{repo.isPrivate ? "private" : "public"}</Pill> | |
| 60 | + | {repo.description && ( | |
| 61 | + | <p className="min-w-0 truncate text-sm text-muted">{repo.description}</p> | |
| 62 | + | )} | |
| 63 | + | </div> | |
| 64 | + | </div> | |
| 65 | + | <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6"> | |
| 66 | + | <Outlet /> | |
| 67 | + | </div> | |
| 68 | + | </> | |
| 69 | + | ); | |
| 70 | + | } | |
| 70 | 71 | return ( | |
| 71 | 72 | <> | |
| 72 | 73 | {/* The repository's own header band, under the site header. */} | |
| 110 | 111 | <Tab to={`${base}/commits`} icon={<History size={15} />}> | |
| 111 | 112 | Commits | |
| 112 | 113 | </Tab> | |
| 114 | + | {member && ( | |
| 115 | + | <Tab to={`${base}/plans`} icon={<ListTree size={15} />}> | |
| 116 | + | Plan | |
| 117 | + | </Tab> | |
| 118 | + | )} | |
| 119 | + | {member && ( | |
| 120 | + | <Tab to={`${base}/settings`} icon={<Settings size={15} />}> | |
| 121 | + | Settings | |
| 122 | + | </Tab> | |
| 123 | + | )} | |
| 113 | 124 | </nav> | |
| 114 | 125 | </div> | |
| 115 | 126 | </div> |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | import { ArrowRight, FileCode2, Sparkles, Terminal } from "lucide-react"; | |
| 3 | + | import { useEffect } from "react"; | |
| 4 | + | import { Form, Link, data, useNavigation, useRevalidator } from "react-router"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/plan"; | |
| 7 | + | import { Markdown } from "../../components/markdown"; | |
| 8 | + | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { Label } from "../../components/work"; | |
| 10 | + | import { work } from "../../lib/services.server"; | |
| 11 | + | import { | |
| 12 | + | assertSameOrigin, | |
| 13 | + | getViewer, | |
| 14 | + | requireUser, | |
| 15 | + | roleIn, | |
| 16 | + | unwrap, | |
| 17 | + | } from "../../lib/session.server"; | |
| 18 | + | ||
| 19 | + | const REFRESH_MS = 4000; | |
| 20 | + | ||
| 21 | + | export function meta({ params }: Route.MetaArgs) { | |
| 22 | + | return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }]; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 26 | + | const viewer = getViewer(context); | |
| 27 | + | // Members only; to anyone else the page does not exist. | |
| 28 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 29 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 30 | + | return { plan: unwrap(await work.getPlan(path, viewer, params.id)) }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 34 | + | assertSameOrigin(request); | |
| 35 | + | const user = requireUser(context, request); | |
| 36 | + | const form = await request.formData(); | |
| 37 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 38 | + | const applied = await env.RUNNER.applyPlan(user, path, params.id, { | |
| 39 | + | assign: form.get("action") === "assign", | |
| 40 | + | keep: form.getAll("keep").map(Number), | |
| 41 | + | }); | |
| 42 | + | return applied.ok ? null : { error: applied.error.message }; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 46 | + | const { plan } = loaderData; | |
| 47 | + | const base = `/${params.owner}/${params.repo}`; | |
| 48 | + | const applying = useNavigation().state === "submitting"; | |
| 49 | + | ||
| 50 | + | // The agent is still writing it. | |
| 51 | + | const revalidator = useRevalidator(); | |
| 52 | + | const planning = plan.status === "planning"; | |
| 53 | + | useEffect(() => { | |
| 54 | + | if (!planning) return; | |
| 55 | + | const timer = setInterval(() => { | |
| 56 | + | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 57 | + | }, REFRESH_MS); | |
| 58 | + | return () => clearInterval(timer); | |
| 59 | + | }, [planning, revalidator]); | |
| 60 | + | ||
| 61 | + | const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length; | |
| 62 | + | return ( | |
| 63 | + | <div className="max-w-4xl"> | |
| 64 | + | <p className="text-sm"> | |
| 65 | + | <Link to={`${base}/plans`} className="text-muted hover:text-fg"> | |
| 66 | + | Plans | |
| 67 | + | </Link> | |
| 68 | + | </p> | |
| 69 | + | <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2> | |
| 70 | + | <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm whitespace-pre-wrap"> | |
| 71 | + | {plan.brief} | |
| 72 | + | </div> | |
| 73 | + | <p className="mt-2 text-xs text-faint"> | |
| 74 | + | Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} /> | |
| 75 | + | </p> | |
| 76 | + | ||
| 77 | + | {planning && ( | |
| 78 | + | <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm"> | |
| 79 | + | <span className="size-2 animate-pulse rounded-full bg-accent" /> | |
| 80 | + | <span> | |
| 81 | + | An agent is reading the repository and writing the plan. This takes a | |
| 82 | + | minute or two; the page fills in when it is done. | |
| 83 | + | </span> | |
| 84 | + | </div> | |
| 85 | + | )} | |
| 86 | + | {plan.status === "failed" && ( | |
| 87 | + | <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm"> | |
| 88 | + | <p className="font-medium">The plan could not be written</p> | |
| 89 | + | <p className="mt-1 text-muted">{plan.error}</p> | |
| 90 | + | <p className="mt-3"> | |
| 91 | + | <Link to={`${base}/plans`} className="text-fg underline underline-offset-4"> | |
| 92 | + | Try again | |
| 93 | + | </Link> | |
| 94 | + | </p> | |
| 95 | + | </div> | |
| 96 | + | )} | |
| 97 | + | ||
| 98 | + | {plan.issues.length > 0 && ( | |
| 99 | + | <Form method="post" className="mt-8"> | |
| 100 | + | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 101 | + | <h2 className="text-xl font-semibold tracking-tight"> | |
| 102 | + | {plan.status === "applied" ? "What was opened" : "The plan"} | |
| 103 | + | </h2> | |
| 104 | + | <p className="text-sm text-muted"> | |
| 105 | + | {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "} | |
| 106 | + | {independent} that can start at once | |
| 107 | + | </p> | |
| 108 | + | </div> | |
| 109 | + | {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>} | |
| 110 | + | ||
| 111 | + | <ol className="mt-5 space-y-3"> | |
| 112 | + | {plan.issues.map((issue, index) => { | |
| 113 | + | const position = index + 1; | |
| 114 | + | return ( | |
| 115 | + | <li | |
| 116 | + | key={position} | |
| 117 | + | className="flex gap-3 rounded-xl border border-line bg-surface p-4" | |
| 118 | + | > | |
| 119 | + | {plan.status === "ready" ? ( | |
| 120 | + | <input | |
| 121 | + | type="checkbox" | |
| 122 | + | name="keep" | |
| 123 | + | value={position} | |
| 124 | + | defaultChecked | |
| 125 | + | aria-label={`Open issue ${position}`} | |
| 126 | + | className="mt-1.5 accent-accent" | |
| 127 | + | /> | |
| 128 | + | ) : ( | |
| 129 | + | <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint"> | |
| 130 | + | {position} | |
| 131 | + | </span> | |
| 132 | + | )} | |
| 133 | + | <div className="min-w-0 grow"> | |
| 134 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 135 | + | {issue.number != null ? ( | |
| 136 | + | <Link | |
| 137 | + | to={`${base}/issues/${issue.number}`} | |
| 138 | + | className="font-medium hover:underline" | |
| 139 | + | > | |
| 140 | + | {issue.title}{" "} | |
| 141 | + | <span className="font-normal text-faint">#{issue.number}</span> | |
| 142 | + | </Link> | |
| 143 | + | ) : ( | |
| 144 | + | <span className="font-medium"> | |
| 145 | + | <span className="mr-1.5 font-mono text-sm font-normal text-faint"> | |
| 146 | + | {position}. | |
| 147 | + | </span> | |
| 148 | + | {issue.title} | |
| 149 | + | </span> | |
| 150 | + | )} | |
| 151 | + | {issue.labels.map((name) => ( | |
| 152 | + | <Label key={name} name={name} /> | |
| 153 | + | ))} | |
| 154 | + | </div> | |
| 155 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"> | |
| 156 | + | {issue.dependsOn.length === 0 ? ( | |
| 157 | + | <span className="text-accent">Starts at once</span> | |
| 158 | + | ) : ( | |
| 159 | + | <span className="flex items-center gap-1"> | |
| 160 | + | <ArrowRight size={12} /> | |
| 161 | + | After{" "} | |
| 162 | + | {issue.dependsOn | |
| 163 | + | .map((earlier) => { | |
| 164 | + | const number = plan.issues[earlier - 1]?.number; | |
| 165 | + | return number != null ? `#${number}` : `${earlier}`; | |
| 166 | + | }) | |
| 167 | + | .join(", ")} | |
| 168 | + | </span> | |
| 169 | + | )} | |
| 170 | + | {issue.checks.map((check) => ( | |
| 171 | + | <span key={check} className="flex items-center gap-1 font-mono"> | |
| 172 | + | <Terminal size={12} /> | |
| 173 | + | {check} | |
| 174 | + | </span> | |
| 175 | + | ))} | |
| 176 | + | </p> | |
| 177 | + | {issue.files.length > 0 && ( | |
| 178 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs text-faint"> | |
| 179 | + | <FileCode2 size={12} /> | |
| 180 | + | {issue.files.map((file) => ( | |
| 181 | + | <span key={file}>{file}</span> | |
| 182 | + | ))} | |
| 183 | + | </p> | |
| 184 | + | )} | |
| 185 | + | <details className="mt-2"> | |
| 186 | + | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 187 | + | What the agent will be told | |
| 188 | + | </summary> | |
| 189 | + | <div className="mt-2 border-t border-line pt-3"> | |
| 190 | + | <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 191 | + | </div> | |
| 192 | + | </details> | |
| 193 | + | </div> | |
| 194 | + | </li> | |
| 195 | + | ); | |
| 196 | + | })} | |
| 197 | + | </ol> | |
| 198 | + | ||
| 199 | + | {plan.status === "ready" && ( | |
| 200 | + | <div className="mt-6 flex flex-wrap items-center gap-3"> | |
| 201 | + | <Button variant="accent" type="submit" name="action" value="assign" disabled={applying}> | |
| 202 | + | <Sparkles size={15} /> | |
| 203 | + | {applying ? "Opening issues…" : "Open these and assign g1t agents"} | |
| 204 | + | </Button> | |
| 205 | + | <Button variant="quiet" type="submit" name="action" value="open" disabled={applying}> | |
| 206 | + | Only open the issues | |
| 207 | + | </Button> | |
| 208 | + | <span className="text-xs text-muted"> | |
| 209 | + | Untick any you do not want. Agents work on the independent ones at once | |
| 210 | + | and the rest follow as what they depend on merges. | |
| 211 | + | </span> | |
| 212 | + | </div> | |
| 213 | + | )} | |
| 214 | + | {plan.status === "applied" && ( | |
| 215 | + | <p className="mt-6 text-sm text-muted"> | |
| 216 | + | These are open. Follow them under{" "} | |
| 217 | + | <Link to={`${base}/issues`} className="text-fg underline underline-offset-4"> | |
| 218 | + | Issues | |
| 219 | + | </Link>{" "} | |
| 220 | + | or in{" "} | |
| 221 | + | <Link to="/" className="text-fg underline underline-offset-4"> | |
| 222 | + | mission control | |
| 223 | + | </Link> | |
| 224 | + | . | |
| 225 | + | </p> | |
| 226 | + | )} | |
| 227 | + | <div className="mt-3"> | |
| 228 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 229 | + | </div> | |
| 230 | + | </Form> | |
| 231 | + | )} | |
| 232 | + | </div> | |
| 233 | + | ); | |
| 234 | + | } |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | import { Sparkles } from "lucide-react"; | |
| 3 | + | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Plan } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import type { Route } from "./+types/plans"; | |
| 8 | + | import { Button, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { work } from "../../lib/services.server"; | |
| 10 | + | import { | |
| 11 | + | assertSameOrigin, | |
| 12 | + | getViewer, | |
| 13 | + | requireUser, | |
| 14 | + | roleIn, | |
| 15 | + | unwrap, | |
| 16 | + | } from "../../lib/session.server"; | |
| 17 | + | ||
| 18 | + | export function meta({ params }: Route.MetaArgs) { | |
| 19 | + | return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }]; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 23 | + | const viewer = getViewer(context); | |
| 24 | + | // Members only; to anyone else the page does not exist. | |
| 25 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 26 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 27 | + | return { plans: unwrap(await work.listPlans(path, viewer)) }; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 31 | + | assertSameOrigin(request); | |
| 32 | + | const user = requireUser(context, request); | |
| 33 | + | const form = await request.formData(); | |
| 34 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 35 | + | const started = await env.RUNNER.plan(user, path, String(form.get("brief") ?? "")); | |
| 36 | + | if (!started.ok) return { error: started.error.message }; | |
| 37 | + | throw redirect(`/${params.owner}/${params.repo}/plans/${started.value.planId}`); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | const STATUS: Record<Plan["status"], string> = { | |
| 41 | + | planning: "Being written", | |
| 42 | + | ready: "Ready to read", | |
| 43 | + | failed: "Could not be written", | |
| 44 | + | applied: "Applied", | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | export default function Plans({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 48 | + | const { plans } = loaderData; | |
| 49 | + | const base = `/${params.owner}/${params.repo}`; | |
| 50 | + | const starting = useNavigation().state === "submitting"; | |
| 51 | + | return ( | |
| 52 | + | <div className="grid gap-10 lg:grid-cols-[1fr_20rem]"> | |
| 53 | + | <div className="min-w-0"> | |
| 54 | + | <h2 className="text-xl font-semibold tracking-tight">Plan work</h2> | |
| 55 | + | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 56 | + | Say what you want to be true when the work is done. An agent reads this | |
| 57 | + | repository and proposes the issues that would get there, with what each | |
| 58 | + | must pass and which have to land before which. You read it, change what | |
| 59 | + | you like, and put g1t agents on it with one click. | |
| 60 | + | </p> | |
| 61 | + | <Form method="post" className="mt-5 space-y-3"> | |
| 62 | + | <Textarea | |
| 63 | + | name="brief" | |
| 64 | + | rows={7} | |
| 65 | + | required | |
| 66 | + | placeholder={ | |
| 67 | + | "The outcome, in your own words. For example:\n\nThe greeter should support a --lang flag for Spanish and French, a --shout flag that upper-cases the greeting, and a --version flag. Each should be documented in the README and covered by tests." | |
| 68 | + | } | |
| 69 | + | /> | |
| 70 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 71 | + | <Button variant="accent" type="submit" disabled={starting}> | |
| 72 | + | <Sparkles size={15} /> | |
| 73 | + | {starting ? "Starting the planner…" : "Plan it"} | |
| 74 | + | </Button> | |
| 75 | + | <span className="text-xs text-muted"> | |
| 76 | + | Nothing is opened until you have read the plan. | |
| 77 | + | </span> | |
| 78 | + | </div> | |
| 79 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 80 | + | </Form> | |
| 81 | + | ||
| 82 | + | <h3 className="mt-10 text-sm font-medium text-muted">Plans</h3> | |
| 83 | + | <div className="mt-3"> | |
| 84 | + | {plans.length === 0 ? ( | |
| 85 | + | <EmptyState title="No plans yet" /> | |
| 86 | + | ) : ( | |
| 87 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 88 | + | {plans.map((plan) => ( | |
| 89 | + | <li key={plan.id}> | |
| 90 | + | <Link | |
| 91 | + | to={`${base}/plans/${plan.id}`} | |
| 92 | + | className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-surface" | |
| 93 | + | > | |
| 94 | + | <span className="min-w-0 grow"> | |
| 95 | + | <span className="block truncate font-medium">{plan.brief}</span> | |
| 96 | + | <span className="mt-0.5 block text-xs text-faint"> | |
| 97 | + | {plan.author.username} · <TimeAgo at={plan.createdAt} /> | |
| 98 | + | {plan.issues.length > 0 && | |
| 99 | + | ` · ${plan.issues.length} ${plan.issues.length === 1 ? "issue" : "issues"}`} | |
| 100 | + | </span> | |
| 101 | + | </span> | |
| 102 | + | <span | |
| 103 | + | className={`shrink-0 text-xs ${ | |
| 104 | + | plan.status === "failed" | |
| 105 | + | ? "text-danger" | |
| 106 | + | : plan.status === "ready" | |
| 107 | + | ? "text-accent" | |
| 108 | + | : "text-muted" | |
| 109 | + | }`} | |
| 110 | + | > | |
| 111 | + | {STATUS[plan.status]} | |
| 112 | + | </span> | |
| 113 | + | </Link> | |
| 114 | + | </li> | |
| 115 | + | ))} | |
| 116 | + | </ul> | |
| 117 | + | )} | |
| 118 | + | </div> | |
| 119 | + | </div> | |
| 120 | + | ||
| 121 | + | <aside className="space-y-5 text-sm"> | |
| 122 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 123 | + | <h3 className="font-medium">What a plan gives you</h3> | |
| 124 | + | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted"> | |
| 125 | + | <li>Issues small enough to be merged one at a time, written so that an agent needs nothing else.</li> | |
| 126 | + | <li>Acceptance checks on each, taken from how this repository is tested.</li> | |
| 127 | + | <li>The files each will touch, and a dependency wherever two would collide.</li> | |
| 128 | + | <li> | |
| 129 | + | Independent issues are worked on at the same time. One that depends on | |
| 130 | + | another starts when that has merged, from its result. | |
| 131 | + | </li> | |
| 132 | + | </ul> | |
| 133 | + | </section> | |
| 134 | + | </aside> | |
| 135 | + | </div> | |
| 136 | + | ); | |
| 137 | + | } |
| 8 | 8 | CircleCheck, | |
| 9 | 9 | CircleSlash, | |
| 10 | 10 | GitMerge, | |
| 11 | + | Layers, | |
| 12 | + | GitPullRequestArrow, | |
| 13 | + | Hand, | |
| 14 | + | Loader, | |
| 15 | + | Radar, | |
| 16 | + | Sparkles, | |
| 11 | 17 | MessageSquare, | |
| 12 | 18 | MessagesSquare, | |
| 13 | 19 | StickyNote, | |
| 14 | 20 | User, | |
| 15 | 21 | Wrench, | |
| 16 | 22 | } from "lucide-react"; | |
| 17 | − | import { useEffect } from "react"; | |
| 23 | + | import { useEffect, useState } from "react"; | |
| 18 | 24 | import { Form, Link, redirect, useRevalidator } from "react-router"; | |
| 19 | 25 | ||
| 20 | 26 | import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts"; | |
| 21 | 27 | ||
| 22 | 28 | import type { Route } from "./+types/pull"; | |
| 23 | 29 | import { DiffView } from "../../components/diff-view"; | |
| 30 | + | import { LifecyclePanel } from "../../components/lifecycle"; | |
| 24 | 31 | import { Markdown } from "../../components/markdown"; | |
| 25 | 32 | import { | |
| 26 | 33 | Avatar, | |
| 27 | 34 | Button, | |
| 35 | + | ButtonLink, | |
| 28 | 36 | CopyLine, | |
| 29 | 37 | EmptyState, | |
| 30 | 38 | ErrorText, | |
| 32 | 40 | TimeAgo, | |
| 33 | 41 | } from "../../components/ui"; | |
| 34 | 42 | import { ChecksPanel } from "../../components/checks"; | |
| 35 | − | import { Comments, IssueIcon, PullState, verdicts } from "../../components/work"; | |
| 36 | − | import { repos, work } from "../../lib/services.server"; | |
| 43 | + | import { | |
| 44 | + | CommentForm, | |
| 45 | + | CommentList, | |
| 46 | + | IssueIcon, | |
| 47 | + | PeoplePicker, | |
| 48 | + | PullState, | |
| 49 | + | TimelineItem, | |
| 50 | + | verdicts, | |
| 51 | + | } from "../../components/work"; | |
| 52 | + | import { identity, repos, work } from "../../lib/services.server"; | |
| 37 | 53 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 38 | 54 | ||
| 39 | 55 | const REFRESH_MS = 4000; | |
| 55 | 71 | const asked = new URL(request.url).searchParams.get("tab"); | |
| 56 | 72 | const tab: Tab = TABS.find((name) => name === asked) ?? "conversation"; | |
| 57 | 73 | ||
| 58 | − | const [found, repo] = await Promise.all([ | |
| 74 | + | const member = (viewer?.workspaces ?? []).some( | |
| 75 | + | (membership) => membership.slug === params.owner, | |
| 76 | + | ); | |
| 77 | + | // At once: none of these depends on another. | |
| 78 | + | const [found, repo, settings, agentsEnabled, members] = await Promise.all([ | |
| 59 | 79 | work.getPull(path, number, viewer), | |
| 60 | 80 | repos.get(path, viewer), | |
| 81 | + | work.getSettings(path, viewer), | |
| 82 | + | env.RUNNER.enabled(viewer), | |
| 83 | + | // A member picks reviewers and assignees from the workspace's people. | |
| 84 | + | member ? identity.listMembers(params.owner, viewer) : null, | |
| 61 | 85 | ]); | |
| 62 | 86 | if (!found.ok) { | |
| 63 | 87 | // Issues and pull requests share numbers; this one may be an issue. | |
| 73 | 97 | ? repos.compare(range.repoId, viewer, range.base, range.head) | |
| 74 | 98 | : null, | |
| 75 | 99 | ]); | |
| 76 | − | const member = (viewer?.workspaces ?? []).some( | |
| 77 | − | (membership) => membership.slug === params.owner, | |
| 78 | − | ); | |
| 79 | 100 | return { | |
| 80 | 101 | ...found.value, | |
| 81 | 102 | tab, | |
| 86 | 107 | // Members of the repository's workspace can merge. | |
| 87 | 108 | canMerge: member, | |
| 88 | 109 | canManage: member || viewer?.id === pull.author.id, | |
| 110 | + | // A catch-up is pushed as the viewer: a fork takes pushes only from | |
| 111 | + | // whoever opened it, a branch from any member. | |
| 112 | + | canUpdate: pull.fork ? viewer?.id === pull.author.id : member, | |
| 113 | + | agentsEnabled, | |
| 114 | + | members: members?.ok ? members.value.map((person) => person.username) : [], | |
| 115 | + | requireUpToDate: settings.ok && settings.value.requireUpToDate, | |
| 116 | + | mergeQueue: settings.ok && settings.value.mergeQueue, | |
| 117 | + | requiredApprovals: settings.ok ? settings.value.requiredApprovals : 0, | |
| 118 | + | canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks, | |
| 89 | 119 | defaultBranch: repo.ok ? repo.value.defaultBranch : "main", | |
| 90 | 120 | }; | |
| 91 | 121 | } | |
| 99 | 129 | const action = form.get("action"); | |
| 100 | 130 | const verdict = form.get("verdict"); | |
| 101 | 131 | const line = Number(form.get("line")); | |
| 132 | + | /** Names ticked in a people picker, plus those typed beside it. */ | |
| 133 | + | const picked = (field: string) => [ | |
| 134 | + | ...form.getAll(field).map(String), | |
| 135 | + | ...String(form.get("others") ?? "").split(/[\s,]+/), | |
| 136 | + | ]; | |
| 137 | + | // Asking a g1t agent for its review records the request, then starts it. | |
| 138 | + | if (action === "agent-review") { | |
| 139 | + | const asked = await work.updatePull(user, path, number, { | |
| 140 | + | reviewers: [...form.getAll("reviewer").map(String), "g1t-agent"], | |
| 141 | + | }); | |
| 142 | + | if (!asked.ok) return { error: asked.error.message, action }; | |
| 143 | + | } | |
| 102 | 144 | const result = | |
| 103 | 145 | action === "merge" | |
| 104 | 146 | ? await work.mergePull(user, path, number, { | |
| 105 | 147 | keepIssueOpen: form.get("keepIssueOpen") === "on", | |
| 106 | 148 | ignoreChecks: form.get("ignoreChecks") === "on", | |
| 107 | 149 | }) | |
| 150 | + | : action === "unqueue" | |
| 151 | + | ? await work.removeFromQueue(user, path, number) | |
| 108 | 152 | : action === "close" | |
| 109 | 153 | ? await work.closePull(user, path, number) | |
| 110 | 154 | : action === "recheck" | |
| 111 | 155 | ? await env.RUNNER.recheck(user, path, number) | |
| 156 | + | : action === "update" | |
| 157 | + | ? await env.RUNNER.update(user, path, number) | |
| 158 | + | : action === "agent-review" | |
| 159 | + | ? await env.RUNNER.review(user, path, number) | |
| 160 | + | : action === "reviewers" | |
| 161 | + | ? await work.updatePull(user, path, number, { reviewers: picked("reviewer") }) | |
| 162 | + | : action === "assign" | |
| 163 | + | ? await work.updatePull(user, path, number, { assignees: picked("assignee") }) | |
| 112 | 164 | : action === "comment" | |
| 113 | 165 | ? await work.addComment(user, path, number, { | |
| 114 | 166 | body: String(form.get("body") ?? ""), | |
| 200 | 252 | <div className="mt-1"> | |
| 201 | 253 | <Markdown source={entry.text} /> | |
| 202 | 254 | </div> | |
| 255 | + | ) : entry.kind === "prompt" ? ( | |
| 256 | + | <div className="mt-1.5 max-h-[32rem] overflow-y-auto rounded-xl border border-line bg-surface p-4"> | |
| 257 | + | <Markdown source={entry.text} /> | |
| 258 | + | </div> | |
| 203 | 259 | ) : ( | |
| 204 | − | <p | |
| 205 | − | className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${ | |
| 206 | − | entry.kind === "prompt" ? "font-medium" : "" | |
| 207 | − | }`} | |
| 208 | − | > | |
| 260 | + | <p className="mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap"> | |
| 209 | 261 | {entry.text} | |
| 210 | 262 | </p> | |
| 211 | 263 | )} | |
| 221 | 273 | ); | |
| 222 | 274 | } | |
| 223 | 275 | ||
| 276 | + | /** The box at the foot of the conversation saying what stands before a merge. */ | |
| 277 | + | function StatusBox({ children }: { children: React.ReactNode }) { | |
| 278 | + | return ( | |
| 279 | + | <div className="flex gap-3"> | |
| 280 | + | <span className="hidden w-8 shrink-0 sm:block" /> | |
| 281 | + | <section className="min-w-0 grow divide-y divide-line overflow-hidden rounded-xl border border-line-strong bg-surface"> | |
| 282 | + | {children} | |
| 283 | + | </section> | |
| 284 | + | </div> | |
| 285 | + | ); | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | function StatusRow({ | |
| 289 | + | icon, | |
| 290 | + | title, | |
| 291 | + | children, | |
| 292 | + | }: { | |
| 293 | + | icon: React.ReactNode; | |
| 294 | + | title: string; | |
| 295 | + | children?: React.ReactNode; | |
| 296 | + | }) { | |
| 297 | + | return ( | |
| 298 | + | <div className="flex gap-3 px-4 py-3 text-sm"> | |
| 299 | + | <span className="mt-0.5 shrink-0">{icon}</span> | |
| 300 | + | <div className="min-w-0"> | |
| 301 | + | <p className="font-medium">{title}</p> | |
| 302 | + | {children && <div className="mt-0.5 text-muted">{children}</div>} | |
| 303 | + | </div> | |
| 304 | + | </div> | |
| 305 | + | ); | |
| 306 | + | } | |
| 307 | + | ||
| 224 | 308 | export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 225 | 309 | const { | |
| 226 | 310 | pull, | |
| 227 | 311 | issue, | |
| 228 | 312 | comments, | |
| 229 | 313 | checks, | |
| 314 | + | overlaps, | |
| 315 | + | behind, | |
| 316 | + | reviewPending, | |
| 317 | + | lifecycle, | |
| 318 | + | landing, | |
| 319 | + | stalled, | |
| 320 | + | requireUpToDate, | |
| 321 | + | mergeQueue, | |
| 322 | + | requiredApprovals, | |
| 323 | + | canIgnoreChecks, | |
| 324 | + | canUpdate, | |
| 325 | + | agentsEnabled, | |
| 326 | + | members, | |
| 230 | 327 | tab, | |
| 231 | 328 | session, | |
| 232 | 329 | comparison, | |
| 249 | 346 | const reviews = verdicts(comments); | |
| 250 | 347 | // What stands between this pull request and a merge, if anything. | |
| 251 | 348 | const unchecked = checks && checks.status !== "passed"; | |
| 349 | + | // Pull requests for other issues changing the same files will conflict; | |
| 350 | + | // ones for the same issue are alternatives, and expected to. | |
| 351 | + | const collisions = overlaps.filter((other) => other.issue == null || other.issue !== pull.issue); | |
| 352 | + | const review = { | |
| 353 | + | changesUrl: here + "?tab=changes", | |
| 354 | + | // Nobody reviews their own pull request. | |
| 355 | + | canJudge: active && viewer != null && viewer.id !== pull.author.id, | |
| 356 | + | }; | |
| 357 | + | // Everyone whose review was asked for, then anyone who reviewed unasked. | |
| 358 | + | const reviewerNames = [ | |
| 359 | + | ...new Set([...pull.reviewers, ...reviews.map(({ reviewer }) => reviewer)]), | |
| 360 | + | ]; | |
| 361 | + | const [submitted, setSubmitted] = useState<string | null>(null); | |
| 362 | + | const catchingUp = submitted === "update" && behind; | |
| 363 | + | // g1t is taking a step of its own accord, so the page will change. | |
| 364 | + | const moving = | |
| 365 | + | lifecycle != null && lifecycle.stage !== "ready" && lifecycle.stage !== "needs_you"; | |
| 252 | 366 | useEffect(() => { | |
| 253 | − | if (!working && !checking) return; | |
| 367 | + | if (!working && !checking && !reviewPending && !catchingUp && !moving && !landing) return; | |
| 254 | 368 | const timer = setInterval(() => { | |
| 255 | 369 | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 256 | 370 | }, REFRESH_MS); | |
| 257 | 371 | return () => clearInterval(timer); | |
| 258 | − | }, [working, checking, revalidator]); | |
| 372 | + | }, [working, checking, reviewPending, catchingUp, moving, landing, revalidator]); | |
| 259 | 373 | ||
| 260 | 374 | return ( | |
| 261 | − | <div className="grid gap-8 lg:grid-cols-[1fr_19rem]"> | |
| 375 | + | // The changes get the whole width; people and settings are a tab away. | |
| 376 | + | <div className={`grid gap-8 ${tab === "changes" ? "" : "lg:grid-cols-[1fr_19rem]"}`}> | |
| 262 | 377 | <div className="min-w-0"> | |
| 263 | 378 | <h2 className="text-2xl font-semibold tracking-tight text-balance"> | |
| 264 | 379 | {pull.title} <span className="font-normal text-faint">#{pull.number}</span> | |
| 266 | 381 | <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted"> | |
| 267 | 382 | <PullState status={pull.status} /> | |
| 268 | 383 | <span className="flex items-center gap-2"> | |
| 269 | − | <Avatar name={pull.author.username} size={18} /> | |
| 384 | + | <Avatar | |
| 385 | + | name={(pull.status === "merged" && pull.mergedBy) || pull.author.username} | |
| 386 | + | size={18} | |
| 387 | + | /> | |
| 270 | 388 | <span> | |
| 271 | − | <span className="font-medium text-fg">{pull.author.username}</span>{" "} | |
| 389 | + | <span className="font-medium text-fg"> | |
| 390 | + | {(pull.status === "merged" && pull.mergedBy) || pull.author.username} | |
| 391 | + | </span>{" "} | |
| 272 | 392 | {pull.status === "merged" ? "merged" : "wants to merge"} | |
| 273 | 393 | {pull.branch && ( | |
| 274 | 394 | <> | |
| 306 | 426 | </p> | |
| 307 | 427 | )} | |
| 308 | 428 | ||
| 429 | + | {lifecycle && <LifecyclePanel lifecycle={lifecycle} />} | |
| 430 | + | ||
| 309 | 431 | {issue && ( | |
| 310 | 432 | <Link | |
| 311 | 433 | to={`${base}/issues/${issue.number}`} | |
| 333 | 455 | <span> | |
| 334 | 456 | Merged into <span className="font-mono">{defaultBranch}</span> by{" "} | |
| 335 | 457 | <span className="font-medium">{pull.mergedBy}</span>{" "} | |
| 458 | + | {/* Not an account: the repository's settings said to merge it. */} | |
| 459 | + | {pull.mergedBy === "g1t" && "automatically, once it was ready, "} | |
| 336 | 460 | {pull.mergedAt && <TimeAgo at={pull.mergedAt} />} | |
| 337 | 461 | {pull.headCommit && ( | |
| 338 | 462 | <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span> | |
| 353 | 477 | </p> | |
| 354 | 478 | )} | |
| 355 | 479 | ||
| 480 | + | {collisions.length > 0 && active && ( | |
| 481 | + | <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm"> | |
| 482 | + | <p className="flex items-center gap-2.5 font-medium"> | |
| 483 | + | <Radar size={16} className="shrink-0 text-info" /> | |
| 484 | + | Other work is changing the same files | |
| 485 | + | </p> | |
| 486 | + | <ul className="mt-2 space-y-1.5"> | |
| 487 | + | {collisions.map((other) => ( | |
| 488 | + | <li key={other.number} className="flex flex-wrap items-baseline gap-x-2 text-muted"> | |
| 489 | + | <Link | |
| 490 | + | to={`${base}/pull/${other.number}`} | |
| 491 | + | className="font-medium text-fg hover:underline" | |
| 492 | + | > | |
| 493 | + | {other.title} <span className="font-normal text-faint">#{other.number}</span> | |
| 494 | + | </Link> | |
| 495 | + | <span className="font-mono text-xs">{other.paths.join(", ")}</span> | |
| 496 | + | </li> | |
| 497 | + | ))} | |
| 498 | + | </ul> | |
| 499 | + | <p className="mt-2 text-xs text-faint"> | |
| 500 | + | Whichever merges second will have to catch up, and may conflict. | |
| 501 | + | </p> | |
| 502 | + | </div> | |
| 503 | + | )} | |
| 504 | + | ||
| 356 | 505 | <nav className="mt-8 flex gap-6 border-b border-line"> | |
| 357 | 506 | <TabLink to={here} active={tab === "conversation"}> | |
| 358 | 507 | <MessageSquare size={15} /> | |
| 391 | 540 | ) | |
| 392 | 541 | ) : ( | |
| 393 | 542 | <div className="space-y-4"> | |
| 394 | − | {pull.body ? ( | |
| 395 | − | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 396 | − | <Markdown source={pull.body} /> | |
| 397 | − | </section> | |
| 398 | − | ) : ( | |
| 399 | − | <p className="rounded-xl border border-dashed border-line px-5 py-4 text-sm text-muted"> | |
| 400 | − | {working | |
| 401 | − | ? "No description yet. It is written when the pull request is marked ready for review." | |
| 402 | − | : "No description."} | |
| 403 | − | </p> | |
| 543 | + | <TimelineItem | |
| 544 | + | author={pull.author.username} | |
| 545 | + | at={pull.createdAt} | |
| 546 | + | action={ | |
| 547 | + | <span> | |
| 548 | + | opened this pull request | |
| 549 | + | {!pull.branch && ( | |
| 550 | + | <> | |
| 551 | + | {" "} | |
| 552 | + | with <span className="font-mono text-xs">{pull.agent}</span> | |
| 553 | + | </> | |
| 554 | + | )} | |
| 555 | + | </span> | |
| 556 | + | } | |
| 557 | + | > | |
| 558 | + | {pull.body ? ( | |
| 559 | + | <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 560 | + | ) : ( | |
| 561 | + | <p className="text-sm text-muted"> | |
| 562 | + | {working | |
| 563 | + | ? "No description yet. It is written when the pull request is marked ready for review." | |
| 564 | + | : "No description."} | |
| 565 | + | </p> | |
| 566 | + | )} | |
| 567 | + | {canManage && pull.status === "open" && ( | |
| 568 | + | <details className="mt-3 border-t border-line pt-3 text-sm"> | |
| 569 | + | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 570 | + | Edit description | |
| 571 | + | </summary> | |
| 572 | + | <Form method="post" className="mt-3 space-y-2"> | |
| 573 | + | <Textarea | |
| 574 | + | name="summary" | |
| 575 | + | rows={6} | |
| 576 | + | placeholder="What changed and why" | |
| 577 | + | defaultValue={pull.body ?? ""} | |
| 578 | + | /> | |
| 579 | + | <Button type="submit">Save</Button> | |
| 580 | + | </Form> | |
| 581 | + | </details> | |
| 582 | + | )} | |
| 583 | + | </TimelineItem> | |
| 584 | + | ||
| 585 | + | <CommentList comments={comments} review={review} base={base} /> | |
| 586 | + | ||
| 587 | + | {pull.status === "draft" && ( | |
| 588 | + | <StatusBox> | |
| 589 | + | <StatusRow icon={<Loader size={16} className="text-faint" />} title="This is a draft"> | |
| 590 | + | It is still being worked on. It can be reviewed and merged once it is | |
| 591 | + | marked ready. | |
| 592 | + | </StatusRow> | |
| 593 | + | {canManage && ( | |
| 594 | + | <Form method="post" className="space-y-2 px-4 py-3"> | |
| 595 | + | <Textarea name="summary" rows={3} placeholder="What changed and why" /> | |
| 596 | + | <Button type="submit">Mark ready for review</Button> | |
| 597 | + | </Form> | |
| 598 | + | )} | |
| 599 | + | </StatusBox> | |
| 600 | + | )} | |
| 601 | + | ||
| 602 | + | {pull.status === "open" && ( | |
| 603 | + | <StatusBox> | |
| 604 | + | {checks && ( | |
| 605 | + | <StatusRow | |
| 606 | + | icon={ | |
| 607 | + | checks.status === "passed" ? ( | |
| 608 | + | <CircleCheck size={16} className="text-accent" /> | |
| 609 | + | ) : checking ? ( | |
| 610 | + | <Loader size={16} className="animate-spin text-faint" /> | |
| 611 | + | ) : ( | |
| 612 | + | <CircleSlash size={16} className="text-danger" /> | |
| 613 | + | ) | |
| 614 | + | } | |
| 615 | + | title={ | |
| 616 | + | checks.status === "passed" | |
| 617 | + | ? "Acceptance checks passed" | |
| 618 | + | : checking | |
| 619 | + | ? "Acceptance checks are running" | |
| 620 | + | : checks.status === "failed" | |
| 621 | + | ? "Acceptance checks failed" | |
| 622 | + | : "Acceptance checks could not be run" | |
| 623 | + | } | |
| 624 | + | > | |
| 625 | + | {checks.results.length > 0 && | |
| 626 | + | `${checks.results.filter((result) => result.passed).length} of ${checks.results.length} passed, in a clean sandbox.`} | |
| 627 | + | </StatusRow> | |
| 628 | + | )} | |
| 629 | + | <StatusRow | |
| 630 | + | icon={ | |
| 631 | + | reviews.some(({ verdict }) => verdict === "request_changes") ? ( | |
| 632 | + | <CircleSlash size={16} className="text-danger" /> | |
| 633 | + | ) : reviews.length > 0 ? ( | |
| 634 | + | <CircleCheck size={16} className="text-accent" /> | |
| 635 | + | ) : ( | |
| 636 | + | <MessageSquare size={16} className="text-faint" /> | |
| 637 | + | ) | |
| 638 | + | } | |
| 639 | + | title={ | |
| 640 | + | reviews.length === 0 | |
| 641 | + | ? "No reviews yet" | |
| 642 | + | : reviews | |
| 643 | + | .map( | |
| 644 | + | ({ reviewer, verdict }) => | |
| 645 | + | `${verdict === "approve" ? "Approved by" : "Changes requested by"} ${reviewer}`, | |
| 646 | + | ) | |
| 647 | + | .join(" · ") | |
| 648 | + | } | |
| 649 | + | > | |
| 650 | + | {reviewPending && "A g1t agent is reviewing it now. "} | |
| 651 | + | {requiredApprovals > 0 && | |
| 652 | + | `This repository requires ${requiredApprovals} approving ${ | |
| 653 | + | requiredApprovals === 1 ? "review" : "reviews" | |
| 654 | + | } before merging.`} | |
| 655 | + | </StatusRow> | |
| 656 | + | {landing ? ( | |
| 657 | + | <StatusRow | |
| 658 | + | icon={<Loader size={16} className="animate-spin text-accent" />} | |
| 659 | + | title="Merging" | |
| 660 | + | > | |
| 661 | + | {defaultBranch} has moved, so g1t is bringing this up to date first. It | |
| 662 | + | lands as soon as that is done. Watch it in the Session tab. | |
| 663 | + | </StatusRow> | |
| 664 | + | ) : behind ? ( | |
| 665 | + | <StatusRow | |
| 666 | + | icon={<GitPullRequestArrow size={16} className="text-info" />} | |
| 667 | + | title={`${defaultBranch} has moved since this was made`} | |
| 668 | + | > | |
| 669 | + | {requireUpToDate | |
| 670 | + | ? "This repository requires pull requests to be up to date, so it has to catch up before it can merge." | |
| 671 | + | : "That does not stop it merging: it is brought up to date as part of the merge."} | |
| 672 | + | {canUpdate && agentsEnabled && ( | |
| 673 | + | <Form | |
| 674 | + | method="post" | |
| 675 | + | className="mt-2" | |
| 676 | + | onSubmit={() => setSubmitted("update")} | |
| 677 | + | > | |
| 678 | + | <input type="hidden" name="action" value="update" /> | |
| 679 | + | <Button variant="quiet" type="submit" disabled={catchingUp}> | |
| 680 | + | {catchingUp ? "Catching up…" : `Catch up with ${defaultBranch} now`} | |
| 681 | + | </Button> | |
| 682 | + | </Form> | |
| 683 | + | )} | |
| 684 | + | {actionData?.action === "update" && ( | |
| 685 | + | <ErrorText>{actionData.error}</ErrorText> | |
| 686 | + | )} | |
| 687 | + | </StatusRow> | |
| 688 | + | ) : ( | |
| 689 | + | pull.headCommit && ( | |
| 690 | + | <StatusRow | |
| 691 | + | icon={<CircleCheck size={16} className="text-accent" />} | |
| 692 | + | title={`Up to date with ${defaultBranch}`} | |
| 693 | + | /> | |
| 694 | + | ) | |
| 695 | + | )} | |
| 696 | + | {stalled && !lifecycle && ( | |
| 697 | + | <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you"> | |
| 698 | + | {stalled} | |
| 699 | + | </StatusRow> | |
| 700 | + | )} | |
| 701 | + | {canMerge && lifecycle?.stage === "queued" && ( | |
| 702 | + | <Form method="post" className="flex flex-wrap items-center gap-3 px-4 py-3"> | |
| 703 | + | <ButtonLink to={`${base}/queue`}> | |
| 704 | + | <Layers size={15} /> | |
| 705 | + | See the queue | |
| 706 | + | </ButtonLink> | |
| 707 | + | <Button variant="quiet" type="submit" name="action" value="unqueue"> | |
| 708 | + | Remove from the queue | |
| 709 | + | </Button> | |
| 710 | + | </Form> | |
| 711 | + | )} | |
| 712 | + | {canMerge && !landing && lifecycle?.stage !== "queued" && ( | |
| 713 | + | <Form method="post" className="space-y-3 px-4 py-3"> | |
| 714 | + | {issue?.state === "open" && ( | |
| 715 | + | <label className="flex items-start gap-2 text-xs text-muted"> | |
| 716 | + | <input | |
| 717 | + | type="checkbox" | |
| 718 | + | name="keepIssueOpen" | |
| 719 | + | className="mt-0.5 accent-accent" | |
| 720 | + | /> | |
| 721 | + | <span>Keep #{issue.number} open. This is only part of the work.</span> | |
| 722 | + | </label> | |
| 723 | + | )} | |
| 724 | + | {unchecked && canIgnoreChecks && ( | |
| 725 | + | <label className="flex items-start gap-2 text-xs text-muted"> | |
| 726 | + | <input | |
| 727 | + | type="checkbox" | |
| 728 | + | name="ignoreChecks" | |
| 729 | + | className="mt-0.5 accent-accent" | |
| 730 | + | /> | |
| 731 | + | <span> | |
| 732 | + | Merge although the checks{" "} | |
| 733 | + | {checking ? "have not finished" : "did not pass"}. | |
| 734 | + | </span> | |
| 735 | + | </label> | |
| 736 | + | )} | |
| 737 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 738 | + | <Button | |
| 739 | + | variant="accent" | |
| 740 | + | type="submit" | |
| 741 | + | name="action" | |
| 742 | + | value="merge" | |
| 743 | + | disabled={behind && requireUpToDate} | |
| 744 | + | > | |
| 745 | + | {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />} | |
| 746 | + | {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`} | |
| 747 | + | </Button> | |
| 748 | + | <span className="text-xs text-muted"> | |
| 749 | + | {mergeQueue | |
| 750 | + | ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.` | |
| 751 | + | : issue?.state === "open" | |
| 752 | + | ? `Closes issue #${issue.number}, and any other pull requests still open for it.` | |
| 753 | + | : `Lands its commits on ${defaultBranch}.`} | |
| 754 | + | </span> | |
| 755 | + | </div> | |
| 756 | + | {actionData?.action === "merge" && ( | |
| 757 | + | <ErrorText>{actionData.error}</ErrorText> | |
| 758 | + | )} | |
| 759 | + | </Form> | |
| 760 | + | )} | |
| 761 | + | </StatusBox> | |
| 404 | 762 | )} | |
| 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 | − | }} | |
| 763 | + | ||
| 764 | + | <CommentForm | |
| 765 | + | author={viewer?.username ?? null} | |
| 766 | + | resetKey={comments.length} | |
| 767 | + | review={review} | |
| 413 | 768 | /> | |
| 769 | + | {canManage && active && ( | |
| 770 | + | <Form method="post" className="flex justify-end"> | |
| 771 | + | <Button variant="quiet" type="submit" name="action" value="close"> | |
| 772 | + | Close pull request | |
| 773 | + | </Button> | |
| 774 | + | </Form> | |
| 775 | + | )} | |
| 776 | + | {actionData && | |
| 777 | + | !["merge", "comment", "recheck", "update", "agent-review", "reviewers", "assign"].includes( | |
| 778 | + | String(actionData.action), | |
| 779 | + | ) && <ErrorText>{actionData.error}</ErrorText>} | |
| 414 | 780 | </div> | |
| 415 | 781 | )} | |
| 416 | 782 | {actionData?.action === "comment" && ( | |
| 421 | 787 | </div> | |
| 422 | 788 | </div> | |
| 423 | 789 | ||
| 424 | − | <aside className="space-y-6"> | |
| 790 | + | <aside className={tab === "changes" ? "hidden" : "space-y-6"}> | |
| 425 | 791 | <ChecksPanel | |
| 426 | 792 | run={checks} | |
| 427 | 793 | commands={issue?.checks ?? []} | |
| 429 | 795 | /> | |
| 430 | 796 | {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>} | |
| 431 | 797 | ||
| 432 | − | {canMerge && pull.status === "open" && ( | |
| 433 | − | <section className="rounded-xl border border-accent/30 bg-accent/5 p-4"> | |
| 434 | − | <h3 className="text-sm font-medium">Merge this pull request</h3> | |
| 435 | − | <p className="mt-1 text-xs text-muted"> | |
| 436 | − | Lands its commits on {defaultBranch}. | |
| 437 | − | {issue?.state === "open" && | |
| 438 | − | ` Closes issue #${issue.number}, and any other pull requests still open for it.`} | |
| 439 | − | </p> | |
| 440 | − | <Form method="post" className="mt-3 space-y-3"> | |
| 441 | − | {issue?.state === "open" && ( | |
| 442 | − | <label className="flex items-start gap-2 text-xs text-muted"> | |
| 443 | − | <input type="checkbox" name="keepIssueOpen" className="mt-0.5 accent-accent" /> | |
| 444 | − | <span> | |
| 445 | − | Keep #{issue.number} open. This is only part of the work. | |
| 446 | − | </span> | |
| 447 | − | </label> | |
| 798 | + | <section> | |
| 799 | + | <h3 className="text-sm font-medium">Reviewers</h3> | |
| 800 | + | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 801 | + | {reviewerNames.map((name) => { | |
| 802 | + | const verdict = reviews.find(({ reviewer }) => reviewer === name)?.verdict; | |
| 803 | + | const pending = name === "g1t-agent" && reviewPending; | |
| 804 | + | return ( | |
| 805 | + | <li key={name} className="flex items-center gap-2 px-1"> | |
| 806 | + | {name === "g1t-agent" ? ( | |
| 807 | + | <Sparkles size={16} className="shrink-0 text-accent" /> | |
| 808 | + | ) : ( | |
| 809 | + | <Avatar name={name} size={20} /> | |
| 810 | + | )} | |
| 811 | + | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 812 | + | {pending ? ( | |
| 813 | + | <span className="flex items-center gap-1.5 text-xs text-muted"> | |
| 814 | + | <span className="size-1.5 animate-pulse rounded-full bg-accent" /> | |
| 815 | + | Reviewing | |
| 816 | + | </span> | |
| 817 | + | ) : verdict === "approve" ? ( | |
| 818 | + | <span className="flex items-center gap-1 text-xs text-accent"> | |
| 819 | + | <CircleCheck size={13} /> Approved | |
| 820 | + | </span> | |
| 821 | + | ) : verdict === "request_changes" ? ( | |
| 822 | + | <span className="flex items-center gap-1 text-xs text-danger"> | |
| 823 | + | <CircleSlash size={13} /> Changes requested | |
| 824 | + | </span> | |
| 825 | + | ) : ( | |
| 826 | + | <span className="text-xs text-faint">Review requested</span> | |
| 827 | + | )} | |
| 828 | + | </li> | |
| 829 | + | ); | |
| 830 | + | })} | |
| 831 | + | {reviewerNames.length === 0 && ( | |
| 832 | + | <li className="px-1 text-xs text-faint">No reviews requested yet.</li> | |
| 833 | + | )} | |
| 834 | + | </ul> | |
| 835 | + | {pull.status === "open" && canManage && ( | |
| 836 | + | <div className="mt-3 space-y-2"> | |
| 837 | + | {agentsEnabled && !reviewPending && ( | |
| 838 | + | <Form method="post"> | |
| 839 | + | <input type="hidden" name="action" value="agent-review" /> | |
| 840 | + | {pull.reviewers.map((name) => ( | |
| 841 | + | <input key={name} type="hidden" name="reviewer" value={name} /> | |
| 842 | + | ))} | |
| 843 | + | <div className="*:w-full"> | |
| 844 | + | <Button variant="quiet" type="submit"> | |
| 845 | + | <Sparkles size={14} className="text-accent" /> | |
| 846 | + | Request review from g1t agent | |
| 847 | + | </Button> | |
| 848 | + | </div> | |
| 849 | + | </Form> | |
| 448 | 850 | )} | |
| 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 | − | )} | |
| 458 | − | <div className="*:w-full"> | |
| 459 | − | <Button variant="accent" type="submit" name="action" value="merge"> | |
| 460 | − | <GitMerge size={15} /> | |
| 461 | − | Merge into {defaultBranch} | |
| 462 | − | </Button> | |
| 463 | − | </div> | |
| 464 | − | </Form> | |
| 465 | − | {actionData?.action === "merge" && <ErrorText>{actionData.error}</ErrorText>} | |
| 466 | − | </section> | |
| 467 | − | )} | |
| 851 | + | <details> | |
| 852 | + | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 853 | + | Request review from people | |
| 854 | + | </summary> | |
| 855 | + | <Form method="post" className="mt-2 space-y-2" key={pull.reviewers.join()}> | |
| 856 | + | <input type="hidden" name="action" value="reviewers" /> | |
| 857 | + | {pull.reviewers.includes("g1t-agent") && ( | |
| 858 | + | <input type="hidden" name="reviewer" value="g1t-agent" /> | |
| 859 | + | )} | |
| 860 | + | <PeoplePicker | |
| 861 | + | name="reviewer" | |
| 862 | + | members={members.filter((name) => name !== pull.author.username)} | |
| 863 | + | chosen={pull.reviewers.filter((name) => name !== "g1t-agent")} | |
| 864 | + | /> | |
| 865 | + | <Button variant="quiet" type="submit"> | |
| 866 | + | Save reviewers | |
| 867 | + | </Button> | |
| 868 | + | </Form> | |
| 869 | + | </details> | |
| 870 | + | </div> | |
| 871 | + | )} | |
| 872 | + | {["agent-review", "reviewers"].includes(String(actionData?.action)) && ( | |
| 873 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 874 | + | )} | |
| 875 | + | </section> | |
| 468 | 876 | ||
| 469 | − | {canManage && active && ( | |
| 470 | − | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 471 | − | <h3 className="text-sm font-medium"> | |
| 472 | − | {pull.status === "draft" ? "Ready for review" : "Update description"} | |
| 473 | − | </h3> | |
| 474 | − | <Form method="post" className="mt-3 space-y-2"> | |
| 475 | − | <Textarea | |
| 476 | − | name="summary" | |
| 477 | − | rows={4} | |
| 478 | − | placeholder="What changed and why" | |
| 479 | − | defaultValue={pull.body ?? ""} | |
| 480 | − | /> | |
| 481 | − | <div className="flex flex-wrap gap-2"> | |
| 482 | − | <Button type="submit"> | |
| 483 | − | {pull.status === "draft" ? "Mark ready" : "Save"} | |
| 484 | − | </Button> | |
| 485 | − | <Button variant="quiet" type="submit" name="action" value="close"> | |
| 486 | − | Close pull request | |
| 487 | − | </Button> | |
| 488 | − | </div> | |
| 489 | − | </Form> | |
| 490 | − | {actionData && | |
| 491 | − | !["merge", "comment", "recheck"].includes(String(actionData.action)) && ( | |
| 492 | − | <ErrorText>{actionData.error}</ErrorText> | |
| 877 | + | <section> | |
| 878 | + | <h3 className="text-sm font-medium">Assignees</h3> | |
| 879 | + | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 880 | + | {pull.assignees.map((name) => ( | |
| 881 | + | <li key={name} className="flex items-center gap-2 px-1"> | |
| 882 | + | <Avatar name={name} size={20} /> | |
| 883 | + | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 884 | + | </li> | |
| 885 | + | ))} | |
| 886 | + | {pull.assignees.length === 0 && ( | |
| 887 | + | <li className="px-1 text-xs text-faint">No one yet.</li> | |
| 493 | 888 | )} | |
| 494 | − | </section> | |
| 495 | − | )} | |
| 889 | + | </ul> | |
| 890 | + | {viewer && canManage && active && ( | |
| 891 | + | <div className="mt-3 space-y-2"> | |
| 892 | + | {!pull.assignees.includes(viewer.username) && ( | |
| 893 | + | <Form method="post"> | |
| 894 | + | <input type="hidden" name="action" value="assign" /> | |
| 895 | + | {pull.assignees.map((name) => ( | |
| 896 | + | <input key={name} type="hidden" name="assignee" value={name} /> | |
| 897 | + | ))} | |
| 898 | + | <input type="hidden" name="assignee" value={viewer.username} /> | |
| 899 | + | <div className="*:w-full"> | |
| 900 | + | <Button variant="quiet" type="submit"> | |
| 901 | + | Assign yourself | |
| 902 | + | </Button> | |
| 903 | + | </div> | |
| 904 | + | </Form> | |
| 905 | + | )} | |
| 906 | + | <details> | |
| 907 | + | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 908 | + | Assign people | |
| 909 | + | </summary> | |
| 910 | + | <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}> | |
| 911 | + | <input type="hidden" name="action" value="assign" /> | |
| 912 | + | <PeoplePicker name="assignee" members={members} chosen={pull.assignees} /> | |
| 913 | + | <Button variant="quiet" type="submit"> | |
| 914 | + | Save assignees | |
| 915 | + | </Button> | |
| 916 | + | </Form> | |
| 917 | + | </details> | |
| 918 | + | </div> | |
| 919 | + | )} | |
| 920 | + | {actionData?.action === "assign" && <ErrorText>{actionData.error}</ErrorText>} | |
| 921 | + | </section> | |
| 496 | 922 | ||
| 497 | 923 | <section> | |
| 498 | 924 | <h3 className="text-sm font-medium">Working copy</h3> | |
| 499 | 925 | {pull.branch ? ( | |
| 500 | 926 | <> | |
| 501 | − | <p className="mt-1 flex items-center gap-1.5 text-xs text-muted"> | |
| 502 | − | <GitBranch size={13} /> | |
| 927 | + | <p className="mt-1 text-xs text-muted"> | |
| 928 | + | <GitBranch size={13} className="mr-1 inline align-[-2px]" /> | |
| 503 | 929 | Branch <span className="font-mono text-fg">{pull.branch}</span> of this | |
| 504 | 930 | repository. Pushes to it show up here. | |
| 505 | 931 | </p> |
| 4 | 4 | import type { Route } from "./+types/pulls"; | |
| 5 | 5 | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 6 | 6 | import { CheckBadge } from "../../components/checks"; | |
| 7 | − | import { PullIcon, StateTabs } from "../../components/work"; | |
| 7 | + | import { ChangeSize, PullIcon, StateTabs } from "../../components/work"; | |
| 8 | 8 | import { work } from "../../lib/services.server"; | |
| 9 | 9 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 10 | 10 | ||
| 51 | 51 | {pulls.map((pull) => ( | |
| 52 | 52 | <li key={pull.id}> | |
| 53 | 53 | <Link | |
| 54 | + | prefetch="intent" | |
| 54 | 55 | to={`${base}/pull/${pull.number}`} | |
| 55 | 56 | className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface" | |
| 56 | 57 | > | |
| 73 | 74 | {pull.supersededBy != null && <> · superseded by #{pull.supersededBy}</>} | |
| 74 | 75 | </span> | |
| 75 | 76 | </span> | |
| 77 | + | <span className="mt-0.5 hidden sm:block"> | |
| 78 | + | <ChangeSize files={pull.files} /> | |
| 79 | + | </span> | |
| 76 | 80 | <span className="mt-0.5"> | |
| 77 | 81 | <CheckBadge status={pull.checkStatus} /> | |
| 78 | 82 | </span> |
| 1 | + | import { | |
| 2 | + | Check, | |
| 3 | + | CircleDashed, | |
| 4 | + | GitCommitHorizontal, | |
| 5 | + | GitMerge, | |
| 6 | + | Layers, | |
| 7 | + | Loader2, | |
| 8 | + | Minus, | |
| 9 | + | X, | |
| 10 | + | } from "lucide-react"; | |
| 11 | + | import { useEffect } from "react"; | |
| 12 | + | import { Link, useRevalidator } from "react-router"; | |
| 13 | + | ||
| 14 | + | import type { QueueEntry, QueueState } from "@g1t/contracts"; | |
| 15 | + | ||
| 16 | + | import type { Route } from "./+types/queue"; | |
| 17 | + | import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 18 | + | import { repos, work } from "../../lib/services.server"; | |
| 19 | + | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 20 | + | ||
| 21 | + | const REFRESH_MS = 4000; | |
| 22 | + | ||
| 23 | + | export function meta({ params }: Route.MetaArgs) { | |
| 24 | + | return [{ title: `Merge queue · ${params.owner}/${params.repo} · g1t` }]; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 28 | + | const viewer = getViewer(context); | |
| 29 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 30 | + | const [queue, repo] = await Promise.all([work.queue(path, viewer), repos.get(path, viewer)]); | |
| 31 | + | return { | |
| 32 | + | queue: unwrap(queue), | |
| 33 | + | defaultBranch: unwrap(repo).defaultBranch, | |
| 34 | + | member: roleIn(viewer, params.owner) != null, | |
| 35 | + | }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | const STATE: Record<QueueState, { label: string; tone: string; icon: React.ReactNode }> = { | |
| 39 | + | waiting: { label: "Waiting", tone: "text-muted border-line", icon: <CircleDashed size={13} /> }, | |
| 40 | + | testing: { | |
| 41 | + | label: "Testing", | |
| 42 | + | tone: "text-accent border-accent/40 bg-accent/10", | |
| 43 | + | icon: <Loader2 size={13} className="animate-spin" />, | |
| 44 | + | }, | |
| 45 | + | passed: { label: "Passed", tone: "text-accent border-accent/40 bg-accent/10", icon: <Check size={13} /> }, | |
| 46 | + | failed: { label: "Failed", tone: "text-danger border-danger/40 bg-danger/10", icon: <X size={13} /> }, | |
| 47 | + | landed: { label: "Landed", tone: "text-merged border-merged/40 bg-merged/10", icon: <GitMerge size={13} /> }, | |
| 48 | + | removed: { label: "Removed", tone: "text-faint border-line", icon: <Minus size={13} /> }, | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | function StateBadge({ state }: { state: QueueState }) { | |
| 52 | + | const { label, tone, icon } = STATE[state]; | |
| 53 | + | return ( | |
| 54 | + | <span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ${tone}`}> | |
| 55 | + | {icon} | |
| 56 | + | {label} | |
| 57 | + | </span> | |
| 58 | + | ); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** `main + #12 + #14`: the state an entry is being tested in. */ | |
| 62 | + | function TestedAs({ entry, branch }: { entry: QueueEntry; branch: string }) { | |
| 63 | + | return ( | |
| 64 | + | <span className="flex flex-wrap items-center gap-1 font-mono text-xs text-muted"> | |
| 65 | + | <span className="rounded border border-line px-1.5 py-px">{branch}</span> | |
| 66 | + | {[...entry.ahead, entry.number].map((number) => ( | |
| 67 | + | <span key={number} className="flex items-center gap-1"> | |
| 68 | + | <span className="text-faint">+</span> | |
| 69 | + | <span | |
| 70 | + | className={`rounded border px-1.5 py-px ${ | |
| 71 | + | number === entry.number ? "border-accent/40 text-fg" : "border-line" | |
| 72 | + | }`} | |
| 73 | + | > | |
| 74 | + | #{number} | |
| 75 | + | </span> | |
| 76 | + | </span> | |
| 77 | + | ))} | |
| 78 | + | </span> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | function Entry({ entry, base, branch, position }: { entry: QueueEntry; base: string; branch: string; position?: number }) { | |
| 83 | + | const ran = entry.results.length; | |
| 84 | + | const passed = entry.results.filter((result) => result.passed).length; | |
| 85 | + | return ( | |
| 86 | + | <li className="relative pl-10"> | |
| 87 | + | {/* Its place on the rail. */} | |
| 88 | + | <span | |
| 89 | + | className={`absolute top-4 left-[13px] size-3 rounded-full border-2 ${ | |
| 90 | + | entry.state === "testing" | |
| 91 | + | ? "animate-pulse border-accent bg-accent/30" | |
| 92 | + | : entry.state === "passed" || entry.state === "landed" | |
| 93 | + | ? "border-accent bg-accent" | |
| 94 | + | : entry.state === "failed" | |
| 95 | + | ? "border-danger bg-danger" | |
| 96 | + | : "border-line-strong bg-bg" | |
| 97 | + | }`} | |
| 98 | + | /> | |
| 99 | + | <div className="rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 100 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 101 | + | {position != null && <span className="font-mono text-xs text-faint">{position}</span>} | |
| 102 | + | <Link to={`${base}/pull/${entry.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent"> | |
| 103 | + | {entry.title} <span className="font-normal text-faint">#{entry.number}</span> | |
| 104 | + | </Link> | |
| 105 | + | <StateBadge state={entry.state} /> | |
| 106 | + | </div> | |
| 107 | + | <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted"> | |
| 108 | + | {(entry.state === "testing" || entry.state === "passed" || entry.state === "failed") && ( | |
| 109 | + | <TestedAs entry={entry} branch={branch} /> | |
| 110 | + | )} | |
| 111 | + | {ran > 0 && ( | |
| 112 | + | <span className={passed === ran ? "text-accent" : "text-danger"}> | |
| 113 | + | {passed}/{ran} checks passed | |
| 114 | + | </span> | |
| 115 | + | )} | |
| 116 | + | <span className="flex items-center gap-1.5"> | |
| 117 | + | <Avatar name={entry.agent} size={14} /> | |
| 118 | + | {entry.agent} | |
| 119 | + | </span> | |
| 120 | + | <span> | |
| 121 | + | queued by {entry.enqueuedBy} <TimeAgo at={entry.createdAt} /> | |
| 122 | + | </span> | |
| 123 | + | {entry.combinedCommit && ( | |
| 124 | + | <span className="flex items-center gap-1 font-mono"> | |
| 125 | + | <GitCommitHorizontal size={12} /> | |
| 126 | + | {entry.combinedCommit.slice(0, 7)} | |
| 127 | + | </span> | |
| 128 | + | )} | |
| 129 | + | </div> | |
| 130 | + | {entry.error && <p className="mt-2.5 text-xs text-danger">{entry.error}</p>} | |
| 131 | + | {entry.state === "failed" && | |
| 132 | + | entry.results | |
| 133 | + | .filter((result) => !result.passed) | |
| 134 | + | .slice(0, 2) | |
| 135 | + | .map((result) => ( | |
| 136 | + | <details key={result.command} className="mt-2"> | |
| 137 | + | <summary className="cursor-pointer font-mono text-xs text-danger">{result.command}</summary> | |
| 138 | + | <pre className="mt-1.5 max-h-48 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted">{result.output}</pre> | |
| 139 | + | </details> | |
| 140 | + | ))} | |
| 141 | + | </div> | |
| 142 | + | </li> | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | export default function Queue({ loaderData, params }: Route.ComponentProps) { | |
| 147 | + | const { queue, defaultBranch, member } = loaderData; | |
| 148 | + | const base = `/${params.owner}/${params.repo}`; | |
| 149 | + | const revalidator = useRevalidator(); | |
| 150 | + | const moving = queue.active.length > 0; | |
| 151 | + | useEffect(() => { | |
| 152 | + | if (!moving) return; | |
| 153 | + | const timer = setInterval(() => { | |
| 154 | + | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 155 | + | }, REFRESH_MS); | |
| 156 | + | return () => clearInterval(timer); | |
| 157 | + | }, [moving, revalidator]); | |
| 158 | + | ||
| 159 | + | const testing = queue.active.filter((entry) => entry.state !== "waiting").length; | |
| 160 | + | return ( | |
| 161 | + | <div className="max-w-4xl"> | |
| 162 | + | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 163 | + | <div> | |
| 164 | + | <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 165 | + | <Layers size={19} className="text-accent" /> | |
| 166 | + | Merge queue | |
| 167 | + | </h2> | |
| 168 | + | <p className="mt-1.5 max-w-2xl text-sm text-muted"> | |
| 169 | + | Each pull request is tested together with everything ahead of it, several | |
| 170 | + | combinations at once, and {defaultBranch} only ever moves to a combination that | |
| 171 | + | passed. One that fails goes back to its author; the ones behind it are tested | |
| 172 | + | again without it. | |
| 173 | + | </p> | |
| 174 | + | </div> | |
| 175 | + | {member && ( | |
| 176 | + | <ButtonLink to={`${base}/settings`}>{queue.enabled ? "Settings" : "Turn it on"}</ButtonLink> | |
| 177 | + | )} | |
| 178 | + | </div> | |
| 179 | + | ||
| 180 | + | {!queue.enabled && queue.active.length === 0 && queue.recent.length === 0 ? ( | |
| 181 | + | <div className="mt-8"> | |
| 182 | + | <EmptyState title="This repository merges directly"> | |
| 183 | + | Turn on <b>Merge through a queue</b> in the repository's settings, and merging a | |
| 184 | + | pull request adds it here instead of changing {defaultBranch} at once. | |
| 185 | + | </EmptyState> | |
| 186 | + | </div> | |
| 187 | + | ) : ( | |
| 188 | + | <> | |
| 189 | + | <section className="mt-8"> | |
| 190 | + | <div className="flex items-baseline justify-between"> | |
| 191 | + | <h3 className="text-sm font-medium">In the queue</h3> | |
| 192 | + | <span className="text-xs text-muted"> | |
| 193 | + | {queue.active.length} waiting to land{testing > 0 && `, ${testing} being tested now`} | |
| 194 | + | </span> | |
| 195 | + | </div> | |
| 196 | + | <ol className="relative mt-3 space-y-3 before:absolute before:top-0 before:bottom-4 before:left-[18px] before:w-px before:bg-line"> | |
| 197 | + | <li className="relative flex items-center gap-2 pl-10 text-xs text-muted"> | |
| 198 | + | <span className="absolute left-[11px] flex size-4 items-center justify-center rounded-full bg-raised ring-1 ring-line-strong"> | |
| 199 | + | <GitCommitHorizontal size={10} /> | |
| 200 | + | </span> | |
| 201 | + | <span className="font-mono text-fg">{defaultBranch}</span> | |
| 202 | + | {queue.active[0]?.baseCommit && ( | |
| 203 | + | <span className="font-mono">at {queue.active[0].baseCommit.slice(0, 7)}</span> | |
| 204 | + | )} | |
| 205 | + | </li> | |
| 206 | + | {queue.active.map((entry, index) => ( | |
| 207 | + | <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} position={index + 1} /> | |
| 208 | + | ))} | |
| 209 | + | {queue.active.length === 0 && ( | |
| 210 | + | <li className="pl-10 text-sm text-muted">Nothing is waiting. Merged pull requests appear here.</li> | |
| 211 | + | )} | |
| 212 | + | </ol> | |
| 213 | + | </section> | |
| 214 | + | ||
| 215 | + | {queue.recent.length > 0 && ( | |
| 216 | + | <section className="mt-10"> | |
| 217 | + | <h3 className="text-sm font-medium">Recently</h3> | |
| 218 | + | <ol className="mt-3 space-y-3"> | |
| 219 | + | {queue.recent.map((entry) => ( | |
| 220 | + | <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} /> | |
| 221 | + | ))} | |
| 222 | + | </ol> | |
| 223 | + | </section> | |
| 224 | + | )} | |
| 225 | + | </> | |
| 226 | + | )} | |
| 227 | + | </div> | |
| 228 | + | ); | |
| 229 | + | } |
| 1 | + | import type { ReactNode } from "react"; | |
| 2 | + | import { Form, data, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/settings"; | |
| 5 | + | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 6 | + | import { repos, work } from "../../lib/services.server"; | |
| 7 | + | import { | |
| 8 | + | assertSameOrigin, | |
| 9 | + | getViewer, | |
| 10 | + | requireUser, | |
| 11 | + | roleIn, | |
| 12 | + | unwrap, | |
| 13 | + | } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params }: Route.MetaArgs) { | |
| 16 | + | return [{ title: `Settings · ${params.owner}/${params.repo} · g1t` }]; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 20 | + | const viewer = getViewer(context); | |
| 21 | + | // Members only; to anyone else the page does not exist. | |
| 22 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 23 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 24 | + | const [repo, settings] = await Promise.all([ | |
| 25 | + | repos.get(path, viewer), | |
| 26 | + | work.getSettings(path, viewer), | |
| 27 | + | ]); | |
| 28 | + | return { repo: unwrap(repo), settings: unwrap(settings) }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /** A whole number from a form field, kept within `min` and `max`. */ | |
| 32 | + | function count(value: FormDataEntryValue | null, min: number, max: number): number { | |
| 33 | + | const number = Math.trunc(Number(value)); | |
| 34 | + | return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : min; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 38 | + | assertSameOrigin(request); | |
| 39 | + | const user = requireUser(context, request); | |
| 40 | + | const form = await request.formData(); | |
| 41 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 42 | + | const on = (name: string) => form.get(name) === "on"; | |
| 43 | + | ||
| 44 | + | // The repository's own details belong to one service and how its pull | |
| 45 | + | // requests are handled to another; the page is one form over both. | |
| 46 | + | const repo = await repos.update(user, path, { | |
| 47 | + | description: String(form.get("description") ?? ""), | |
| 48 | + | isPrivate: form.get("visibility") === "private", | |
| 49 | + | protected: on("protected"), | |
| 50 | + | }); | |
| 51 | + | if (!repo.ok) return { saved: false, error: repo.error.message }; | |
| 52 | + | const settings = await work.updateSettings(user, path, { | |
| 53 | + | autoMerge: on("autoMerge"), | |
| 54 | + | requireUpToDate: on("requireUpToDate"), | |
| 55 | + | requiredApprovals: count(form.get("requiredApprovals"), 0, 6), | |
| 56 | + | countAgentApprovals: on("countAgentApprovals"), | |
| 57 | + | allowIgnoringChecks: !on("requireChecks"), | |
| 58 | + | agentReview: on("agentReview"), | |
| 59 | + | maxRevisions: count(form.get("maxRevisions"), 0, 5), | |
| 60 | + | mergeQueue: on("mergeQueue"), | |
| 61 | + | }); | |
| 62 | + | return settings.ok | |
| 63 | + | ? { saved: true, error: null } | |
| 64 | + | : { saved: false, error: settings.error.message }; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | function Section({ | |
| 68 | + | title, | |
| 69 | + | about, | |
| 70 | + | children, | |
| 71 | + | }: { | |
| 72 | + | title: string; | |
| 73 | + | about: string; | |
| 74 | + | children: ReactNode; | |
| 75 | + | }) { | |
| 76 | + | return ( | |
| 77 | + | <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]"> | |
| 78 | + | <div> | |
| 79 | + | <h2 className="font-medium">{title}</h2> | |
| 80 | + | <p className="mt-1 text-sm text-muted">{about}</p> | |
| 81 | + | </div> | |
| 82 | + | <div className="space-y-3">{children}</div> | |
| 83 | + | </section> | |
| 84 | + | ); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /** A setting that is on or off, with what it means. */ | |
| 88 | + | function Toggle({ | |
| 89 | + | name, | |
| 90 | + | on, | |
| 91 | + | title, | |
| 92 | + | children, | |
| 93 | + | }: { | |
| 94 | + | name: string; | |
| 95 | + | on: boolean; | |
| 96 | + | title: string; | |
| 97 | + | children: ReactNode; | |
| 98 | + | }) { | |
| 99 | + | return ( | |
| 100 | + | <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 101 | + | <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" /> | |
| 102 | + | <span> | |
| 103 | + | <span className="block text-sm font-medium">{title}</span> | |
| 104 | + | <span className="mt-1 block text-sm text-muted">{children}</span> | |
| 105 | + | </span> | |
| 106 | + | </label> | |
| 107 | + | ); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /** A setting chosen from a few numbers. */ | |
| 111 | + | function Choice({ | |
| 112 | + | name, | |
| 113 | + | value, | |
| 114 | + | options, | |
| 115 | + | title, | |
| 116 | + | children, | |
| 117 | + | }: { | |
| 118 | + | name: string; | |
| 119 | + | value: number; | |
| 120 | + | options: [number, string][]; | |
| 121 | + | title: string; | |
| 122 | + | children: ReactNode; | |
| 123 | + | }) { | |
| 124 | + | return ( | |
| 125 | + | <div className="flex items-start gap-4 rounded-xl border border-line bg-surface p-4"> | |
| 126 | + | <div className="grow"> | |
| 127 | + | <p className="text-sm font-medium">{title}</p> | |
| 128 | + | <p className="mt-1 text-sm text-muted">{children}</p> | |
| 129 | + | </div> | |
| 130 | + | <select | |
| 131 | + | name={name} | |
| 132 | + | defaultValue={value} | |
| 133 | + | aria-label={title} | |
| 134 | + | className="shrink-0 rounded-md border border-line bg-bg px-2 py-1.5 text-sm" | |
| 135 | + | > | |
| 136 | + | {options.map(([option, label]) => ( | |
| 137 | + | <option key={option} value={option}> | |
| 138 | + | {label} | |
| 139 | + | </option> | |
| 140 | + | ))} | |
| 141 | + | </select> | |
| 142 | + | </div> | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 147 | + | const { repo, settings } = loaderData; | |
| 148 | + | const saving = useNavigation().state === "submitting"; | |
| 149 | + | const branch = repo.defaultBranch; | |
| 150 | + | return ( | |
| 151 | + | <Form method="post" className="max-w-4xl space-y-8"> | |
| 152 | + | <Section title="General" about="What the repository is and who can see it."> | |
| 153 | + | <Field label="Description"> | |
| 154 | + | <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} /> | |
| 155 | + | </Field> | |
| 156 | + | <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 157 | + | <legend className="sr-only">Visibility</legend> | |
| 158 | + | <label className="flex items-start gap-3"> | |
| 159 | + | <input | |
| 160 | + | type="radio" | |
| 161 | + | name="visibility" | |
| 162 | + | value="public" | |
| 163 | + | defaultChecked={!repo.isPrivate} | |
| 164 | + | className="mt-1 accent-accent" | |
| 165 | + | /> | |
| 166 | + | <span> | |
| 167 | + | <span className="block font-medium">Public</span> | |
| 168 | + | <span className="text-muted">Anyone can see and clone it.</span> | |
| 169 | + | </span> | |
| 170 | + | </label> | |
| 171 | + | <label className="flex items-start gap-3"> | |
| 172 | + | <input | |
| 173 | + | type="radio" | |
| 174 | + | name="visibility" | |
| 175 | + | value="private" | |
| 176 | + | defaultChecked={repo.isPrivate} | |
| 177 | + | className="mt-1 accent-accent" | |
| 178 | + | /> | |
| 179 | + | <span> | |
| 180 | + | <span className="block font-medium">Private</span> | |
| 181 | + | <span className="text-muted">Only members of the workspace can see it.</span> | |
| 182 | + | </span> | |
| 183 | + | </label> | |
| 184 | + | </fieldset> | |
| 185 | + | </Section> | |
| 186 | + | ||
| 187 | + | <Section | |
| 188 | + | title="Branch protection" | |
| 189 | + | about={`Rules for ${branch}, the branch everything lands on.`} | |
| 190 | + | > | |
| 191 | + | <Toggle | |
| 192 | + | name="protected" | |
| 193 | + | on={repo.protected} | |
| 194 | + | title={`Require a pull request to change ${branch}`} | |
| 195 | + | > | |
| 196 | + | Pushing to {branch} is refused, for members and agents alike, and git says why. | |
| 197 | + | Changes reach it only by merging a pull request. The first push to an empty | |
| 198 | + | repository is still allowed. | |
| 199 | + | </Toggle> | |
| 200 | + | <Choice | |
| 201 | + | name="requiredApprovals" | |
| 202 | + | value={settings.requiredApprovals} | |
| 203 | + | title="Required approvals" | |
| 204 | + | options={[ | |
| 205 | + | [0, "None"], | |
| 206 | + | [1, "1"], | |
| 207 | + | [2, "2"], | |
| 208 | + | [3, "3"], | |
| 209 | + | ]} | |
| 210 | + | > | |
| 211 | + | How many reviewers must approve before a pull request can merge. A reviewer | |
| 212 | + | who has since asked for changes blocks it, and nobody approves their own. | |
| 213 | + | </Choice> | |
| 214 | + | <Toggle | |
| 215 | + | name="countAgentApprovals" | |
| 216 | + | on={settings.countAgentApprovals} | |
| 217 | + | title="A g1t agent's approval counts" | |
| 218 | + | > | |
| 219 | + | With this off, required approvals have to come from people, and an agent's | |
| 220 | + | review is advice. | |
| 221 | + | </Toggle> | |
| 222 | + | <Toggle | |
| 223 | + | name="requireChecks" | |
| 224 | + | on={!settings.allowIgnoringChecks} | |
| 225 | + | title="Require acceptance checks to pass" | |
| 226 | + | > | |
| 227 | + | With this off, a member can choose to merge although the issue's checks | |
| 228 | + | failed or have not finished. With it on, nobody can. | |
| 229 | + | </Toggle> | |
| 230 | + | <Toggle | |
| 231 | + | name="requireUpToDate" | |
| 232 | + | on={settings.requireUpToDate} | |
| 233 | + | title="Require pull requests to be up to date before merging" | |
| 234 | + | > | |
| 235 | + | With this off, a pull request can be merged after {branch} has moved: g1t | |
| 236 | + | brings it up to date as part of merging, and asks you only if there is a | |
| 237 | + | conflict it cannot resolve. With it on, it has to catch up first and its | |
| 238 | + | checks run again on the result, so what lands is exactly what was checked. | |
| 239 | + | </Toggle> | |
| 240 | + | <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue"> | |
| 241 | + | Merging adds a pull request to the queue instead of changing {branch} at once. | |
| 242 | + | g1t tests it together with every pull request ahead of it, several | |
| 243 | + | combinations at a time, and {branch} only ever moves to a combination whose | |
| 244 | + | checks passed. One that fails leaves the queue and goes back to its author, | |
| 245 | + | and the ones behind it are tested again without it. | |
| 246 | + | </Toggle> | |
| 247 | + | </Section> | |
| 248 | + | ||
| 249 | + | <Section | |
| 250 | + | title="g1t agents" | |
| 251 | + | about="What happens to a pull request a g1t agent makes, from the moment it is ready." | |
| 252 | + | > | |
| 253 | + | <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent"> | |
| 254 | + | A different agent reads each change and posts comments on lines, a summary | |
| 255 | + | and a verdict. If it asks for changes, the author is sent back to make them. | |
| 256 | + | With this off, review is left to people. | |
| 257 | + | </Toggle> | |
| 258 | + | <Choice | |
| 259 | + | name="maxRevisions" | |
| 260 | + | value={settings.maxRevisions} | |
| 261 | + | title="Revisions before asking you" | |
| 262 | + | options={[ | |
| 263 | + | [0, "None"], | |
| 264 | + | [1, "1"], | |
| 265 | + | [2, "2"], | |
| 266 | + | [3, "3"], | |
| 267 | + | [5, "5"], | |
| 268 | + | ]} | |
| 269 | + | > | |
| 270 | + | How many times an agent is sent back to fix failed checks or address a | |
| 271 | + | review before g1t stops and the pull request says it needs you. | |
| 272 | + | </Choice> | |
| 273 | + | <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready"> | |
| 274 | + | A g1t agent's pull request lands without anyone pressing merge once every | |
| 275 | + | rule above is met. With this off, it waits for a member. Pull requests from | |
| 276 | + | people and from other agents always wait. | |
| 277 | + | </Toggle> | |
| 278 | + | </Section> | |
| 279 | + | ||
| 280 | + | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 281 | + | <Button type="submit" disabled={saving}> | |
| 282 | + | {saving ? "Saving…" : "Save settings"} | |
| 283 | + | </Button> | |
| 284 | + | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 285 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 286 | + | {settings.updatedBy && settings.updatedAt && !actionData && ( | |
| 287 | + | <span className="text-xs text-faint"> | |
| 288 | + | Merge rules last changed by{" "} | |
| 289 | + | <span className="font-mono">{settings.updatedBy}</span>{" "} | |
| 290 | + | <TimeAgo at={settings.updatedAt} /> | |
| 291 | + | </span> | |
| 292 | + | )} | |
| 293 | + | </div> | |
| 294 | + | </Form> | |
| 295 | + | ); | |
| 296 | + | } |
| 1 | + | import { data } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { SessionEntry } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/why"; | |
| 6 | + | import { pullForCommit } from "../../lib/provenance.server"; | |
| 7 | + | import { repos, work } from "../../lib/services.server"; | |
| 8 | + | import { getViewer } from "../../lib/session.server"; | |
| 9 | + | ||
| 10 | + | /** How many steps of the agent's session are shown for one commit. */ | |
| 11 | + | const MAX_STEPS = 8; | |
| 12 | + | /** Longest a step's text is shown. */ | |
| 13 | + | const MAX_STEP_CHARS = 600; | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * The steps of a session behind `hash`: what the agent said and did | |
| 17 | + | * between the previous commit and this one. Entries carry the head commit | |
| 18 | + | * when they were recorded, so the work that produced `hash` is the run of | |
| 19 | + | * entries just before the first one recorded at it. Steps that touch | |
| 20 | + | * `file` come first. | |
| 21 | + | */ | |
| 22 | + | function stepsFor(session: SessionEntry[], hash: string, file: string): SessionEntry[] { | |
| 23 | + | // The sandbox notes each push: "Pushed b1c42490059b." The work behind a | |
| 24 | + | // commit runs from the push before it to the push that carried it. | |
| 25 | + | const pushed = (entry: SessionEntry) => | |
| 26 | + | entry.kind === "note" ? /^Pushed ([0-9a-f]{7,40})/.exec(entry.text)?.[1] ?? null : null; | |
| 27 | + | const end = session.findIndex((entry) => { | |
| 28 | + | const pushedHash = pushed(entry); | |
| 29 | + | return entry.commit === hash || (pushedHash != null && hash.startsWith(pushedHash)); | |
| 30 | + | }); | |
| 31 | + | let window = session; | |
| 32 | + | if (end >= 0) { | |
| 33 | + | let start = end - 1; | |
| 34 | + | while (start >= 0 && pushed(session[start]!) == null) start--; | |
| 35 | + | window = session.slice(start + 1, end + 1); | |
| 36 | + | } | |
| 37 | + | const name = file.split("/").pop() ?? file; | |
| 38 | + | const useful = window.filter( | |
| 39 | + | (entry) => | |
| 40 | + | entry.kind === "message" || | |
| 41 | + | (entry.kind === "tool_call" && (entry.text.includes(file) || entry.text.includes(name))), | |
| 42 | + | ); | |
| 43 | + | const touching = useful.filter((entry) => entry.text.includes(name)); | |
| 44 | + | const chosen = (touching.length > 0 ? [...touching, ...useful.filter((e) => e.kind === "message")] : useful) | |
| 45 | + | .filter((entry, index, all) => all.findIndex((other) => other.seq === entry.seq) === index) | |
| 46 | + | .sort((a, b) => a.seq - b.seq) | |
| 47 | + | .slice(-MAX_STEPS); | |
| 48 | + | return chosen.map((entry) => ({ | |
| 49 | + | ...entry, | |
| 50 | + | text: entry.text.length > MAX_STEP_CHARS ? `${entry.text.slice(0, MAX_STEP_CHARS)}…` : entry.text, | |
| 51 | + | })); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * Why a line is the way it is: the commit that last changed it, the pull | |
| 56 | + | * request and issue it came from, and what the agent was thinking. | |
| 57 | + | * Fetched by the blame view when a line is picked. | |
| 58 | + | */ | |
| 59 | + | export async function loader({ params, request, context }: Route.LoaderArgs) { | |
| 60 | + | const viewer = getViewer(context); | |
| 61 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 62 | + | const file = new URL(request.url).searchParams.get("path") ?? ""; | |
| 63 | + | if (!/^[0-9a-f]{40}$/i.test(params.hash)) throw data(null, { status: 404 }); | |
| 64 | + | const [log, pull] = await Promise.all([ | |
| 65 | + | repos.log(path, viewer, params.hash, 1), | |
| 66 | + | pullForCommit(path, viewer, params.hash), | |
| 67 | + | ]); | |
| 68 | + | const commit = log.ok ? log.value[0] : undefined; | |
| 69 | + | if (!commit) throw data(null, { status: 404 }); | |
| 70 | + | if (!pull) return { commit, pull: null, issue: null, steps: [] as SessionEntry[] }; | |
| 71 | + | const [detail, session] = await Promise.all([ | |
| 72 | + | work.getPull(path, pull.number, viewer), | |
| 73 | + | pull.runtime === "hosted" || pull.agent ? work.readSession(path, pull.number, viewer) : null, | |
| 74 | + | ]); | |
| 75 | + | const issue = detail.ok ? detail.value.issue : null; | |
| 76 | + | return { | |
| 77 | + | commit, | |
| 78 | + | pull: { | |
| 79 | + | number: pull.number, | |
| 80 | + | title: pull.title, | |
| 81 | + | agent: pull.agent, | |
| 82 | + | runtime: pull.runtime, | |
| 83 | + | author: pull.author.username, | |
| 84 | + | mergedBy: pull.mergedBy, | |
| 85 | + | mergedAt: pull.mergedAt, | |
| 86 | + | }, | |
| 87 | + | issue: issue && { | |
| 88 | + | number: issue.number, | |
| 89 | + | title: issue.title, | |
| 90 | + | body: issue.body.length > 900 ? `${issue.body.slice(0, 900)}…` : issue.body, | |
| 91 | + | author: issue.author.username, | |
| 92 | + | }, | |
| 93 | + | steps: session?.ok ? stepsFor(session.value, commit.hash, file) : [], | |
| 94 | + | }; | |
| 95 | + | } |
| 1 | 1 | import { identity } from "../lib/services.server"; | |
| 2 | − | import { Form } from "react-router"; | |
| 2 | + | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings"; | |
| 5 | 5 | import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui"; | |
| 40 | 40 | case "add-token": { | |
| 41 | 41 | const created = await identity.createAccessToken( | |
| 42 | 42 | user, | |
| 43 | − | String(form.get("name") ?? ""), | |
| 43 | + | String(form.get("label") ?? ""), | |
| 44 | 44 | ); | |
| 45 | 45 | return { newToken: created.token }; | |
| 46 | 46 | } | |
| 117 | 117 | <h2 className="font-medium">Access tokens</h2> | |
| 118 | 118 | <p className="mt-1 text-sm text-muted"> | |
| 119 | 119 | Use a token as the password when git asks for one over HTTPS, and to | |
| 120 | − | authenticate agents and the API. | |
| 120 | + | authenticate agents and the API. A token here acts as you. | |
| 121 | 121 | </p> | |
| 122 | + | {(user.workspaces ?? []).length > 0 && ( | |
| 123 | + | <p className="mt-1 text-sm text-muted"> | |
| 124 | + | For CI and integrations that work for a team, use a workspace's | |
| 125 | + | own tokens instead:{" "} | |
| 126 | + | {(user.workspaces ?? []).map((membership, i) => ( | |
| 127 | + | <span key={membership.slug}> | |
| 128 | + | {i > 0 && ", "} | |
| 129 | + | <Link | |
| 130 | + | to={`/${membership.slug}/-/tokens`} | |
| 131 | + | className="font-mono text-fg underline underline-offset-4" | |
| 132 | + | > | |
| 133 | + | {membership.slug} | |
| 134 | + | </Link> | |
| 135 | + | </span> | |
| 136 | + | ))} | |
| 137 | + | . | |
| 138 | + | </p> | |
| 139 | + | )} | |
| 122 | 140 | {actionData?.newToken && ( | |
| 123 | 141 | <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4"> | |
| 124 | 142 | <p className="text-sm">Copy it now. It will not be shown again.</p> | |
| 130 | 148 | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> | |
| 131 | 149 | {tokens.map((token) => ( | |
| 132 | 150 | <li key={token.id} className="flex items-center gap-4 px-4 py-3"> | |
| 133 | − | <p className="text-sm">{token.name}</p> | |
| 151 | + | <div className="min-w-0"> | |
| 152 | + | <p className="truncate text-sm">{token.name}</p> | |
| 153 | + | <p className="text-xs text-faint"> | |
| 154 | + | Created <TimeAgo at={token.createdAt} /> ·{" "} | |
| 155 | + | {token.lastUsedAt ? ( | |
| 156 | + | <> | |
| 157 | + | last used <TimeAgo at={token.lastUsedAt} /> | |
| 158 | + | </> | |
| 159 | + | ) : ( | |
| 160 | + | "never used" | |
| 161 | + | )} | |
| 162 | + | </p> | |
| 163 | + | </div> | |
| 134 | 164 | <DeleteButton intent="delete-token" id={token.id} /> | |
| 135 | 165 | </li> | |
| 136 | 166 | ))} | |
| 139 | 169 | <input type="hidden" name="intent" value="add-token" /> | |
| 140 | 170 | <div className="grow"> | |
| 141 | 171 | <Field label="Name"> | |
| 142 | − | <Input name="name" maxLength={100} placeholder="laptop" /> | |
| 172 | + | <Input name="label" maxLength={100} placeholder="laptop" /> | |
| 143 | 173 | </Field> | |
| 144 | 174 | </div> | |
| 145 | 175 | <Button type="submit">Create token</Button> |
| 36 | 36 | Email confirmed | |
| 37 | 37 | </h1> | |
| 38 | 38 | <p className="mt-2 text-muted"> | |
| 39 | − | Your account {loaderData.username} is ready to use. | |
| 39 | + | Your account {loaderData.username} is ready. Next, create the | |
| 40 | + | workspace your repositories will live in. | |
| 40 | 41 | </p> | |
| 41 | 42 | <div className="mt-8"> | |
| 42 | − | <ButtonLink to="/">Go to g1t</ButtonLink> | |
| 43 | + | <ButtonLink to="/">Continue</ButtonLink> | |
| 43 | 44 | </div> | |
| 44 | 45 | </> | |
| 45 | 46 | ) : ( |
| 1 | − | import { Form, redirect } from "react-router"; | |
| 2 | − | ||
| 3 | − | import type { Route } from "./+types/workspace-new"; | |
| 4 | − | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 5 | − | import { identity } from "../lib/services.server"; | |
| 6 | − | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 7 | − | ||
| 8 | − | export function meta({}: Route.MetaArgs) { | |
| 9 | − | return [{ title: "New workspace · g1t" }]; | |
| 10 | − | } | |
| 11 | − | ||
| 12 | − | export function loader({ request, context }: Route.LoaderArgs) { | |
| 13 | − | const user = requireUser(context, request); | |
| 14 | − | return { user, first: (user.workspaces ?? []).length === 0 }; | |
| 15 | − | } | |
| 16 | − | ||
| 17 | − | export async function action({ request, context }: Route.ActionArgs) { | |
| 18 | − | assertSameOrigin(request); | |
| 19 | − | const user = requireUser(context, request); | |
| 20 | − | const form = await request.formData(); | |
| 21 | − | const result = await identity.createWorkspace( | |
| 22 | − | user, | |
| 23 | − | String(form.get("slug") ?? ""), | |
| 24 | − | String(form.get("name") ?? ""), | |
| 25 | − | ); | |
| 26 | − | if (!result.ok) return { error: result.error.message }; | |
| 27 | − | throw redirect(`/${result.value.slug}`); | |
| 28 | − | } | |
| 29 | − | ||
| 30 | − | export default function NewWorkspace({ | |
| 31 | − | loaderData, | |
| 32 | − | actionData, | |
| 33 | − | }: Route.ComponentProps) { | |
| 34 | − | const { user, first } = loaderData; | |
| 35 | − | return ( | |
| 36 | − | <main className="mx-auto max-w-lg px-4 py-12"> | |
| 37 | − | <h1 className="text-xl font-semibold"> | |
| 38 | − | {first ? "Create your first workspace" : "New workspace"} | |
| 39 | − | </h1> | |
| 40 | − | <p className="mt-2 text-sm text-muted"> | |
| 41 | − | A workspace owns repositories and is the first part of their address:{" "} | |
| 42 | − | <span className="font-mono text-fg">g1t.sh/workspace/repo</span>. Use | |
| 43 | − | one for yourself, and one for each team or company you work with. | |
| 44 | − | </p> | |
| 45 | − | <Form method="post" className="mt-8 space-y-4"> | |
| 46 | − | <Field | |
| 47 | − | label="Name in URLs" | |
| 48 | − | hint="Lowercase letters, digits and single hyphens. It cannot be changed later." | |
| 49 | − | > | |
| 50 | − | <div className="flex items-center gap-2 font-mono text-sm"> | |
| 51 | − | <span className="text-muted">g1t.sh/</span> | |
| 52 | − | <Input | |
| 53 | − | name="slug" | |
| 54 | − | required | |
| 55 | − | autoFocus | |
| 56 | − | maxLength={39} | |
| 57 | − | defaultValue={first ? user.username : ""} | |
| 58 | − | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" | |
| 59 | − | /> | |
| 60 | − | </div> | |
| 61 | − | </Field> | |
| 62 | − | <Field label="Display name (optional)"> | |
| 63 | − | <Input name="name" maxLength={80} /> | |
| 64 | − | </Field> | |
| 65 | − | <ErrorText>{actionData?.error}</ErrorText> | |
| 66 | − | <Button type="submit">Create workspace</Button> | |
| 67 | − | </Form> | |
| 68 | − | </main> | |
| 69 | − | ); | |
| 70 | − | } |
| 1 | − | import { Plus, Users } from "lucide-react"; | |
| 2 | − | import { Form, data } from "react-router"; | |
| 3 | − | ||
| 4 | − | import type { Route } from "./+types/workspace"; | |
| 5 | − | import { RepoList } from "../components/repo-list"; | |
| 6 | − | import { Avatar, Button, ButtonLink, ErrorText, Input, Pill } from "../components/ui"; | |
| 7 | − | import { identity, repos as reposApi } from "../lib/services.server"; | |
| 8 | − | import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server"; | |
| 9 | − | ||
| 10 | − | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 11 | − | return [{ title: `${loaderData?.workspace.name ?? params.owner} · g1t` }]; | |
| 12 | − | } | |
| 13 | − | ||
| 14 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 15 | − | const viewer = getViewer(context); | |
| 16 | − | const workspace = await identity.getWorkspace(params.owner); | |
| 17 | − | if (!workspace) throw data(null, { status: 404 }); | |
| 18 | − | const role = | |
| 19 | − | viewer?.workspaces?.find((membership) => membership.slug === workspace.slug) | |
| 20 | − | ?.role ?? null; | |
| 21 | − | const [repos, members] = await Promise.all([ | |
| 22 | − | reposApi.list(viewer, { namespace: workspace.slug }), | |
| 23 | − | role ? identity.listMembers(workspace.slug, viewer) : null, | |
| 24 | − | ]); | |
| 25 | − | return { | |
| 26 | − | workspace, | |
| 27 | − | repos, | |
| 28 | − | role, | |
| 29 | − | members: members?.ok ? members.value : null, | |
| 30 | − | }; | |
| 31 | − | } | |
| 32 | − | ||
| 33 | − | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 34 | − | assertSameOrigin(request); | |
| 35 | − | const user = requireUser(context, request); | |
| 36 | − | const form = await request.formData(); | |
| 37 | − | const username = String(form.get("username") ?? ""); | |
| 38 | − | const result = | |
| 39 | − | form.get("action") === "remove" | |
| 40 | − | ? await identity.removeMember(user, params.owner, username) | |
| 41 | − | : await identity.addMember(user, params.owner, username); | |
| 42 | − | return result.ok ? null : { error: result.error.message }; | |
| 43 | − | } | |
| 44 | − | ||
| 45 | − | export default function WorkspacePage({ | |
| 46 | − | loaderData, | |
| 47 | − | actionData, | |
| 48 | − | }: Route.ComponentProps) { | |
| 49 | − | const { workspace, repos, role, members } = loaderData; | |
| 50 | − | return ( | |
| 51 | − | <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_18rem]"> | |
| 52 | − | <div className="min-w-0"> | |
| 53 | − | <div className="flex items-center gap-4"> | |
| 54 | − | <Avatar name={workspace.slug} size={56} /> | |
| 55 | − | <div className="min-w-0 grow"> | |
| 56 | − | <h1 className="truncate text-2xl font-semibold tracking-tight"> | |
| 57 | − | {workspace.name} | |
| 58 | − | </h1> | |
| 59 | − | <p className="font-mono text-sm text-muted"> | |
| 60 | − | g1t.sh/{workspace.slug} | |
| 61 | − | </p> | |
| 62 | − | </div> | |
| 63 | − | {role && ( | |
| 64 | − | <ButtonLink to={`/new?workspace=${workspace.slug}`}> | |
| 65 | − | <Plus size={15} /> | |
| 66 | − | New repository | |
| 67 | − | </ButtonLink> | |
| 68 | − | )} | |
| 69 | − | </div> | |
| 70 | − | <h2 className="mt-10 text-sm font-medium text-muted">Repositories</h2> | |
| 71 | − | <RepoList repos={repos} /> | |
| 72 | − | </div> | |
| 73 | − | ||
| 74 | − | <aside> | |
| 75 | − | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 76 | − | <Users size={15} className="text-faint" /> | |
| 77 | − | {workspace.memberCount}{" "} | |
| 78 | − | {workspace.memberCount === 1 ? "member" : "members"} | |
| 79 | − | </h2> | |
| 80 | − | {members && ( | |
| 81 | − | <ul className="mt-3 space-y-1"> | |
| 82 | − | {members.map((member) => ( | |
| 83 | − | <li | |
| 84 | − | key={member.username} | |
| 85 | − | className="flex items-center gap-2 rounded-md px-1 py-1 text-sm" | |
| 86 | − | > | |
| 87 | − | <Avatar name={member.username} /> | |
| 88 | − | <span className="grow font-mono">{member.username}</span> | |
| 89 | − | {member.role === "owner" ? ( | |
| 90 | − | <Pill>owner</Pill> | |
| 91 | − | ) : ( | |
| 92 | − | role === "owner" && ( | |
| 93 | − | <Form method="post"> | |
| 94 | − | <input type="hidden" name="action" value="remove" /> | |
| 95 | − | <input type="hidden" name="username" value={member.username} /> | |
| 96 | − | <button | |
| 97 | − | type="submit" | |
| 98 | − | className="text-xs text-faint hover:text-danger" | |
| 99 | − | > | |
| 100 | − | Remove | |
| 101 | − | </button> | |
| 102 | − | </Form> | |
| 103 | − | ) | |
| 104 | − | )} | |
| 105 | − | </li> | |
| 106 | − | ))} | |
| 107 | − | </ul> | |
| 108 | − | )} | |
| 109 | − | {role === "owner" && ( | |
| 110 | − | <Form method="post" className="mt-4 flex gap-2"> | |
| 111 | − | <Input name="username" placeholder="Username to add" required /> | |
| 112 | − | <Button variant="quiet" type="submit"> | |
| 113 | − | Add | |
| 114 | − | </Button> | |
| 115 | − | </Form> | |
| 116 | − | )} | |
| 117 | − | <div className="mt-2"> | |
| 118 | − | <ErrorText>{actionData?.error}</ErrorText> | |
| 119 | − | </div> | |
| 120 | − | {!role && ( | |
| 121 | − | <p className="mt-2 text-sm text-muted"> | |
| 122 | − | Members can create repositories here and merge pull requests into them. | |
| 123 | − | </p> | |
| 124 | − | )} | |
| 125 | − | </aside> | |
| 126 | − | </main> | |
| 127 | − | ); | |
| 128 | − | } |
| 1 | + | import { CreditCard } from "lucide-react"; | |
| 2 | + | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { MICROS_PER_DOLLAR } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/billing"; | |
| 7 | + | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { billing } from "../../lib/services.server"; | |
| 9 | + | import { | |
| 10 | + | assertSameOrigin, | |
| 11 | + | getViewer, | |
| 12 | + | requireUser, | |
| 13 | + | roleIn, | |
| 14 | + | unwrap, | |
| 15 | + | } from "../../lib/session.server"; | |
| 16 | + | ||
| 17 | + | /** What can be added in one payment, in dollars. */ | |
| 18 | + | const AMOUNTS = [10, 25, 50, 100]; | |
| 19 | + | ||
| 20 | + | export function meta({ params }: Route.MetaArgs) { | |
| 21 | + | return [{ title: `Billing · ${params.owner} · g1t` }]; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 25 | + | const viewer = getViewer(context); | |
| 26 | + | const role = roleIn(viewer, params.owner); | |
| 27 | + | // Members only; to anyone else the page does not exist. | |
| 28 | + | if (!role) throw data(null, { status: 404 }); | |
| 29 | + | const slug = params.owner.toLowerCase(); | |
| 30 | + | ||
| 31 | + | // Back from the payment page: credit it, then drop the id from the address. | |
| 32 | + | const url = new URL(request.url); | |
| 33 | + | const session = url.searchParams.get("session"); | |
| 34 | + | if (session) { | |
| 35 | + | await billing.confirm(slug, viewer, session); | |
| 36 | + | throw redirect(`/${slug}/-/billing?added=1`); | |
| 37 | + | } | |
| 38 | + | const [account, ledger] = await Promise.all([ | |
| 39 | + | billing.account(slug, viewer), | |
| 40 | + | billing.ledger(slug, viewer), | |
| 41 | + | ]); | |
| 42 | + | return { | |
| 43 | + | slug, | |
| 44 | + | role, | |
| 45 | + | account: unwrap(account), | |
| 46 | + | ledger: unwrap(ledger), | |
| 47 | + | added: url.searchParams.has("added"), | |
| 48 | + | }; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 52 | + | assertSameOrigin(request); | |
| 53 | + | const user = requireUser(context, request); | |
| 54 | + | const form = await request.formData(); | |
| 55 | + | const dollars = Math.trunc(Number(form.get("amount"))); | |
| 56 | + | const started = await billing.checkout( | |
| 57 | + | user, | |
| 58 | + | params.owner, | |
| 59 | + | Number.isFinite(dollars) ? dollars * 100 : 0, | |
| 60 | + | // Spelled out: a form post arrives at a data address, not the page's. | |
| 61 | + | `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`, | |
| 62 | + | ); | |
| 63 | + | if (!started.ok) return { error: started.error.message }; | |
| 64 | + | throw redirect(started.value.url); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** Millionths of a dollar as dollars, to the cent or finer. */ | |
| 68 | + | function dollars(micros: number, digits = 2): string { | |
| 69 | + | const sign = micros < 0 ? "−" : ""; | |
| 70 | + | return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) { | |
| 74 | + | const { slug, role, account, ledger, added } = loaderData; | |
| 75 | + | const { status } = account; | |
| 76 | + | const paying = useNavigation().state === "submitting"; | |
| 77 | + | const empty = account.balanceMicros <= 0; | |
| 78 | + | return ( | |
| 79 | + | <div className="grid gap-10 lg:grid-cols-[1fr_20rem]"> | |
| 80 | + | <div className="min-w-0"> | |
| 81 | + | <h2 className="font-medium">Agent credit</h2> | |
| 82 | + | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 83 | + | g1t agents that work on this workspace's repositories are paid for from | |
| 84 | + | its credit: what the model cost, plus {account.marginPercent}%. Checks run | |
| 85 | + | free. With no credit, agents do not start. | |
| 86 | + | </p> | |
| 87 | + | ||
| 88 | + | <div | |
| 89 | + | className={`mt-5 rounded-xl border p-5 ${ | |
| 90 | + | empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface" | |
| 91 | + | }`} | |
| 92 | + | > | |
| 93 | + | <p className="text-xs text-muted">Balance</p> | |
| 94 | + | <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight"> | |
| 95 | + | {dollars(account.balanceMicros)} | |
| 96 | + | </p> | |
| 97 | + | {added && ( | |
| 98 | + | <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p> | |
| 99 | + | )} | |
| 100 | + | {!status.enabled ? ( | |
| 101 | + | <p className="mt-3 text-sm text-muted"> | |
| 102 | + | Payments are not set up on this g1t yet, so nothing is charged and | |
| 103 | + | agents are limited to selected accounts. | |
| 104 | + | </p> | |
| 105 | + | ) : role === "owner" ? ( | |
| 106 | + | <Form method="post" className="mt-4"> | |
| 107 | + | <p className="text-sm text-muted">Add credit by card:</p> | |
| 108 | + | <div className="mt-2 flex flex-wrap gap-2"> | |
| 109 | + | {AMOUNTS.map((amount) => ( | |
| 110 | + | <Button | |
| 111 | + | key={amount} | |
| 112 | + | variant={amount === 25 ? "accent" : "quiet"} | |
| 113 | + | type="submit" | |
| 114 | + | name="amount" | |
| 115 | + | value={amount} | |
| 116 | + | disabled={paying} | |
| 117 | + | > | |
| 118 | + | <CreditCard size={14} />${amount} | |
| 119 | + | </Button> | |
| 120 | + | ))} | |
| 121 | + | </div> | |
| 122 | + | {!status.live && ( | |
| 123 | + | <p className="mt-3 text-xs text-faint"> | |
| 124 | + | Payments are in test mode. No real card is charged; use Stripe's | |
| 125 | + | test card 4242 4242 4242 4242 with any future date and any code. | |
| 126 | + | </p> | |
| 127 | + | )} | |
| 128 | + | <div className="mt-2"> | |
| 129 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 130 | + | </div> | |
| 131 | + | </Form> | |
| 132 | + | ) : ( | |
| 133 | + | <p className="mt-3 text-sm text-muted">An owner can add credit.</p> | |
| 134 | + | )} | |
| 135 | + | </div> | |
| 136 | + | ||
| 137 | + | <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3> | |
| 138 | + | <div className="mt-3"> | |
| 139 | + | {ledger.length === 0 ? ( | |
| 140 | + | <EmptyState title="Nothing yet"> | |
| 141 | + | Each agent run and each payment appears here. | |
| 142 | + | </EmptyState> | |
| 143 | + | ) : ( | |
| 144 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 145 | + | {ledger.map((entry) => ( | |
| 146 | + | <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm"> | |
| 147 | + | <div className="min-w-0 grow"> | |
| 148 | + | {entry.repo && entry.number ? ( | |
| 149 | + | <Link | |
| 150 | + | to={`/${entry.repo}/pull/${entry.number}`} | |
| 151 | + | className="block truncate font-medium hover:underline" | |
| 152 | + | > | |
| 153 | + | {entry.description} | |
| 154 | + | </Link> | |
| 155 | + | ) : ( | |
| 156 | + | <p className="truncate font-medium">{entry.description}</p> | |
| 157 | + | )} | |
| 158 | + | <p className="mt-0.5 text-xs text-faint"> | |
| 159 | + | <TimeAgo at={entry.createdAt} /> | |
| 160 | + | {entry.model && ` · ${entry.model}`} | |
| 161 | + | {entry.createdBy && ` · ${entry.createdBy}`} | |
| 162 | + | </p> | |
| 163 | + | </div> | |
| 164 | + | <span | |
| 165 | + | className={`shrink-0 font-mono text-sm tabular-nums ${ | |
| 166 | + | entry.amountMicros > 0 ? "text-accent" : "text-muted" | |
| 167 | + | }`} | |
| 168 | + | > | |
| 169 | + | {entry.amountMicros > 0 && "+"} | |
| 170 | + | {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)} | |
| 171 | + | </span> | |
| 172 | + | </li> | |
| 173 | + | ))} | |
| 174 | + | </ul> | |
| 175 | + | )} | |
| 176 | + | </div> | |
| 177 | + | </div> | |
| 178 | + | ||
| 179 | + | <aside className="space-y-5 text-sm"> | |
| 180 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 181 | + | <h3 className="font-medium">How it is charged</h3> | |
| 182 | + | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted"> | |
| 183 | + | <li> | |
| 184 | + | Each run is charged when it finishes, to the workspace that owns the | |
| 185 | + | repository, whoever assigned the issue. | |
| 186 | + | </li> | |
| 187 | + | <li> | |
| 188 | + | A change, a review, a revision and a catch-up are each a run. The | |
| 189 | + | statement links each to its pull request. | |
| 190 | + | </li> | |
| 191 | + | <li> | |
| 192 | + | Every pull request's session ends with what that run cost before | |
| 193 | + | the margin. | |
| 194 | + | </li> | |
| 195 | + | <li> | |
| 196 | + | Only members of <span className="font-mono text-fg">{slug}</span> can | |
| 197 | + | put agents to work on its repositories. | |
| 198 | + | </li> | |
| 199 | + | </ul> | |
| 200 | + | </section> | |
| 201 | + | </aside> | |
| 202 | + | </div> | |
| 203 | + | ); | |
| 204 | + | } |
| 1 | + | import { CreditCard, KeyRound, LayoutGrid, Plus, Settings, Users } from "lucide-react"; | |
| 2 | + | import { Outlet, data, useRouteLoaderData } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/layout"; | |
| 5 | + | import { Avatar, ButtonLink, Pill, TabLink } from "../../components/ui"; | |
| 6 | + | import { identity } from "../../lib/services.server"; | |
| 7 | + | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 8 | + | ||
| 9 | + | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 10 | + | return [{ title: `${loaderData?.workspace.name ?? params.owner} · g1t` }]; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 14 | + | const workspace = await identity.getWorkspace(params.owner); | |
| 15 | + | if (!workspace) throw data(null, { status: 404 }); | |
| 16 | + | return { workspace, role: roleIn(getViewer(context), workspace.slug) }; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) { | |
| 20 | + | const { workspace, role } = loaderData; | |
| 21 | + | const base = `/${workspace.slug}`; | |
| 22 | + | const signedIn = useRouteLoaderData("root")?.user != null; | |
| 23 | + | return ( | |
| 24 | + | <> | |
| 25 | + | {/* The workspace's own header band, under the site header. */} | |
| 26 | + | <div className="border-b border-line bg-surface/60"> | |
| 27 | + | <div className="mx-auto max-w-6xl px-4 pt-8"> | |
| 28 | + | <div className="flex flex-wrap items-center gap-4"> | |
| 29 | + | <Avatar name={workspace.slug} size={52} square /> | |
| 30 | + | <div className="min-w-0 grow"> | |
| 31 | + | <div className="flex flex-wrap items-center gap-2.5"> | |
| 32 | + | <h1 className="truncate text-2xl font-semibold tracking-tight"> | |
| 33 | + | {workspace.name} | |
| 34 | + | </h1> | |
| 35 | + | {role && <Pill>{role}</Pill>} | |
| 36 | + | </div> | |
| 37 | + | <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p> | |
| 38 | + | </div> | |
| 39 | + | {role && ( | |
| 40 | + | <ButtonLink to={`/new?workspace=${workspace.slug}`}> | |
| 41 | + | <Plus size={15} /> | |
| 42 | + | New repository | |
| 43 | + | </ButtonLink> | |
| 44 | + | )} | |
| 45 | + | </div> | |
| 46 | + | {workspace.description && ( | |
| 47 | + | <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p> | |
| 48 | + | )} | |
| 49 | + | {signedIn ? ( | |
| 50 | + | <div className="pb-8" /> | |
| 51 | + | ) : ( | |
| 52 | + | <nav className="mt-6 flex flex-wrap gap-x-6"> | |
| 53 | + | <TabLink to={base} end icon={<LayoutGrid size={15} />}> | |
| 54 | + | Overview | |
| 55 | + | </TabLink> | |
| 56 | + | {role && ( | |
| 57 | + | <> | |
| 58 | + | <TabLink | |
| 59 | + | to={`${base}/-/people`} | |
| 60 | + | icon={<Users size={15} />} | |
| 61 | + | count={workspace.memberCount} | |
| 62 | + | > | |
| 63 | + | People | |
| 64 | + | </TabLink> | |
| 65 | + | <TabLink to={`${base}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 66 | + | Access tokens | |
| 67 | + | </TabLink> | |
| 68 | + | <TabLink to={`${base}/-/billing`} icon={<CreditCard size={15} />}> | |
| 69 | + | Billing | |
| 70 | + | </TabLink> | |
| 71 | + | </> | |
| 72 | + | )} | |
| 73 | + | {role === "owner" && ( | |
| 74 | + | <TabLink to={`${base}/-/settings`} icon={<Settings size={15} />}> | |
| 75 | + | Settings | |
| 76 | + | </TabLink> | |
| 77 | + | )} | |
| 78 | + | </nav> | |
| 79 | + | )} | |
| 80 | + | </div> | |
| 81 | + | </div> | |
| 82 | + | <div className="mx-auto max-w-6xl px-4 py-8"> | |
| 83 | + | <Outlet /> | |
| 84 | + | </div> | |
| 85 | + | </> | |
| 86 | + | ); | |
| 87 | + | } |
| 1 | + | import { Form, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/new"; | |
| 4 | + | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 5 | + | import { identity } from "../../lib/services.server"; | |
| 6 | + | import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server"; | |
| 7 | + | ||
| 8 | + | export function meta({}: Route.MetaArgs) { | |
| 9 | + | return [{ title: "New workspace · g1t" }]; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export function loader({ request, context }: Route.LoaderArgs) { | |
| 13 | + | const user = requireUser(context, request); | |
| 14 | + | return { user, first: (user.workspaces ?? []).length === 0 }; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 18 | + | assertSameOrigin(request); | |
| 19 | + | const user = requireUser(context, request); | |
| 20 | + | const form = await request.formData(); | |
| 21 | + | const result = await identity.createWorkspace( | |
| 22 | + | user, | |
| 23 | + | String(form.get("slug") ?? ""), | |
| 24 | + | String(form.get("displayName") ?? ""), | |
| 25 | + | ); | |
| 26 | + | if (!result.ok) return { error: result.error.message }; | |
| 27 | + | // Someone sent here on their way elsewhere carries on to it. | |
| 28 | + | const next = nextPath(request); | |
| 29 | + | throw redirect(next === "/" ? `/${result.value.slug}` : next); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export default function NewWorkspace({ | |
| 33 | + | loaderData, | |
| 34 | + | actionData, | |
| 35 | + | }: Route.ComponentProps) { | |
| 36 | + | const { user, first } = loaderData; | |
| 37 | + | return ( | |
| 38 | + | <main className="mx-auto max-w-lg px-4 py-12"> | |
| 39 | + | <h1 className="text-xl font-semibold"> | |
| 40 | + | {first ? "Create your workspace" : "New workspace"} | |
| 41 | + | </h1> | |
| 42 | + | <p className="mt-2 text-sm text-muted"> | |
| 43 | + | {first && "Everything on g1t lives in a workspace, so this comes first. "} | |
| 44 | + | A workspace holds repositories, the people who work on them and the | |
| 45 | + | access tokens that automate them, and is the first part of every | |
| 46 | + | address: <span className="font-mono text-fg">g1t.sh/workspace/repo</span>. | |
| 47 | + | Use one for yourself, and one for each team or company you work with. | |
| 48 | + | </p> | |
| 49 | + | {!user.verified && ( | |
| 50 | + | <p className="mt-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-sm"> | |
| 51 | + | Confirm your email address first. We sent you a link. | |
| 52 | + | </p> | |
| 53 | + | )} | |
| 54 | + | <Form method="post" className="mt-8 space-y-4"> | |
| 55 | + | <Field | |
| 56 | + | label="Name in URLs" | |
| 57 | + | hint="Lowercase letters, digits and single hyphens. It cannot be changed later." | |
| 58 | + | > | |
| 59 | + | <div className="flex items-center gap-2 font-mono text-sm"> | |
| 60 | + | <span className="text-muted">g1t.sh/</span> | |
| 61 | + | <Input | |
| 62 | + | name="slug" | |
| 63 | + | required | |
| 64 | + | autoFocus | |
| 65 | + | maxLength={39} | |
| 66 | + | defaultValue={first ? user.username : ""} | |
| 67 | + | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" | |
| 68 | + | /> | |
| 69 | + | </div> | |
| 70 | + | </Field> | |
| 71 | + | <Field label="Display name (optional)"> | |
| 72 | + | <Input name="displayName" maxLength={80} /> | |
| 73 | + | </Field> | |
| 74 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 75 | + | <Button type="submit">Create workspace</Button> | |
| 76 | + | </Form> | |
| 77 | + | </main> | |
| 78 | + | ); | |
| 79 | + | } |
| 1 | + | import { ArrowRight, KeyRound } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/overview"; | |
| 5 | + | import { RepoList } from "../../components/repo-list"; | |
| 6 | + | import { Avatar, ButtonLink, CopyLine, Pill, TimeAgo } from "../../components/ui"; | |
| 7 | + | import { PullIcon } from "../../components/work"; | |
| 8 | + | import { identity, repos as reposApi, work } from "../../lib/services.server"; | |
| 9 | + | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 10 | + | ||
| 11 | + | /** Repositories whose open issues and pull requests are counted. */ | |
| 12 | + | const MAX_COUNTED = 30; | |
| 13 | + | /** Repositories whose pull requests are listed under "In progress". */ | |
| 14 | + | const MAX_LISTED = 8; | |
| 15 | + | const MAX_PULLS = 8; | |
| 16 | + | const MAX_FACES = 8; | |
| 17 | + | ||
| 18 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 19 | + | const viewer = getViewer(context); | |
| 20 | + | const slug = params.owner.toLowerCase(); | |
| 21 | + | const role = roleIn(viewer, slug); | |
| 22 | + | const [repos, members] = await Promise.all([ | |
| 23 | + | reposApi.list(viewer, { namespace: slug }), | |
| 24 | + | role ? identity.listMembers(slug, viewer) : null, | |
| 25 | + | ]); | |
| 26 | + | ||
| 27 | + | const counted = repos.slice(0, MAX_COUNTED); | |
| 28 | + | const counts = await Promise.all( | |
| 29 | + | counted.map((repo) => work.counts({ namespace: repo.namespace, name: repo.name }, viewer)), | |
| 30 | + | ); | |
| 31 | + | const open: Record<string, { issues: number; pulls: number }> = {}; | |
| 32 | + | counted.forEach((repo, i) => { | |
| 33 | + | const result = counts[i]; | |
| 34 | + | if (result.ok) open[repo.id] = result.value; | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | const busy = counted.filter((repo) => (open[repo.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED); | |
| 38 | + | const lists = await Promise.all( | |
| 39 | + | busy.map((repo) => | |
| 40 | + | work.listPulls({ namespace: repo.namespace, name: repo.name }, viewer, "open"), | |
| 41 | + | ), | |
| 42 | + | ); | |
| 43 | + | const pulls = lists | |
| 44 | + | .flatMap((result, i) => | |
| 45 | + | result.ok ? result.value.map((pull) => ({ pull, repo: busy[i].name })) : [], | |
| 46 | + | ) | |
| 47 | + | .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt)) | |
| 48 | + | .slice(0, MAX_PULLS); | |
| 49 | + | ||
| 50 | + | return { slug, role, repos, open, pulls, members: members?.ok ? members.value : null }; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | function Stat({ value, label }: { value: number; label: string }) { | |
| 54 | + | return ( | |
| 55 | + | <div className="rounded-xl border border-line bg-surface px-4 py-3"> | |
| 56 | + | <p className="text-xl font-semibold tabular-nums tracking-tight">{value}</p> | |
| 57 | + | <p className="text-xs text-muted">{label}</p> | |
| 58 | + | </div> | |
| 59 | + | ); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** What a member sees in a workspace that has no repositories yet. */ | |
| 63 | + | function GetStarted({ slug }: { slug: string }) { | |
| 64 | + | return ( | |
| 65 | + | <div className="rounded-xl border border-line bg-surface p-6"> | |
| 66 | + | <h2 className="font-medium">Add the first repository</h2> | |
| 67 | + | <p className="mt-1.5 max-w-xl text-sm text-muted"> | |
| 68 | + | A repository holds code, the issues that say what should change, and | |
| 69 | + | the pull requests agents and people make for them. | |
| 70 | + | </p> | |
| 71 | + | <div className="mt-5 flex flex-wrap gap-3"> | |
| 72 | + | <ButtonLink to={`/new?workspace=${slug}`}>Create or import a repository</ButtonLink> | |
| 73 | + | </div> | |
| 74 | + | <p className="mt-6 text-sm text-muted">Or push one you already have:</p> | |
| 75 | + | <div className="mt-2 max-w-xl"> | |
| 76 | + | <CopyLine prompt text={`git push https://g1t.sh/${slug}/my-project.git main`} /> | |
| 77 | + | </div> | |
| 78 | + | </div> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) { | |
| 83 | + | const { slug, role, repos, open, pulls, members } = loaderData; | |
| 84 | + | const totals = Object.values(open).reduce( | |
| 85 | + | (sum, counts) => ({ | |
| 86 | + | issues: sum.issues + counts.issues, | |
| 87 | + | pulls: sum.pulls + counts.pulls, | |
| 88 | + | }), | |
| 89 | + | { issues: 0, pulls: 0 }, | |
| 90 | + | ); | |
| 91 | + | return ( | |
| 92 | + | <div className="grid gap-10 lg:grid-cols-[1fr_18rem]"> | |
| 93 | + | <div className="min-w-0 space-y-10"> | |
| 94 | + | {repos.length === 0 && role ? ( | |
| 95 | + | <GetStarted slug={slug} /> | |
| 96 | + | ) : ( | |
| 97 | + | <> | |
| 98 | + | <div className="grid grid-cols-3 gap-3"> | |
| 99 | + | <Stat | |
| 100 | + | value={repos.length} | |
| 101 | + | label={repos.length === 1 ? "Repository" : "Repositories"} | |
| 102 | + | /> | |
| 103 | + | <Stat value={totals.issues} label="Open issues" /> | |
| 104 | + | <Stat value={totals.pulls} label="Pull requests in progress" /> | |
| 105 | + | </div> | |
| 106 | + | ||
| 107 | + | {pulls.length > 0 && ( | |
| 108 | + | <section> | |
| 109 | + | <h2 className="text-sm font-medium text-muted">In progress</h2> | |
| 110 | + | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 111 | + | {pulls.map(({ pull, repo }) => ( | |
| 112 | + | <li key={pull.id}> | |
| 113 | + | <Link | |
| 114 | + | prefetch="intent" | |
| 115 | + | to={`/${slug}/${repo}/pull/${pull.number}`} | |
| 116 | + | className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised" | |
| 117 | + | > | |
| 118 | + | <PullIcon status={pull.status} /> | |
| 119 | + | <span className="min-w-0 grow"> | |
| 120 | + | <span className="block truncate font-medium">{pull.title}</span> | |
| 121 | + | <span className="font-mono text-xs text-muted"> | |
| 122 | + | {repo}#{pull.number} | |
| 123 | + | {pull.issue != null && ` · for #${pull.issue}`} ·{" "} | |
| 124 | + | {pull.author.username} | |
| 125 | + | </span> | |
| 126 | + | </span> | |
| 127 | + | <span className="hidden shrink-0 text-xs text-muted sm:block"> | |
| 128 | + | {pull.status === "draft" ? "Being worked on" : "Ready for review"} | |
| 129 | + | </span> | |
| 130 | + | <span className="w-14 shrink-0 text-right text-xs text-faint"> | |
| 131 | + | <TimeAgo at={pull.updatedAt} /> | |
| 132 | + | </span> | |
| 133 | + | </Link> | |
| 134 | + | </li> | |
| 135 | + | ))} | |
| 136 | + | </ul> | |
| 137 | + | </section> | |
| 138 | + | )} | |
| 139 | + | ||
| 140 | + | <section> | |
| 141 | + | <h2 className="text-sm font-medium text-muted">Repositories</h2> | |
| 142 | + | <RepoList repos={repos} open={open} /> | |
| 143 | + | </section> | |
| 144 | + | </> | |
| 145 | + | )} | |
| 146 | + | </div> | |
| 147 | + | ||
| 148 | + | <aside className="space-y-6"> | |
| 149 | + | {members ? ( | |
| 150 | + | <section> | |
| 151 | + | <div className="flex items-center justify-between"> | |
| 152 | + | <h2 className="text-sm font-medium">People</h2> | |
| 153 | + | <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg"> | |
| 154 | + | {role === "owner" ? "Manage" : "See all"} | |
| 155 | + | </Link> | |
| 156 | + | </div> | |
| 157 | + | <ul className="mt-3 space-y-1.5"> | |
| 158 | + | {members.slice(0, MAX_FACES).map((member) => ( | |
| 159 | + | <li key={member.username} className="flex items-center gap-2 text-sm"> | |
| 160 | + | <Avatar name={member.username} /> | |
| 161 | + | <span className="grow truncate font-mono">{member.username}</span> | |
| 162 | + | {member.role === "owner" && <Pill>owner</Pill>} | |
| 163 | + | </li> | |
| 164 | + | ))} | |
| 165 | + | </ul> | |
| 166 | + | {members.length > MAX_FACES && ( | |
| 167 | + | <p className="mt-2 text-xs text-faint"> | |
| 168 | + | and {members.length - MAX_FACES} more | |
| 169 | + | </p> | |
| 170 | + | )} | |
| 171 | + | </section> | |
| 172 | + | ) : ( | |
| 173 | + | <p className="text-sm text-muted"> | |
| 174 | + | Members of this workspace create repositories here and merge pull | |
| 175 | + | requests into them. Anyone can open an issue or a pull request on | |
| 176 | + | a public repository. | |
| 177 | + | </p> | |
| 178 | + | )} | |
| 179 | + | ||
| 180 | + | {role && ( | |
| 181 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 182 | + | <h2 className="flex items-center gap-2 font-medium"> | |
| 183 | + | <KeyRound size={15} className="text-faint" /> | |
| 184 | + | Automate without a service account | |
| 185 | + | </h2> | |
| 186 | + | <p className="mt-1.5 text-sm text-muted"> | |
| 187 | + | A workspace has access tokens of its own for CI, integrations | |
| 188 | + | and agents. They act as the workspace, not as a person. | |
| 189 | + | </p> | |
| 190 | + | <Link | |
| 191 | + | prefetch="intent" | |
| 192 | + | to={`/${slug}/-/tokens`} | |
| 193 | + | className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline" | |
| 194 | + | > | |
| 195 | + | Access tokens <ArrowRight size={13} /> | |
| 196 | + | </Link> | |
| 197 | + | </section> | |
| 198 | + | )} | |
| 199 | + | </aside> | |
| 200 | + | </div> | |
| 201 | + | ); | |
| 202 | + | } |
| 1 | + | import { Form } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/people"; | |
| 4 | + | import { Avatar, Button, ErrorText, Field, Input, Pill } from "../../components/ui"; | |
| 5 | + | import { identity } from "../../lib/services.server"; | |
| 6 | + | import { | |
| 7 | + | assertSameOrigin, | |
| 8 | + | getViewer, | |
| 9 | + | requireUser, | |
| 10 | + | roleIn, | |
| 11 | + | unwrap, | |
| 12 | + | } from "../../lib/session.server"; | |
| 13 | + | ||
| 14 | + | export function meta({ params }: Route.MetaArgs) { | |
| 15 | + | return [{ title: `People · ${params.owner} · g1t` }]; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 19 | + | const viewer = getViewer(context); | |
| 20 | + | return { | |
| 21 | + | role: roleIn(viewer, params.owner), | |
| 22 | + | members: unwrap(await identity.listMembers(params.owner, viewer)), | |
| 23 | + | }; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 27 | + | assertSameOrigin(request); | |
| 28 | + | const user = requireUser(context, request); | |
| 29 | + | const form = await request.formData(); | |
| 30 | + | const username = String(form.get("member") ?? ""); | |
| 31 | + | const result = | |
| 32 | + | form.get("action") === "remove" | |
| 33 | + | ? await identity.removeMember(user, params.owner, username) | |
| 34 | + | : await identity.addMember(user, params.owner, username); | |
| 35 | + | return result.ok ? null : { error: result.error.message }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | export default function WorkspacePeople({ loaderData, actionData }: Route.ComponentProps) { | |
| 39 | + | const { role, members } = loaderData; | |
| 40 | + | return ( | |
| 41 | + | <div className="max-w-2xl"> | |
| 42 | + | <h2 className="font-medium">People</h2> | |
| 43 | + | <p className="mt-1 text-sm text-muted"> | |
| 44 | + | Members create repositories, push, manage issues and merge pull | |
| 45 | + | requests. Owners also manage people, access tokens and the | |
| 46 | + | workspace's details. | |
| 47 | + | </p> | |
| 48 | + | <ul className="mt-5 divide-y divide-line rounded-xl border border-line"> | |
| 49 | + | {members.map((member) => ( | |
| 50 | + | <li key={member.username} className="flex items-center gap-3 px-4 py-3"> | |
| 51 | + | <Avatar name={member.username} size={28} /> | |
| 52 | + | <span className="grow font-mono text-sm">{member.username}</span> | |
| 53 | + | <Pill>{member.role}</Pill> | |
| 54 | + | {role === "owner" && member.role !== "owner" && ( | |
| 55 | + | <Form method="post"> | |
| 56 | + | <input type="hidden" name="action" value="remove" /> | |
| 57 | + | <input type="hidden" name="member" value={member.username} /> | |
| 58 | + | <Button variant="quiet" type="submit"> | |
| 59 | + | Remove | |
| 60 | + | </Button> | |
| 61 | + | </Form> | |
| 62 | + | )} | |
| 63 | + | </li> | |
| 64 | + | ))} | |
| 65 | + | </ul> | |
| 66 | + | {role === "owner" && ( | |
| 67 | + | <Form method="post" className="mt-6 flex items-end gap-3"> | |
| 68 | + | <div className="grow"> | |
| 69 | + | <Field label="Add a member" hint="Their g1t username. They join as a member."> | |
| 70 | + | <Input name="member" required maxLength={39} /> | |
| 71 | + | </Field> | |
| 72 | + | </div> | |
| 73 | + | <div className="pb-6"> | |
| 74 | + | <Button type="submit">Add</Button> | |
| 75 | + | </div> | |
| 76 | + | </Form> | |
| 77 | + | )} | |
| 78 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 79 | + | </div> | |
| 80 | + | ); | |
| 81 | + | } |
| 1 | + | import { Form, data, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/settings"; | |
| 4 | + | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 5 | + | import { identity } from "../../lib/services.server"; | |
| 6 | + | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 7 | + | ||
| 8 | + | export function meta({ params }: Route.MetaArgs) { | |
| 9 | + | return [{ title: `Settings · ${params.owner} · g1t` }]; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 13 | + | // Owners only; to anyone else the page does not exist. | |
| 14 | + | if (roleIn(getViewer(context), params.owner) !== "owner") { | |
| 15 | + | throw data(null, { status: 404 }); | |
| 16 | + | } | |
| 17 | + | const workspace = await identity.getWorkspace(params.owner); | |
| 18 | + | if (!workspace) throw data(null, { status: 404 }); | |
| 19 | + | return { workspace }; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 23 | + | assertSameOrigin(request); | |
| 24 | + | const user = requireUser(context, request); | |
| 25 | + | const form = await request.formData(); | |
| 26 | + | const result = await identity.updateWorkspace(user, params.owner, { | |
| 27 | + | name: String(form.get("displayName") ?? ""), | |
| 28 | + | description: String(form.get("description") ?? ""), | |
| 29 | + | }); | |
| 30 | + | if (!result.ok) return { error: result.error.message }; | |
| 31 | + | throw redirect(`/${result.value.slug}`); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 35 | + | const { workspace } = loaderData; | |
| 36 | + | return ( | |
| 37 | + | <div className="max-w-lg"> | |
| 38 | + | <h2 className="font-medium">Workspace details</h2> | |
| 39 | + | <Form method="post" className="mt-5 space-y-4"> | |
| 40 | + | <Field label="Display name"> | |
| 41 | + | <Input name="displayName" maxLength={80} defaultValue={workspace.name} /> | |
| 42 | + | </Field> | |
| 43 | + | <Field label="Description" hint="One line, shown at the top of the workspace's page."> | |
| 44 | + | <Input | |
| 45 | + | name="description" | |
| 46 | + | maxLength={160} | |
| 47 | + | defaultValue={workspace.description ?? ""} | |
| 48 | + | /> | |
| 49 | + | </Field> | |
| 50 | + | <Field | |
| 51 | + | label="Name in URLs" | |
| 52 | + | hint="This cannot be changed: repository addresses and clones depend on it." | |
| 53 | + | > | |
| 54 | + | <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p> | |
| 55 | + | </Field> | |
| 56 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 57 | + | <Button type="submit">Save</Button> | |
| 58 | + | </Form> | |
| 59 | + | </div> | |
| 60 | + | ); | |
| 61 | + | } |
| 1 | + | import { Form } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/tokens"; | |
| 4 | + | import { | |
| 5 | + | Button, | |
| 6 | + | CopyLine, | |
| 7 | + | EmptyState, | |
| 8 | + | ErrorText, | |
| 9 | + | Field, | |
| 10 | + | Input, | |
| 11 | + | TimeAgo, | |
| 12 | + | } from "../../components/ui"; | |
| 13 | + | import { identity } from "../../lib/services.server"; | |
| 14 | + | import { | |
| 15 | + | assertSameOrigin, | |
| 16 | + | getViewer, | |
| 17 | + | requireUser, | |
| 18 | + | roleIn, | |
| 19 | + | unwrap, | |
| 20 | + | } from "../../lib/session.server"; | |
| 21 | + | ||
| 22 | + | export function meta({ params }: Route.MetaArgs) { | |
| 23 | + | return [{ title: `Access tokens · ${params.owner} · g1t` }]; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 27 | + | const viewer = getViewer(context); | |
| 28 | + | return { | |
| 29 | + | slug: params.owner.toLowerCase(), | |
| 30 | + | role: roleIn(viewer, params.owner), | |
| 31 | + | tokens: unwrap(await identity.listWorkspaceTokens(params.owner, viewer)), | |
| 32 | + | }; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 36 | + | assertSameOrigin(request); | |
| 37 | + | const user = requireUser(context, request); | |
| 38 | + | const form = await request.formData(); | |
| 39 | + | if (form.get("action") === "delete") { | |
| 40 | + | const removed = await identity.removeWorkspaceToken( | |
| 41 | + | user, | |
| 42 | + | params.owner, | |
| 43 | + | String(form.get("id") ?? ""), | |
| 44 | + | ); | |
| 45 | + | return { token: null, error: removed.ok ? null : removed.error.message }; | |
| 46 | + | } | |
| 47 | + | const created = await identity.createWorkspaceToken( | |
| 48 | + | user, | |
| 49 | + | params.owner, | |
| 50 | + | String(form.get("label") ?? ""), | |
| 51 | + | ); | |
| 52 | + | return created.ok | |
| 53 | + | ? { token: created.value, error: null } | |
| 54 | + | : { token: null, error: created.error.message }; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | export default function WorkspaceTokens({ loaderData, actionData }: Route.ComponentProps) { | |
| 58 | + | const { slug, role, tokens } = loaderData; | |
| 59 | + | const created = actionData?.token; | |
| 60 | + | return ( | |
| 61 | + | <div className="grid gap-10 lg:grid-cols-[1fr_20rem]"> | |
| 62 | + | <div className="min-w-0"> | |
| 63 | + | <h2 className="font-medium">Access tokens</h2> | |
| 64 | + | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 65 | + | These belong to <span className="font-mono text-fg">{slug}</span>, | |
| 66 | + | not to a person. Use them for CI, integrations and agents that work | |
| 67 | + | for the whole team, instead of creating an account to share. | |
| 68 | + | </p> | |
| 69 | + | ||
| 70 | + | {created && ( | |
| 71 | + | <div className="mt-5 rounded-xl border border-accent/40 bg-surface p-4"> | |
| 72 | + | <p className="text-sm"> | |
| 73 | + | <span className="font-medium">{created.info.name}</span> is ready. Copy | |
| 74 | + | it now; it will not be shown again. | |
| 75 | + | </p> | |
| 76 | + | <div className="mt-3"> | |
| 77 | + | <CopyLine text={created.token} /> | |
| 78 | + | </div> | |
| 79 | + | </div> | |
| 80 | + | )} | |
| 81 | + | ||
| 82 | + | <div className="mt-5"> | |
| 83 | + | {tokens.length === 0 ? ( | |
| 84 | + | <EmptyState title="No access tokens yet"> | |
| 85 | + | {role === "owner" | |
| 86 | + | ? "Create one below and give it to whatever needs to act for this workspace." | |
| 87 | + | : "An owner can create one."} | |
| 88 | + | </EmptyState> | |
| 89 | + | ) : ( | |
| 90 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 91 | + | {tokens.map((token) => ( | |
| 92 | + | <li key={token.id} className="flex items-center gap-4 px-4 py-3"> | |
| 93 | + | <div className="min-w-0 grow"> | |
| 94 | + | <p className="truncate text-sm font-medium">{token.name}</p> | |
| 95 | + | <p className="mt-0.5 text-xs text-faint"> | |
| 96 | + | Created <TimeAgo at={token.createdAt} /> | |
| 97 | + | {token.createdBy ? ( | |
| 98 | + | <> | |
| 99 | + | {" "} | |
| 100 | + | by <span className="font-mono">{token.createdBy}</span> | |
| 101 | + | </> | |
| 102 | + | ) : ( | |
| 103 | + | " by someone who has since left g1t" | |
| 104 | + | )}{" "} | |
| 105 | + | ·{" "} | |
| 106 | + | {token.lastUsedAt ? ( | |
| 107 | + | <> | |
| 108 | + | last used <TimeAgo at={token.lastUsedAt} /> | |
| 109 | + | </> | |
| 110 | + | ) : ( | |
| 111 | + | "never used" | |
| 112 | + | )} | |
| 113 | + | </p> | |
| 114 | + | </div> | |
| 115 | + | {role === "owner" && ( | |
| 116 | + | <Form method="post"> | |
| 117 | + | <input type="hidden" name="action" value="delete" /> | |
| 118 | + | <input type="hidden" name="id" value={token.id} /> | |
| 119 | + | <Button variant="quiet" type="submit"> | |
| 120 | + | Delete | |
| 121 | + | </Button> | |
| 122 | + | </Form> | |
| 123 | + | )} | |
| 124 | + | </li> | |
| 125 | + | ))} | |
| 126 | + | </ul> | |
| 127 | + | )} | |
| 128 | + | </div> | |
| 129 | + | ||
| 130 | + | {role === "owner" ? ( | |
| 131 | + | <Form method="post" className="mt-6 flex items-end gap-3"> | |
| 132 | + | <div className="grow"> | |
| 133 | + | <Field label="New token" hint="Name it after what will use it."> | |
| 134 | + | <Input name="label" required maxLength={100} placeholder="deploy pipeline" /> | |
| 135 | + | </Field> | |
| 136 | + | </div> | |
| 137 | + | <div className="pb-6"> | |
| 138 | + | <Button type="submit">Create token</Button> | |
| 139 | + | </div> | |
| 140 | + | </Form> | |
| 141 | + | ) : ( | |
| 142 | + | <p className="mt-4 text-sm text-muted"> | |
| 143 | + | Only owners can create or delete a workspace's tokens. | |
| 144 | + | </p> | |
| 145 | + | )} | |
| 146 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 147 | + | </div> | |
| 148 | + | ||
| 149 | + | <aside className="space-y-5 text-sm"> | |
| 150 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 151 | + | <h3 className="font-medium">What a token can do</h3> | |
| 152 | + | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted"> | |
| 153 | + | <li> | |
| 154 | + | Everything a member can, in this workspace only: push, open and | |
| 155 | + | merge pull requests, manage issues. | |
| 156 | + | </li> | |
| 157 | + | <li> | |
| 158 | + | It acts as <span className="font-mono text-fg">{slug}</span>, so | |
| 159 | + | what it does is shown as the workspace's doing. | |
| 160 | + | </li> | |
| 161 | + | <li>It keeps working when the person who made it leaves.</li> | |
| 162 | + | <li>It cannot manage people, tokens or other workspaces.</li> | |
| 163 | + | </ul> | |
| 164 | + | </section> | |
| 165 | + | <section> | |
| 166 | + | <h3 className="font-medium">Using one</h3> | |
| 167 | + | <p className="mt-2 text-muted">With git, as the password:</p> | |
| 168 | + | <div className="mt-2"> | |
| 169 | + | <CopyLine | |
| 170 | + | prompt | |
| 171 | + | text={`git clone https://${slug}:$G1T_TOKEN@g1t.sh/${slug}/<repo>.git`} | |
| 172 | + | /> | |
| 173 | + | </div> | |
| 174 | + | <p className="mt-4 text-muted">With the API and the MCP server:</p> | |
| 175 | + | <div className="mt-2"> | |
| 176 | + | <CopyLine | |
| 177 | + | prompt | |
| 178 | + | text={'curl -H "Authorization: Bearer $G1T_TOKEN" https://api.g1t.sh/user'} | |
| 179 | + | /> | |
| 180 | + | </div> | |
| 181 | + | </section> | |
| 182 | + | </aside> | |
| 183 | + | </div> | |
| 184 | + | ); | |
| 185 | + | } |
| 13 | 13 | }, | |
| 14 | 14 | "dependencies": { | |
| 15 | 15 | "@g1t/contracts": "*", | |
| 16 | + | "@g1t/theme": "*", | |
| 16 | 17 | "class-variance-authority": "^0.7.1", | |
| 17 | 18 | "clsx": "^2.1.1", | |
| 18 | 19 | "isbot": "^5.1.36", | |
| 19 | 20 | "lucide-react": "^1.49.0", | |
| 21 | + | "mdast-util-find-and-replace": "^3.0.2", | |
| 20 | 22 | "radix-ui": "^1.6.7", | |
| 21 | 23 | "react": "^19.2.8", | |
| 22 | 24 | "react-dom": "^19.2.8", | |
| 23 | 25 | "react-markdown": "^10.1.0", | |
| 24 | 26 | "react-router": "^8.4.0", | |
| 27 | + | "rehype-raw": "^7.0.0", | |
| 28 | + | "rehype-sanitize": "^6.0.0", | |
| 25 | 29 | "remark-gfm": "^4.0.1", | |
| 26 | 30 | "shiki": "^4.5.0", | |
| 27 | 31 | "tailwind-merge": "^3.7.0", | |
| 28 | − | "@g1t/theme": "*" | |
| 32 | + | "unist-util-visit": "^5.1.0" | |
| 29 | 33 | }, | |
| 30 | 34 | "devDependencies": { | |
| 31 | 35 | "@cloudflare/vite-plugin": "^1.62.4", |
| 15 | 15 | 1. **Start a sign-in.** | |
| 16 | 16 | ||
| 17 | 17 | ```sh | |
| 18 | − | curl -X POST https://api.g1t.sh/v1/device/code \ | |
| 18 | + | curl -X POST https://api.g1t.sh/device/code \ | |
| 19 | 19 | -H "Content-Type: application/json" \ | |
| 20 | 20 | -d '{"client_name": "Claude Code"}' | |
| 21 | 21 | ``` | |
| 37 | 37 | 3. **Collect the token.** Poll every `interval` seconds, not faster: | |
| 38 | 38 | ||
| 39 | 39 | ```sh | |
| 40 | − | curl -X POST https://api.g1t.sh/v1/device/token \ | |
| 40 | + | curl -X POST https://api.g1t.sh/device/token \ | |
| 41 | 41 | -H "Content-Type: application/json" \ | |
| 42 | 42 | -d '{"device_code": "DEVICE_CODE"}' | |
| 43 | 43 | ``` | |
| 61 | 61 | person in through their browser instead (in Claude Code: `/mcp`, then | |
| 62 | 62 | choose g1t). The MCP server always needs one or the other. | |
| 63 | 63 | ||
| 64 | − | 5. **Create a workspace** if `GET /v1/user` shows none. A workspace owns | |
| 64 | + | 5. **Create a workspace** if `GET /user` shows none. A workspace owns | |
| 65 | 65 | repositories and is the first part of their address. Ask the person what | |
| 66 | 66 | to call it; their username is a sensible default. | |
| 67 | 67 | ||
| 68 | 68 | ```sh | |
| 69 | − | curl -X POST https://api.g1t.sh/v1/workspaces \ | |
| 69 | + | curl -X POST https://api.g1t.sh/workspaces \ | |
| 70 | 70 | -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \ | |
| 71 | 71 | -d '{"slug": "WORKSPACE"}' | |
| 72 | 72 | ``` | |
| 73 | 73 | ||
| 74 | − | 6. **Push a repository.** Pushing to a repository that does not exist, in a | |
| 74 | + | 6. **Or import one.** `POST /repos` with `name` and | |
| 75 | + | `import_url` (the https address of a public repository, such as one on | |
| 76 | + | GitHub) copies its default branch. | |
| 77 | + | ||
| 78 | + | 7. **Push a repository.** Pushing to a repository that does not exist, in a | |
| 75 | 79 | workspace the person belongs to, creates it, public by default. | |
| 76 | 80 | ||
| 77 | 81 | ```sh | |
| 88 | 92 | ||
| 89 | 93 | Issues and pull requests are addressed by repository and number, and share | |
| 90 | 94 | one sequence of numbers: `#12` is one or the other. Below, `{repo}` stands | |
| 91 | − | for `/v1/repos/{owner}/{name}`. | |
| 95 | + | for `/repos/{owner}/{name}`. | |
| 92 | 96 | ||
| 93 | 97 | - **Find work:** `GET {repo}/issues?state=open`, optionally `&label=bug`. | |
| 94 | 98 | - **Open an issue:** `POST {repo}/issues` with `title`, `body`, and | |
| 112 | 116 | - **Mark it ready:** `POST {repo}/pulls/{number}/ready` with `summary`, which | |
| 113 | 117 | becomes the pull request's description. | |
| 114 | 118 | - **See what a pull request changes:** `GET {repo}/pulls/{number}/changes`. | |
| 119 | + | - **Before going far**, read `overlaps` on `GET {repo}/pulls/{number}`: | |
| 120 | + | other pull requests in progress changing the same files. `behind` says | |
| 121 | + | whether main has moved since; if so, pull main into the fork and push. | |
| 115 | 122 | - **Checks:** once a pull request is ready, g1t runs the issue's `checks` | |
| 116 | 123 | against it in a clean sandbox. `GET {repo}/pulls/{number}` returns | |
| 117 | 124 | `checks.results`, each with `passed` and `output`. If they failed, push a | |
| 130 | 137 | `https://g1t.sh/{owner}/{name}.git` into the fork, push, and merge again. | |
| 131 | 138 | ||
| 132 | 139 | Every one of these is also an MCP tool: `list_issues`, `get_issue`, | |
| 133 | − | `create_issue`, `update_issue`, `close_issue`, `reopen_issue`, | |
| 140 | + | `create_issue`, `update_issue`, `close_issue`, `reopen_issue`, `assign_issue`, `plan_work`, `get_plan`, `apply_plan`, | |
| 134 | 141 | `list_labels`, `add_comment`, `list_pull_requests`, `get_pull_request`, | |
| 135 | 142 | `create_pull_request`, `record_session`, `read_session`, | |
| 136 | 143 | `mark_pull_request_ready`, `close_pull_request`, | |
| 137 | 144 | `get_pull_request_changes`, `review_pull_request`, `merge_pull_request`, and `list_repos`, | |
| 138 | − | `get_repo`, `create_repo`, `list_events`, `create_workspace`, `whoami`. | |
| 145 | + | `get_repo`, `create_repo`, `update_repo`, `get_repo_settings`, `update_repo_settings`, `list_events`, `create_workspace`, `whoami`. | |
| 139 | 146 | MCP tools take the repository as `repo`, written `owner/name`. | |
| 140 | 147 | ||
| 141 | 148 | ## Facts |
| 8 | 8 | REPOS: ServiceBinding & { fetch(request: Request): Promise<Response> }; | |
| 9 | 9 | WORK: ServiceBinding; | |
| 10 | 10 | RUNNER: RunnerApi; | |
| 11 | + | BILLING: ServiceBinding; | |
| 11 | 12 | } | |
| 12 | 13 | } | |
| 13 | 14 | interface Env extends Cloudflare.Env {} |
| 3 | 3 | "name": "g1t", | |
| 4 | 4 | "account_id": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 5 | 5 | "compatibility_date": "2026-09-26", | |
| 6 | + | // Runs beside the services it calls, which run beside their databases: | |
| 7 | + | // a page makes several calls in turn, and only the page travels back. | |
| 8 | + | "placement": { "mode": "smart" }, | |
| 6 | 9 | "main": "./workers/app.ts", | |
| 7 | 10 | "routes": [{ "pattern": "g1t.sh", "custom_domain": true }], | |
| 8 | 11 | // The site holds no data of its own; everything goes through services. | |
| 10 | 13 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 11 | 14 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 12 | 15 | { "binding": "WORK", "service": "g1t-work" }, | |
| 13 | − | { "binding": "RUNNER", "service": "g1t-runner" } | |
| 16 | + | { "binding": "RUNNER", "service": "g1t-runner" }, | |
| 17 | + | { "binding": "BILLING", "service": "g1t-billing" } | |
| 14 | 18 | ], | |
| 15 | 19 | "observability": { "enabled": true }, | |
| 16 | 20 | "upload_source_maps": true |
| 1 | + | //! The billing service: what agents cost, charged to the workspace they | |
| 2 | + | //! worked for. | |
| 3 | + | //! | |
| 4 | + | //! A workspace buys credit and each agent run deducts what it cost, plus | |
| 5 | + | //! g1t's margin. With no credit, no agent starts. Money is held in | |
| 6 | + | //! millionths of a US dollar, so that a run costing a fraction of a cent is | |
| 7 | + | //! recorded exactly. | |
| 8 | + | //! | |
| 9 | + | //! Each `*Args` struct is the argument of the method of the same name, | |
| 10 | + | //! served at `POST /rpc/<method>`. | |
| 11 | + | ||
| 12 | + | use serde::{Deserialize, Serialize}; | |
| 13 | + | ||
| 14 | + | use crate::repos::RepoPath; | |
| 15 | + | use crate::{User, Viewer}; | |
| 16 | + | ||
| 17 | + | /// Millionths of a US dollar in one dollar. | |
| 18 | + | pub const MICROS_PER_DOLLAR: i64 = 1_000_000; | |
| 19 | + | ||
| 20 | + | /// Whether workspaces are charged for agents at all, and with real money. | |
| 21 | + | /// `status` takes nothing and returns this. | |
| 22 | + | #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] | |
| 23 | + | pub struct Status { | |
| 24 | + | /// False when no payment provider is configured: nothing is charged, | |
| 25 | + | /// and who may run agents is decided some other way. | |
| 26 | + | pub enabled: bool, | |
| 27 | + | /// False while the payment provider is in its test mode, where cards | |
| 28 | + | /// are not real. | |
| 29 | + | pub live: bool, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// A workspace's standing. | |
| 33 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 34 | + | #[serde(rename_all = "camelCase")] | |
| 35 | + | pub struct Account { | |
| 36 | + | pub workspace: String, | |
| 37 | + | /// Credit left, in millionths of a dollar. Can dip below zero by the | |
| 38 | + | /// cost of the runs that were under way when it ran out. | |
| 39 | + | pub balance_micros: i64, | |
| 40 | + | pub status: Status, | |
| 41 | + | /// What is added to a run's cost, in percent. | |
| 42 | + | pub margin_percent: u32, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 46 | + | #[serde(rename_all = "snake_case")] | |
| 47 | + | pub enum EntryKind { | |
| 48 | + | /// Credit bought with a card. | |
| 49 | + | TopUp, | |
| 50 | + | /// An agent's run. | |
| 51 | + | Usage, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// One line of a workspace's statement. | |
| 55 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 56 | + | #[serde(rename_all = "camelCase")] | |
| 57 | + | pub struct LedgerEntry { | |
| 58 | + | pub id: String, | |
| 59 | + | pub kind: EntryKind, | |
| 60 | + | /// Positive for credit added, negative for usage. | |
| 61 | + | pub amount_micros: i64, | |
| 62 | + | pub description: String, | |
| 63 | + | /// For usage: the repository and pull request the agent worked on. | |
| 64 | + | pub repo: Option<String>, | |
| 65 | + | pub number: Option<u32>, | |
| 66 | + | /// For usage: `implement`, `review` or `update`. | |
| 67 | + | pub task: Option<String>, | |
| 68 | + | /// For usage: the model, by its public name. | |
| 69 | + | pub model: Option<String>, | |
| 70 | + | /// For a top-up: the username of whoever paid. | |
| 71 | + | pub created_by: Option<String>, | |
| 72 | + | /// RFC 3339. | |
| 73 | + | pub created_at: String, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// `account` (`Outcome<Account>`) and `ledger` (`Outcome<Vec<LedgerEntry>>`, | |
| 77 | + | /// newest first). Members of the workspace only. | |
| 78 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 79 | + | pub struct AccountArgs { | |
| 80 | + | pub workspace: String, | |
| 81 | + | pub viewer: Viewer, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// `checkout`: starts a card payment for credit. Owners of the workspace | |
| 85 | + | /// only. Returns `Outcome<Checkout>`. | |
| 86 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 87 | + | #[serde(rename_all = "camelCase")] | |
| 88 | + | pub struct CheckoutArgs { | |
| 89 | + | pub actor: User, | |
| 90 | + | pub workspace: String, | |
| 91 | + | /// How much credit to buy, in cents. | |
| 92 | + | pub amount_cents: u32, | |
| 93 | + | /// Where the payment page sends the person afterwards. The payment's | |
| 94 | + | /// id is appended as `session`. | |
| 95 | + | pub return_url: String, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 99 | + | pub struct Checkout { | |
| 100 | + | /// The payment page to send the person to. | |
| 101 | + | pub url: String, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// `confirm`: credits a payment once the provider says it was made. Safe | |
| 105 | + | /// to call any number of times. Returns `Outcome<Account>`. | |
| 106 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 107 | + | pub struct ConfirmArgs { | |
| 108 | + | pub workspace: String, | |
| 109 | + | pub viewer: Viewer, | |
| 110 | + | /// The payment's id, as returned to `return_url`. | |
| 111 | + | pub session: String, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// `can_start`: whether a workspace may start an agent now, asked before | |
| 115 | + | /// anything is opened for it. Returns `Outcome<bool>`: a failure, with the | |
| 116 | + | /// reason to show, when it has no credit. | |
| 117 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 118 | + | pub struct CanStartArgs { | |
| 119 | + | pub workspace: String, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// `start_run`: asks whether a workspace may start an agent, and opens the | |
| 123 | + | /// run it will be charged for. Called by the runner service. Returns | |
| 124 | + | /// `Outcome<Option<RunTicket>>`: no ticket when billing is off, a failure | |
| 125 | + | /// when the workspace has no credit. | |
| 126 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 127 | + | pub struct StartRunArgs { | |
| 128 | + | pub workspace: String, | |
| 129 | + | pub repo: RepoPath, | |
| 130 | + | pub number: u32, | |
| 131 | + | /// `implement`, `review` or `update`. | |
| 132 | + | pub task: String, | |
| 133 | + | /// The model, by its public name. | |
| 134 | + | pub model: String, | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 138 | + | #[serde(rename_all = "camelCase")] | |
| 139 | + | pub struct RunTicket { | |
| 140 | + | pub run_id: String, | |
| 141 | + | /// Lets the sandbox, and nothing else, report what this run cost. | |
| 142 | + | pub token: String, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// `finish_run`: what a run cost, as its sandbox reports it. Charged once. | |
| 146 | + | /// Returns `Outcome<bool>`. | |
| 147 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 148 | + | #[serde(rename_all = "camelCase")] | |
| 149 | + | pub struct FinishRunArgs { | |
| 150 | + | pub run_id: String, | |
| 151 | + | pub token: String, | |
| 152 | + | /// What the model provider charged, in US dollars. | |
| 153 | + | pub cost_usd: f64, | |
| 154 | + | #[serde(default)] | |
| 155 | + | pub turns: u32, | |
| 156 | + | } |
| 48 | 48 | pub default_branch: bool, | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | − | /// The payload of `issue.opened`, `issue.updated`, `issue.closed` and | |
| 52 | − | /// `issue.reopened`; each uses the fields that apply to it. | |
| 51 | + | /// The payload of `issue.opened`, `issue.updated`, `issue.assigned`, | |
| 52 | + | /// `issue.closed` and `issue.reopened`; each uses the fields that apply to it. | |
| 53 | 53 | #[derive(Debug, Default, Serialize)] | |
| 54 | 54 | #[serde(rename_all = "camelCase")] | |
| 55 | 55 | pub struct IssueEvent { | |
| 64 | 64 | /// On close: the number of the pull request whose merge closed it. | |
| 65 | 65 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 66 | 66 | pub resolved_by: Option<u32>, | |
| 67 | + | /// On `issue.assigned`: the people it is now assigned to. | |
| 68 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 69 | + | pub assignees: Option<Vec<String>>, | |
| 67 | 70 | } | |
| 68 | 71 | ||
| 69 | 72 | /// The payload of `pull.opened`, `pull.ready`, `pull.updated` (its head | |
| 101 | 104 | pub commit: String, | |
| 102 | 105 | } | |
| 103 | 106 | ||
| 107 | + | /// `review.completed`: a g1t agent finished reviewing a pull request, or | |
| 108 | + | /// could not. | |
| 109 | + | #[derive(Debug, Serialize)] | |
| 110 | + | #[serde(rename_all = "camelCase")] | |
| 111 | + | pub struct ReviewEvent { | |
| 112 | + | pub pull_id: String, | |
| 113 | + | pub repo_id: String, | |
| 114 | + | pub number: u32, | |
| 115 | + | /// `approve` or `request_changes`; absent when no review was written. | |
| 116 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 117 | + | pub verdict: Option<&'static str>, | |
| 118 | + | } | |
| 119 | + | ||
| 104 | 120 | /// `comment.created`. `number` is the issue or pull request commented on. | |
| 105 | 121 | #[derive(Debug, Serialize)] | |
| 106 | 122 | #[serde(rename_all = "camelCase")] | |
| 108 | 124 | pub comment_id: String, | |
| 109 | 125 | pub repo_id: String, | |
| 110 | 126 | pub number: u32, | |
| 127 | + | /// Set when the comment is on a pull request. | |
| 128 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 129 | + | pub pull_id: Option<String>, | |
| 130 | + | /// Set when the comment is a review: approve or request changes. | |
| 131 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 132 | + | pub verdict: Option<crate::work::Verdict>, | |
| 111 | 133 | } | |
| 112 | 134 | ||
| 113 | 135 | #[derive(Debug, Serialize)] | |
| 180 | 202 | #[serde(default)] | |
| 181 | 203 | pub limit: Option<u32>, | |
| 182 | 204 | } | |
| 205 | + | ||
| 206 | + | /// `queue.changed`: a repository's merge queue gained, lost or settled an | |
| 207 | + | /// entry, so the next batch may be ready to test. | |
| 208 | + | #[derive(Debug, Serialize)] | |
| 209 | + | #[serde(rename_all = "camelCase")] | |
| 210 | + | pub struct QueueChanged { | |
| 211 | + | pub repo_id: String, | |
| 212 | + | } |
| 24 | 24 | pub name: String, | |
| 25 | 25 | /// RFC 3339. | |
| 26 | 26 | pub created_at: String, | |
| 27 | + | /// RFC 3339, to within a few minutes. Null until it is first used. | |
| 28 | + | pub last_used_at: Option<String>, | |
| 29 | + | /// For a workspace's token, the username of the member who made it. | |
| 30 | + | /// Null once that account is gone, and on personal tokens. | |
| 31 | + | pub created_by: Option<String>, | |
| 27 | 32 | } | |
| 28 | 33 | ||
| 29 | 34 | /// `sign_in`: verifies a username and password for website sign-in. | |
| 96 | 101 | pub id: String, | |
| 97 | 102 | } | |
| 98 | 103 | ||
| 99 | − | /// `create_access_token`. | |
| 104 | + | /// `create_access_token`: a token that acts as `user`. For a workspace | |
| 105 | + | /// acting through a token of its own, the new token belongs to that | |
| 106 | + | /// workspace too. | |
| 100 | 107 | #[derive(Debug, Serialize, Deserialize)] | |
| 101 | 108 | #[serde(rename_all = "camelCase")] | |
| 102 | 109 | pub struct CreateAccessTokenArgs { | |
| 222 | 229 | pub id: String, | |
| 223 | 230 | pub slug: String, | |
| 224 | 231 | pub name: String, | |
| 232 | + | /// One line saying what the workspace is for. | |
| 233 | + | pub description: Option<String>, | |
| 225 | 234 | /// RFC 3339. | |
| 226 | 235 | pub created_at: String, | |
| 227 | 236 | pub member_count: u32, | |
| 264 | 273 | pub username: String, | |
| 265 | 274 | } | |
| 266 | 275 | ||
| 276 | + | /// `update_workspace`: owners only. An empty name falls back to the slug; | |
| 277 | + | /// an empty description clears it. Returns `Outcome<Workspace>`. | |
| 278 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 279 | + | pub struct UpdateWorkspaceArgs { | |
| 280 | + | pub actor: User, | |
| 281 | + | pub slug: String, | |
| 282 | + | pub name: String, | |
| 283 | + | pub description: String, | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /// `list_workspace_tokens`: members only. Returns | |
| 287 | + | /// `Outcome<Vec<AccessToken>>`. | |
| 288 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 289 | + | pub struct WorkspaceTokensArgs { | |
| 290 | + | pub slug: String, | |
| 291 | + | pub viewer: crate::Viewer, | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// `create_workspace_token`: owners only. The token belongs to the | |
| 295 | + | /// workspace, acts as it, and keeps working when the member who made it | |
| 296 | + | /// leaves. Returns `Outcome<CreatedAccessToken>`. | |
| 297 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 298 | + | pub struct CreateWorkspaceTokenArgs { | |
| 299 | + | pub actor: User, | |
| 300 | + | pub slug: String, | |
| 301 | + | pub name: String, | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /// `remove_workspace_token`: owners only. Returns `Outcome<bool>`. | |
| 305 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 306 | + | pub struct RemoveWorkspaceTokenArgs { | |
| 307 | + | pub actor: User, | |
| 308 | + | pub slug: String, | |
| 309 | + | pub id: String, | |
| 310 | + | } | |
| 311 | + | ||
| 267 | 312 | /// `oauth_authorize`: the signed-in person approved an application. The | |
| 268 | 313 | /// caller has checked the client and that it may be redirected to | |
| 269 | 314 | /// `redirect_uri`. Returns `OAuthCode`. | |
| 326 | 371 | /// RFC 3339. | |
| 327 | 372 | pub last_used_at: String, | |
| 328 | 373 | } | |
| 374 | + | ||
| 375 | + | ||
| 376 | + | /// What an agent's token may do: these operations, in this repository. | |
| 377 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 378 | + | pub struct AgentScope { | |
| 379 | + | pub repo: crate::repos::RepoPath, | |
| 380 | + | /// API and MCP operation names, such as `create_issue`. | |
| 381 | + | pub operations: Vec<String>, | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | /// `create_agent_token`: a token for a g1t agent working on someone's | |
| 385 | + | /// behalf. It acts as `g1t-agent`, a member of the repository's workspace, | |
| 386 | + | /// and only for the operations in `scope`. Returns `CreatedAccessToken`. | |
| 387 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 388 | + | #[serde(rename_all = "camelCase")] | |
| 389 | + | pub struct CreateAgentTokenArgs { | |
| 390 | + | /// The person the agent works for; the token is recorded as theirs. | |
| 391 | + | pub on_behalf_of: User, | |
| 392 | + | pub scope: AgentScope, | |
| 393 | + | pub ttl_seconds: u64, | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // `agent_scope` takes `TokenArgs` and returns `Option<AgentScope>`: what an | |
| 397 | + | // agent's token may do, or null for any other token. | |
| 398 | + | ||
| 399 | + | /// The id and name g1t's agents act under. | |
| 400 | + | pub const AGENT_ID: &str = "usr_g1t_agent"; | |
| 401 | + | pub const AGENT_NAME: &str = "g1t-agent"; |
| 4 | 4 | //! arguments of each of its methods. Services and their callers depend on | |
| 5 | 5 | //! this crate, never on each other's code. | |
| 6 | 6 | ||
| 7 | + | pub mod billing; | |
| 7 | 8 | pub mod events; | |
| 8 | 9 | pub mod identity; | |
| 9 | 10 | mod ids; | |
| 37 | 38 | pub role: Role, | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 41 | + | /// What a set of credentials resolved to. | |
| 42 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 43 | + | #[serde(rename_all = "lowercase")] | |
| 44 | + | pub enum PrincipalKind { | |
| 45 | + | /// A person's account. | |
| 46 | + | #[default] | |
| 47 | + | User, | |
| 48 | + | /// A workspace, acting through one of its own access tokens. Its `id` | |
| 49 | + | /// is the workspace's, its `username` the workspace's slug, and it is a | |
| 50 | + | /// member of that workspace and no other. | |
| 51 | + | Workspace, | |
| 52 | + | /// A g1t agent at work in a sandbox, acting through a token that lives | |
| 53 | + | /// as long as its run and can do only what that token's scope lists, in | |
| 54 | + | /// one repository. Its `username` is `g1t-agent`. | |
| 55 | + | Agent, | |
| 56 | + | } | |
| 57 | + | ||
| 40 | 58 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 41 | 59 | pub struct User { | |
| 42 | 60 | pub id: String, | |
| 43 | 61 | pub username: String, | |
| 62 | + | #[serde(default)] | |
| 63 | + | pub kind: PrincipalKind, | |
| 44 | 64 | /// Whether the account's email address has been confirmed. Unverified | |
| 45 | 65 | /// accounts can sign in but cannot create or change anything. | |
| 46 | 66 | #[serde(default)] |
| 56 | 56 | }) | |
| 57 | 57 | && !value.starts_with('.') | |
| 58 | 58 | && !value.ends_with(".git") | |
| 59 | + | // `/<workspace>/-/…` holds the workspace's own pages. | |
| 60 | + | && value != "-" | |
| 59 | 61 | } |
| 9 | 9 | Unauthenticated, | |
| 10 | 10 | Conflict, | |
| 11 | 11 | Invalid, | |
| 12 | + | /// The workspace has to pay before this can happen. | |
| 13 | + | PaymentRequired, | |
| 12 | 14 | } | |
| 13 | 15 | ||
| 14 | 16 | impl FailureCode { | |
| 19 | 21 | FailureCode::Unauthenticated => 401, | |
| 20 | 22 | FailureCode::Conflict => 409, | |
| 21 | 23 | FailureCode::Invalid => 422, | |
| 24 | + | FailureCode::PaymentRequired => 402, | |
| 22 | 25 | } | |
| 23 | 26 | } | |
| 24 | 27 | } |
| 20 | 20 | pub default_branch: String, | |
| 21 | 21 | /// Set when this repo is a pull request's working copy of another repo. | |
| 22 | 22 | pub fork_of: Option<String>, | |
| 23 | + | /// Whether the default branch is protected: it changes only by merging | |
| 24 | + | /// a pull request, and pushes to it are refused. | |
| 25 | + | #[serde(default)] | |
| 26 | + | pub protected: bool, | |
| 23 | 27 | /// RFC 3339. | |
| 24 | 28 | pub created_at: String, | |
| 25 | 29 | } | |
| 162 | 166 | pub description: Option<String>, | |
| 163 | 167 | #[serde(default)] | |
| 164 | 168 | pub is_private: bool, | |
| 169 | + | /// The https address of a public git repository to copy the default | |
| 170 | + | /// branch of, such as `https://github.com/owner/repo`. | |
| 171 | + | #[serde(default)] | |
| 172 | + | pub import_url: Option<String>, | |
| 165 | 173 | } | |
| 166 | 174 | ||
| 175 | + | /// `update`: changes whichever of a repository's details are given. | |
| 176 | + | /// Members of its workspace only. Returns `Outcome<Repo>`. | |
| 177 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 178 | + | #[serde(rename_all = "camelCase")] | |
| 179 | + | pub struct UpdateArgs { | |
| 180 | + | pub actor: User, | |
| 181 | + | pub path: RepoPath, | |
| 182 | + | /// An empty description clears it. | |
| 183 | + | #[serde(default)] | |
| 184 | + | pub description: Option<String>, | |
| 185 | + | #[serde(default)] | |
| 186 | + | pub is_private: Option<bool>, | |
| 187 | + | #[serde(default)] | |
| 188 | + | pub protected: Option<bool>, | |
| 189 | + | } | |
| 190 | + | ||
| 167 | 191 | /// `tree`. Returns `Outcome<TreeView>`. | |
| 168 | 192 | #[derive(Debug, Serialize, Deserialize)] | |
| 169 | 193 | #[serde(rename_all = "camelCase")] | |
| 307 | 331 | pub head: Option<String>, | |
| 308 | 332 | } | |
| 309 | 333 | ||
| 334 | + | /// Lines `start` to `end` of a file, inclusive and counted from 1, last | |
| 335 | + | /// changed by `commit`. | |
| 336 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 337 | + | pub struct BlameRange { | |
| 338 | + | pub start: u32, | |
| 339 | + | pub end: u32, | |
| 340 | + | pub commit: String, | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// Who last changed each line of a file. | |
| 344 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 345 | + | pub struct Blame { | |
| 346 | + | /// The commit the file was read at. | |
| 347 | + | pub head: String, | |
| 348 | + | /// Every line, in order, in runs that share a commit. | |
| 349 | + | pub ranges: Vec<BlameRange>, | |
| 350 | + | /// The commits the ranges name, each once. | |
| 351 | + | pub commits: Vec<Commit>, | |
| 352 | + | /// True when the history was too long to read in full, so the oldest | |
| 353 | + | /// lines are given to the oldest commit read. | |
| 354 | + | pub partial: bool, | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | /// `blame`: who last changed each line of `path` as of `ref` (the default | |
| 358 | + | /// branch if absent). Returns `Outcome<Blame>`; not found when the file is | |
| 359 | + | /// missing or is not text. | |
| 360 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 361 | + | pub struct BlameArgs { | |
| 362 | + | pub path: RepoPath, | |
| 363 | + | pub viewer: Viewer, | |
| 364 | + | #[serde(default, rename = "ref")] | |
| 365 | + | pub git_ref: Option<String>, | |
| 366 | + | #[serde(rename = "filePath")] | |
| 367 | + | pub file_path: String, | |
| 368 | + | } | |
| 369 | + | ||
| 310 | 370 | /// A branch and the commit it points to. | |
| 311 | 371 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 312 | 372 | pub struct Branch { | |
| 322 | 382 | pub viewer: Viewer, | |
| 323 | 383 | } | |
| 324 | 384 | ||
| 385 | + | /// `behind`: whether the default branch of the repository a pull request | |
| 386 | + | /// would merge into has commits its source does not. For services that | |
| 387 | + | /// have already decided the caller may see the pull request; it reveals | |
| 388 | + | /// one bit. Returns `bool`. | |
| 389 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 390 | + | #[serde(rename_all = "camelCase")] | |
| 391 | + | pub struct BehindArgs { | |
| 392 | + | /// The pull request's fork, or the repository itself for a branch. | |
| 393 | + | pub source_id: String, | |
| 394 | + | /// The branch of the source. A fork is compared on its default branch. | |
| 395 | + | #[serde(default)] | |
| 396 | + | pub branch: Option<String>, | |
| 397 | + | } | |
| 398 | + | ||
| 325 | 399 | /// `head`: the commit a branch points to, or null. For services reacting | |
| 326 | 400 | /// to a push, which have no viewer; it reveals nothing but a commit hash. | |
| 327 | 401 | /// Returns `Option<String>`. |
| 8 | 8 | ||
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | ||
| 11 | − | use crate::repos::RepoPath; | |
| 11 | + | use crate::repos::{CompareArgs, RepoPath}; | |
| 12 | 12 | use crate::{User, Viewer}; | |
| 13 | 13 | ||
| 14 | 14 | /// Labels every repository starts with. Any other label comes into being | |
| 74 | 74 | /// Pull requests made against this issue, in any state. | |
| 75 | 75 | pub pull_count: u32, | |
| 76 | 76 | pub comment_count: u32, | |
| 77 | + | /// Usernames of the people it is assigned to. | |
| 78 | + | #[serde(default)] | |
| 79 | + | pub assignees: Vec<String>, | |
| 80 | + | /// The numbers of the issues that have to be merged before this one is | |
| 81 | + | /// worked on. | |
| 82 | + | #[serde(default)] | |
| 83 | + | pub blocked_by: Vec<u32>, | |
| 84 | + | /// Whether a g1t agent takes it as soon as it can: at once, or when | |
| 85 | + | /// what it is blocked by has merged. | |
| 86 | + | #[serde(default)] | |
| 87 | + | pub queued: bool, | |
| 88 | + | /// The agent working on it now: the one behind its newest pull request | |
| 89 | + | /// that is still in progress in a fork, such as `g1t-agent`. | |
| 90 | + | #[serde(default)] | |
| 91 | + | pub agent: Option<String>, | |
| 77 | 92 | } | |
| 78 | 93 | ||
| 79 | 94 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 151 | 166 | /// Where the latest run of the issue's acceptance checks stands, if | |
| 152 | 167 | /// there has been one against the current head. | |
| 153 | 168 | pub check_status: Option<CheckStatus>, | |
| 169 | + | /// The files it changes, as of its latest push. | |
| 170 | + | #[serde(default)] | |
| 171 | + | pub files: Vec<ChangedFile>, | |
| 172 | + | /// Usernames of the people it is assigned to. | |
| 173 | + | #[serde(default)] | |
| 174 | + | pub assignees: Vec<String>, | |
| 175 | + | /// Those whose review was asked for: usernames, and `g1t-agent` when a | |
| 176 | + | /// g1t agent was asked. | |
| 177 | + | #[serde(default)] | |
| 178 | + | pub reviewers: Vec<String>, | |
| 154 | 179 | pub author: User, | |
| 155 | 180 | /// RFC 3339. | |
| 156 | 181 | pub created_at: String, | |
| 158 | 183 | pub updated_at: String, | |
| 159 | 184 | } | |
| 160 | 185 | ||
| 186 | + | /// One file a pull request changes, and by how much. | |
| 187 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 188 | + | pub struct ChangedFile { | |
| 189 | + | pub path: String, | |
| 190 | + | pub additions: u32, | |
| 191 | + | pub deletions: u32, | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// Another pull request in progress that changes some of the same files. | |
| 195 | + | /// Two for the same issue are alternatives; two for different issues are | |
| 196 | + | /// heading for a conflict. | |
| 197 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 198 | + | pub struct Overlap { | |
| 199 | + | pub number: u32, | |
| 200 | + | pub title: String, | |
| 201 | + | /// The number of the issue the other pull request is for. | |
| 202 | + | pub issue: Option<u32>, | |
| 203 | + | /// The files both change. | |
| 204 | + | pub paths: Vec<String>, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | impl Pull { | |
| 208 | + | /// What to ask the repos service to see what this pull request changes. | |
| 209 | + | /// | |
| 210 | + | /// A fork is compared as a whole. A branch is compared by name while | |
| 211 | + | /// the pull request is open, and by the commit it was merged or closed | |
| 212 | + | /// at afterwards, so later pushes to the branch do not change the record. | |
| 213 | + | pub fn comparison(&self, viewer: &Viewer) -> CompareArgs { | |
| 214 | + | let settled = matches!(self.status, PullStatus::Merged | PullStatus::Closed); | |
| 215 | + | let (repo_id, head) = match &self.fork_repo_id { | |
| 216 | + | Some(fork) => (fork.clone(), None), | |
| 217 | + | None => ( | |
| 218 | + | self.repo_id.clone(), | |
| 219 | + | self.head_commit | |
| 220 | + | .clone() | |
| 221 | + | .filter(|_| settled) | |
| 222 | + | .or_else(|| self.branch.clone()), | |
| 223 | + | ), | |
| 224 | + | }; | |
| 225 | + | CompareArgs { | |
| 226 | + | repo_id, | |
| 227 | + | viewer: viewer.clone(), | |
| 228 | + | base: self.merge_base.clone(), | |
| 229 | + | head, | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 161 | 234 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 162 | 235 | #[serde(rename_all = "lowercase")] | |
| 163 | 236 | pub enum CheckStatus { | |
| 234 | 307 | } | |
| 235 | 308 | } | |
| 236 | 309 | ||
| 310 | + | /// What an entry in a conversation is. | |
| 311 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 312 | + | #[serde(rename_all = "lowercase")] | |
| 313 | + | pub enum CommentKind { | |
| 314 | + | /// Something a person or an agent wrote. | |
| 315 | + | #[default] | |
| 316 | + | Comment, | |
| 317 | + | /// Something that happened: an assignment, a review asked for, a close. | |
| 318 | + | Event, | |
| 319 | + | } | |
| 320 | + | ||
| 237 | 321 | /// A comment on an issue or a pull request. On a pull request it can sit | |
| 238 | 322 | /// on one line of the change, and it can carry a reviewer's verdict. | |
| 239 | 323 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 240 | 324 | #[serde(rename_all = "camelCase")] | |
| 241 | 325 | pub struct Comment { | |
| 242 | 326 | pub id: String, | |
| 327 | + | /// Something a person wrote, or something that happened. | |
| 328 | + | #[serde(default)] | |
| 329 | + | pub kind: CommentKind, | |
| 243 | 330 | pub author: User, | |
| 244 | − | /// Markdown. | |
| 331 | + | /// Markdown. For an event, what its author did, as the rest of a | |
| 332 | + | /// sentence that starts with their name: "assigned ana". | |
| 245 | 333 | pub body: String, | |
| 246 | 334 | /// The file commented on, for a comment on a line. | |
| 247 | 335 | pub path: Option<String>, | |
| 302 | 390 | pub comments: Vec<Comment>, | |
| 303 | 391 | /// The latest run of the issue's acceptance checks. | |
| 304 | 392 | pub checks: Option<CheckRun>, | |
| 393 | + | /// Other pull requests in progress that change the same files. | |
| 394 | + | #[serde(default)] | |
| 395 | + | pub overlaps: Vec<Overlap>, | |
| 396 | + | /// Whether the branch it would merge into has moved on without it, so | |
| 397 | + | /// that it has to catch up before it can merge. | |
| 398 | + | #[serde(default)] | |
| 399 | + | pub behind: bool, | |
| 400 | + | /// Whether a g1t agent is reviewing it right now. | |
| 401 | + | #[serde(default)] | |
| 402 | + | pub review_pending: bool, | |
| 403 | + | /// Where it stands on its way to being merged, for a pull request g1t | |
| 404 | + | /// is seeing through. Absent on anyone else's. | |
| 405 | + | #[serde(default)] | |
| 406 | + | pub lifecycle: Option<Lifecycle>, | |
| 407 | + | /// A merge was asked for while it was behind: g1t is bringing it up to | |
| 408 | + | /// date and will then land it. | |
| 409 | + | #[serde(default)] | |
| 410 | + | pub landing: bool, | |
| 411 | + | /// Why g1t stopped working on it, if it did: a catch-up that could not | |
| 412 | + | /// be completed, for example. | |
| 413 | + | #[serde(default)] | |
| 414 | + | pub stalled: Option<String>, | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// A step on the way from an assigned issue to a pull request that is ready | |
| 418 | + | /// to merge. g1t takes each one without being asked. | |
| 419 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 420 | + | #[serde(rename_all = "snake_case")] | |
| 421 | + | pub enum Stage { | |
| 422 | + | /// The agent is making the change. | |
| 423 | + | Working, | |
| 424 | + | /// The issue's acceptance checks are running against it. | |
| 425 | + | Checking, | |
| 426 | + | /// A g1t agent is reviewing it. | |
| 427 | + | Reviewing, | |
| 428 | + | /// The agent is addressing failed checks or a review. | |
| 429 | + | Revising, | |
| 430 | + | /// The agent is merging in the branch it would land on, which moved. | |
| 431 | + | CatchingUp, | |
| 432 | + | /// In the repository's merge queue, being tested with what is ahead of | |
| 433 | + | /// it before it lands. | |
| 434 | + | Queued, | |
| 435 | + | /// Checks passed, reviewed and approved, up to date. A person merges. | |
| 436 | + | Ready, | |
| 437 | + | /// g1t has stopped and a person has to decide what happens next. | |
| 438 | + | NeedsYou, | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | /// Where a pull request made by a g1t agent stands. See [`Stage`]. | |
| 442 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 443 | + | pub struct Lifecycle { | |
| 444 | + | pub stage: Stage, | |
| 445 | + | /// One sentence saying what is happening, or why it stopped. | |
| 446 | + | pub detail: String, | |
| 447 | + | /// How many times the agent has been sent back to revise it. | |
| 448 | + | pub revisions: u32, | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | /// `advance`: works out the next step for a pull request g1t is seeing | |
| 452 | + | /// through and, if there is one to take now, claims it, so that it is | |
| 453 | + | /// taken once however many times this is called. Called by the runner | |
| 454 | + | /// service, which carries the step out. Returns `Advance`. | |
| 455 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 456 | + | #[serde(rename_all = "camelCase")] | |
| 457 | + | pub struct AdvanceArgs { | |
| 458 | + | pub pull_id: String, | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 462 | + | #[serde(tag = "action", rename_all = "snake_case")] | |
| 463 | + | pub enum Advance { | |
| 464 | + | /// Nothing to do now: a step is under way, or it is a person's turn. | |
| 465 | + | None, | |
| 466 | + | /// Have a g1t agent review it. | |
| 467 | + | Review { job: LifecycleJob }, | |
| 468 | + | /// Send the agent back to address `job.feedback`. | |
| 469 | + | Revise { job: LifecycleJob }, | |
| 470 | + | /// Merge in the branch it would land on. | |
| 471 | + | CatchUp { job: LifecycleJob }, | |
| 305 | 472 | } | |
| 306 | 473 | ||
| 474 | + | /// What the runner needs to carry out a step of a pull request's lifecycle. | |
| 475 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 476 | + | #[serde(rename_all = "camelCase")] | |
| 477 | + | pub struct LifecycleJob { | |
| 478 | + | pub pull_id: String, | |
| 479 | + | pub repo: RepoPath, | |
| 480 | + | pub number: u32, | |
| 481 | + | /// Who the pull request belongs to. Sandboxes act as them. | |
| 482 | + | pub author: User, | |
| 483 | + | /// The repository holding the change: its fork, or the repository | |
| 484 | + | /// itself for one made on a branch. | |
| 485 | + | pub source: RepoPath, | |
| 486 | + | /// The branch of the source holding the change; its default branch | |
| 487 | + | /// when absent. | |
| 488 | + | #[serde(default)] | |
| 489 | + | pub branch: Option<String>, | |
| 490 | + | pub default_branch: String, | |
| 491 | + | pub title: String, | |
| 492 | + | pub description: String, | |
| 493 | + | pub issue: Option<Issue>, | |
| 494 | + | /// For a revision: the failed checks or the review to address. | |
| 495 | + | pub feedback: String, | |
| 496 | + | /// For a revision: which one this is, from 1. | |
| 497 | + | pub round: u32, | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | /// How a repository wants its pull requests handled. A repository that has | |
| 501 | + | /// changed nothing has the defaults. | |
| 502 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 503 | + | #[serde(rename_all = "camelCase", default)] | |
| 504 | + | pub struct RepoSettings { | |
| 505 | + | /// Land a g1t agent's pull request without a person once it is ready: | |
| 506 | + | /// checks passed and approved as the settings below require. | |
| 507 | + | pub auto_merge: bool, | |
| 508 | + | /// Refuse to merge a pull request that does not contain the default | |
| 509 | + | /// branch's latest commits, so that what merges is what was checked. | |
| 510 | + | /// When off, merging one that is behind brings it up to date first. | |
| 511 | + | pub require_up_to_date: bool, | |
| 512 | + | /// How many approving reviews a pull request needs before it may | |
| 513 | + | /// merge. A reviewer who has since asked for changes blocks it. | |
| 514 | + | pub required_approvals: u32, | |
| 515 | + | /// Whether a g1t agent's approval counts towards `required_approvals`. | |
| 516 | + | pub count_agent_approvals: bool, | |
| 517 | + | /// Whether a member may merge although the acceptance checks did not | |
| 518 | + | /// pass. | |
| 519 | + | pub allow_ignoring_checks: bool, | |
| 520 | + | /// Whether a g1t agent's pull request is reviewed by a second agent | |
| 521 | + | /// without being asked. | |
| 522 | + | pub agent_review: bool, | |
| 523 | + | /// How many times a g1t agent is sent back to its pull request before | |
| 524 | + | /// a person is asked instead. | |
| 525 | + | pub max_revisions: u32, | |
| 526 | + | /// Merge through a queue: pull requests are tested together with those | |
| 527 | + | /// ahead of them, and only a combination that passed reaches the default | |
| 528 | + | /// branch. | |
| 529 | + | pub merge_queue: bool, | |
| 530 | + | /// Username of the member who last changed the settings, if anyone has. | |
| 531 | + | pub updated_by: Option<String>, | |
| 532 | + | /// RFC 3339. | |
| 533 | + | pub updated_at: Option<String>, | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | impl Default for RepoSettings { | |
| 537 | + | fn default() -> Self { | |
| 538 | + | RepoSettings { | |
| 539 | + | auto_merge: false, | |
| 540 | + | require_up_to_date: false, | |
| 541 | + | required_approvals: 0, | |
| 542 | + | count_agent_approvals: true, | |
| 543 | + | allow_ignoring_checks: true, | |
| 544 | + | agent_review: true, | |
| 545 | + | max_revisions: 2, | |
| 546 | + | merge_queue: false, | |
| 547 | + | updated_by: None, | |
| 548 | + | updated_at: None, | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | /// `update_settings`: replaces a repository's settings. Members of its | |
| 554 | + | /// workspace only. Returns `Outcome<RepoSettings>`. `get_settings` takes | |
| 555 | + | /// `ViewArgs` and returns the same. | |
| 556 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 557 | + | pub struct UpdateSettingsArgs { | |
| 558 | + | pub actor: User, | |
| 559 | + | pub repo: RepoPath, | |
| 560 | + | /// Who changed them and when are filled in by the service. | |
| 561 | + | pub settings: RepoSettings, | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | /// `catch_up_job`: what the runner needs to bring a pull request up to date | |
| 565 | + | /// because a merge of it was asked for. Null if none was. Returns | |
| 566 | + | /// `Option<LifecycleJob>`. | |
| 567 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 568 | + | #[serde(rename_all = "camelCase")] | |
| 569 | + | pub struct CatchUpJobArgs { | |
| 570 | + | pub pull_id: String, | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | /// `stall`: records that a step could not be carried out, so that g1t | |
| 574 | + | /// stops and a person is asked. Returns `bool`. | |
| 575 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 576 | + | #[serde(rename_all = "camelCase")] | |
| 577 | + | pub struct StallArgs { | |
| 578 | + | pub pull_id: String, | |
| 579 | + | pub reason: String, | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | /// `managed_pulls`: ids of the open pull requests g1t is seeing through, | |
| 583 | + | /// in one repository or in all of them. Returns `Vec<String>`. | |
| 584 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 585 | + | #[serde(rename_all = "camelCase")] | |
| 586 | + | pub struct ManagedPullsArgs { | |
| 587 | + | #[serde(default)] | |
| 588 | + | pub repo_id: Option<String>, | |
| 589 | + | } | |
| 590 | + | ||
| 307 | 591 | /// `open_issue`. Returns `Outcome<Issue>`. | |
| 308 | 592 | #[derive(Debug, Serialize, Deserialize)] | |
| 309 | 593 | pub struct OpenIssueArgs { | |
| 375 | 659 | pub body: Option<String>, | |
| 376 | 660 | #[serde(default)] | |
| 377 | 661 | pub labels: Option<Vec<String>>, | |
| 662 | + | /// Usernames of the people it is assigned to; replaces the whole set. | |
| 663 | + | /// Assigning it to the g1t agent is the runner's `run`, not this. | |
| 664 | + | #[serde(default)] | |
| 665 | + | pub assignees: Option<Vec<String>>, | |
| 378 | 666 | } | |
| 379 | 667 | ||
| 380 | 668 | /// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`. | |
| 451 | 739 | pub ignore_checks: bool, | |
| 452 | 740 | } | |
| 453 | 741 | ||
| 742 | + | /// Where a plan stands. | |
| 743 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 744 | + | #[serde(rename_all = "lowercase")] | |
| 745 | + | pub enum PlanStatus { | |
| 746 | + | /// An agent is reading the repository and writing it. | |
| 747 | + | Planning, | |
| 748 | + | /// Written, and waiting for a person to read and apply it. | |
| 749 | + | Ready, | |
| 750 | + | /// It could not be written. | |
| 751 | + | Failed, | |
| 752 | + | /// Its issues have been opened. | |
| 753 | + | Applied, | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | /// One issue a plan proposes. | |
| 757 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 758 | + | #[serde(rename_all = "camelCase", default)] | |
| 759 | + | pub struct PlannedIssue { | |
| 760 | + | pub title: String, | |
| 761 | + | /// Markdown: what to change, where, and why. | |
| 762 | + | pub body: String, | |
| 763 | + | pub labels: Vec<String>, | |
| 764 | + | /// Commands that must pass once the change is made. | |
| 765 | + | pub checks: Vec<String>, | |
| 766 | + | /// The files it will most likely change. | |
| 767 | + | pub files: Vec<String>, | |
| 768 | + | /// The positions, counting from 1, of earlier issues in the plan that | |
| 769 | + | /// have to be merged first. An agent writes this as `depends_on`. | |
| 770 | + | #[serde(alias = "depends_on")] | |
| 771 | + | pub depends_on: Vec<u32>, | |
| 772 | + | /// Its number, once the plan has been applied and it was kept. | |
| 773 | + | pub number: Option<u32>, | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | /// An outcome someone wrote, and the issues an agent proposes to get there. | |
| 777 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 778 | + | #[serde(rename_all = "camelCase")] | |
| 779 | + | pub struct Plan { | |
| 780 | + | pub id: String, | |
| 781 | + | pub repo_id: String, | |
| 782 | + | /// The outcome wanted, as written. | |
| 783 | + | pub brief: String, | |
| 784 | + | pub status: PlanStatus, | |
| 785 | + | /// The agent's account of how it split the work. | |
| 786 | + | pub summary: String, | |
| 787 | + | pub issues: Vec<PlannedIssue>, | |
| 788 | + | /// Why it could not be written, when `status` is `failed`. | |
| 789 | + | pub error: Option<String>, | |
| 790 | + | pub author: User, | |
| 791 | + | /// RFC 3339. | |
| 792 | + | pub created_at: String, | |
| 793 | + | /// RFC 3339. | |
| 794 | + | pub finished_at: Option<String>, | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | /// `start_plan`: records an outcome to plan for. Members of the | |
| 798 | + | /// repository's workspace only. Called by the runner service, which starts | |
| 799 | + | /// the sandbox. Returns `Outcome<PlanJob>`. | |
| 800 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 801 | + | pub struct StartPlanArgs { | |
| 802 | + | pub actor: User, | |
| 803 | + | pub repo: RepoPath, | |
| 804 | + | pub brief: String, | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | /// What a sandbox needs to write a plan. | |
| 808 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 809 | + | #[serde(rename_all = "camelCase")] | |
| 810 | + | pub struct PlanJob { | |
| 811 | + | pub plan_id: String, | |
| 812 | + | /// Lets the sandbox, and nothing else, report this plan. | |
| 813 | + | pub token: String, | |
| 814 | + | pub brief: String, | |
| 815 | + | pub repo: RepoPath, | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | /// `report_plan`: the plan a sandbox's agent wrote, or why it could not | |
| 819 | + | /// write one. Returns `Outcome<bool>`. | |
| 820 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 821 | + | #[serde(rename_all = "camelCase")] | |
| 822 | + | pub struct ReportPlanArgs { | |
| 823 | + | pub plan_id: String, | |
| 824 | + | pub token: String, | |
| 825 | + | #[serde(default)] | |
| 826 | + | pub summary: String, | |
| 827 | + | #[serde(default)] | |
| 828 | + | pub issues: Vec<PlannedIssue>, | |
| 829 | + | #[serde(default)] | |
| 830 | + | pub error: Option<String>, | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | /// `get_plan`. Members only. Returns `Outcome<Plan>`. `list_plans` takes | |
| 834 | + | /// `ViewArgs` and returns `Outcome<Vec<Plan>>`, newest first. | |
| 835 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 836 | + | pub struct PlanArgs { | |
| 837 | + | pub repo: RepoPath, | |
| 838 | + | pub viewer: Viewer, | |
| 839 | + | pub id: String, | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | /// `apply_plan`: opens a plan's issues, each blocked by the ones it depends | |
| 843 | + | /// on. Members only, and once. Returns `Outcome<Plan>`, its issues now | |
| 844 | + | /// carrying their numbers. | |
| 845 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 846 | + | pub struct ApplyPlanArgs { | |
| 847 | + | pub actor: User, | |
| 848 | + | pub repo: RepoPath, | |
| 849 | + | pub id: String, | |
| 850 | + | /// Queue every issue for a g1t agent. | |
| 851 | + | #[serde(default)] | |
| 852 | + | pub assign: bool, | |
| 853 | + | /// The positions, counting from 1, of the issues to open. All of them | |
| 854 | + | /// when absent. | |
| 855 | + | #[serde(default)] | |
| 856 | + | pub keep: Option<Vec<u32>>, | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | /// `queue_issue`: asks for a g1t agent to take an issue as soon as it can, | |
| 860 | + | /// or withdraws that. The author or a member may. Returns `Outcome<bool>`. | |
| 861 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 862 | + | pub struct QueueIssueArgs { | |
| 863 | + | pub actor: User, | |
| 864 | + | pub repo: RepoPath, | |
| 865 | + | pub number: u32, | |
| 866 | + | pub queued: bool, | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | /// `ready_issues`: issues waiting for a g1t agent that can be given one | |
| 870 | + | /// now, in one repository or in all. Called by the runner service. Returns | |
| 871 | + | /// `Vec<ReadyIssue>`. | |
| 872 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 873 | + | #[serde(rename_all = "camelCase")] | |
| 874 | + | pub struct ReadyIssuesArgs { | |
| 875 | + | #[serde(default)] | |
| 876 | + | pub repo_id: Option<String>, | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 880 | + | pub struct ReadyIssue { | |
| 881 | + | pub repo: RepoPath, | |
| 882 | + | pub number: u32, | |
| 883 | + | /// Who queued it, on whose say-so the agent works. | |
| 884 | + | pub actor: User, | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | /// `update_pull`: changes who a pull request is assigned to and whose | |
| 888 | + | /// review is asked for. Each list given replaces the whole set. Whoever | |
| 889 | + | /// opened it, or a member of the workspace, may. Returns `Outcome<Pull>`. | |
| 890 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 891 | + | pub struct UpdatePullArgs { | |
| 892 | + | pub actor: User, | |
| 893 | + | pub repo: RepoPath, | |
| 894 | + | pub number: u32, | |
| 895 | + | #[serde(default)] | |
| 896 | + | pub assignees: Option<Vec<String>>, | |
| 897 | + | /// May include `g1t-agent`. Asking for its review does not by itself | |
| 898 | + | /// start one; the runner's `review` does. | |
| 899 | + | #[serde(default)] | |
| 900 | + | pub reviewers: Option<Vec<String>>, | |
| 901 | + | } | |
| 902 | + | ||
| 454 | 903 | /// `start_checks`: begins a run of the acceptance checks for a pull request | |
| 455 | 904 | /// that is ready for review. Called by the runner service, which starts the | |
| 456 | 905 | /// sandbox. Returns `Outcome<CheckJob>`. | |
| 500 | 949 | pub struct ActivePull { | |
| 501 | 950 | pub pull: Pull, | |
| 502 | 951 | pub issue: Option<Issue>, | |
| 952 | + | /// Where it stands, for one g1t is seeing through. | |
| 953 | + | #[serde(default)] | |
| 954 | + | pub lifecycle: Option<Lifecycle>, | |
| 503 | 955 | } | |
| 504 | 956 | ||
| 505 | 957 | /// `list_active_pulls`: drafts and open pull requests the viewer started, | |
| 506 | − | /// most recently active first. Returns `Vec<ActivePull>`. | |
| 958 | + | /// most recently active first. Returns `Vec<ActivePull>`. Also | |
| 959 | + | /// `list_assigned_issues`: open issues assigned to the viewer, most | |
| 960 | + | /// recently changed first. Returns `Vec<Issue>`. | |
| 507 | 961 | #[derive(Debug, Serialize, Deserialize)] | |
| 508 | 962 | pub struct ViewerArgs { | |
| 509 | 963 | pub viewer: Viewer, | |
| 523 | 977 | pub count: u32, | |
| 524 | 978 | } | |
| 525 | 979 | ||
| 980 | + | /// `start_review`: begins a review of a pull request by a g1t agent. Called | |
| 981 | + | /// by the runner service, which starts the sandbox. Returns | |
| 982 | + | /// `Outcome<ReviewJob>`. | |
| 983 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 984 | + | #[serde(rename_all = "camelCase")] | |
| 985 | + | pub struct StartReviewArgs { | |
| 986 | + | pub pull_id: String, | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | /// What a sandbox needs to review a pull request. | |
| 990 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 991 | + | #[serde(rename_all = "camelCase")] | |
| 992 | + | pub struct ReviewJob { | |
| 993 | + | pub run_id: String, | |
| 994 | + | /// Lets the sandbox, and nothing else, report this review. | |
| 995 | + | pub token: String, | |
| 996 | + | /// The repository holding the commit: the fork, or the repository itself. | |
| 997 | + | pub source: RepoPath, | |
| 998 | + | pub commit: String, | |
| 999 | + | pub repo: RepoPath, | |
| 1000 | + | pub default_branch: String, | |
| 1001 | + | pub number: u32, | |
| 1002 | + | pub title: String, | |
| 1003 | + | pub description: String, | |
| 1004 | + | /// The issue the pull request is for, which says what it should achieve. | |
| 1005 | + | pub issue: Option<Issue>, | |
| 1006 | + | /// Who opened the pull request, and so can read its source. | |
| 1007 | + | pub author: User, | |
| 1008 | + | } | |
| 1009 | + | ||
| 1010 | + | /// A comment on one line, as a reviewing agent reports it. | |
| 1011 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1012 | + | pub struct ReviewComment { | |
| 1013 | + | pub path: String, | |
| 1014 | + | #[serde(default)] | |
| 1015 | + | pub line: u32, | |
| 1016 | + | pub body: String, | |
| 1017 | + | } | |
| 1018 | + | ||
| 1019 | + | /// `report_review`: the review a sandbox's agent wrote, or why it could not | |
| 1020 | + | /// write one. Returns `Outcome<bool>`. | |
| 1021 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1022 | + | #[serde(rename_all = "camelCase")] | |
| 1023 | + | pub struct ReportReviewArgs { | |
| 1024 | + | pub run_id: String, | |
| 1025 | + | pub token: String, | |
| 1026 | + | #[serde(default)] | |
| 1027 | + | pub verdict: Option<Verdict>, | |
| 1028 | + | #[serde(default)] | |
| 1029 | + | pub body: String, | |
| 1030 | + | #[serde(default)] | |
| 1031 | + | pub comments: Vec<ReviewComment>, | |
| 1032 | + | /// The model that wrote it, by its public name. | |
| 1033 | + | #[serde(default)] | |
| 1034 | + | pub model: Option<String>, | |
| 1035 | + | #[serde(default)] | |
| 1036 | + | pub error: Option<String>, | |
| 1037 | + | } | |
| 1038 | + | ||
| 526 | 1039 | /// Lowercases, trims and de-duplicates labels, dropping empty ones. | |
| 527 | 1040 | /// Returns `None` if there are too many or one is too long. | |
| 528 | 1041 | pub fn normalize_labels(labels: &[String]) -> Option<Vec<String>> { | |
| 569 | 1082 | assert_eq!(normalize_labels(&many), None); | |
| 570 | 1083 | } | |
| 571 | 1084 | } | |
| 1085 | + | ||
| 1086 | + | ||
| 1087 | + | // --- Merge queue ---------------------------------------------------------- | |
| 1088 | + | ||
| 1089 | + | /// Where a pull request in a merge queue stands. | |
| 1090 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 1091 | + | #[serde(rename_all = "snake_case")] | |
| 1092 | + | pub enum QueueState { | |
| 1093 | + | /// Waiting for its turn to be tested. | |
| 1094 | + | Waiting, | |
| 1095 | + | /// Its combined state is being built and checked. | |
| 1096 | + | Testing, | |
| 1097 | + | /// Its combined state passed; it lands once everything ahead has. | |
| 1098 | + | Passed, | |
| 1099 | + | /// Its combined state failed, or would not merge. It left the queue. | |
| 1100 | + | Failed, | |
| 1101 | + | /// On the default branch. | |
| 1102 | + | Landed, | |
| 1103 | + | /// Taken out of the queue by a person, or closed. | |
| 1104 | + | Removed, | |
| 1105 | + | } | |
| 1106 | + | ||
| 1107 | + | impl QueueState { | |
| 1108 | + | pub fn as_str(self) -> &'static str { | |
| 1109 | + | match self { | |
| 1110 | + | QueueState::Waiting => "waiting", | |
| 1111 | + | QueueState::Testing => "testing", | |
| 1112 | + | QueueState::Passed => "passed", | |
| 1113 | + | QueueState::Failed => "failed", | |
| 1114 | + | QueueState::Landed => "landed", | |
| 1115 | + | QueueState::Removed => "removed", | |
| 1116 | + | } | |
| 1117 | + | } | |
| 1118 | + | ||
| 1119 | + | /// Still in the queue. | |
| 1120 | + | pub fn is_active(self) -> bool { | |
| 1121 | + | matches!( | |
| 1122 | + | self, | |
| 1123 | + | QueueState::Waiting | QueueState::Testing | QueueState::Passed | |
| 1124 | + | ) | |
| 1125 | + | } | |
| 1126 | + | } | |
| 1127 | + | ||
| 1128 | + | /// One pull request's place in a merge queue. | |
| 1129 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 1130 | + | #[serde(rename_all = "camelCase")] | |
| 1131 | + | pub struct QueueEntry { | |
| 1132 | + | pub id: String, | |
| 1133 | + | pub number: u32, | |
| 1134 | + | pub title: String, | |
| 1135 | + | /// Who or what made the pull request, e.g. `g1t-agent`. | |
| 1136 | + | pub agent: String, | |
| 1137 | + | pub state: QueueState, | |
| 1138 | + | /// The pull requests merged ahead of it in the state being tested, in | |
| 1139 | + | /// queue order. Empty when it was tested on the default branch alone. | |
| 1140 | + | pub ahead: Vec<u32>, | |
| 1141 | + | /// The default branch's commit the tested state was built on. | |
| 1142 | + | pub base_commit: Option<String>, | |
| 1143 | + | /// The tested state: the default branch with everything ahead and this. | |
| 1144 | + | pub combined_commit: Option<String>, | |
| 1145 | + | /// Why it failed: a merge conflict or what could not be run. | |
| 1146 | + | pub error: Option<String>, | |
| 1147 | + | /// The checks run against the tested state. | |
| 1148 | + | pub results: Vec<CheckResult>, | |
| 1149 | + | /// Username of whoever merged it into the queue: a person, or `g1t`. | |
| 1150 | + | pub enqueued_by: String, | |
| 1151 | + | /// RFC 3339. | |
| 1152 | + | pub created_at: String, | |
| 1153 | + | /// RFC 3339. When it landed or left. | |
| 1154 | + | pub finished_at: Option<String>, | |
| 1155 | + | } | |
| 1156 | + | ||
| 1157 | + | /// A repository's merge queue: what is in it, in order, and what recently | |
| 1158 | + | /// left it. | |
| 1159 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 1160 | + | #[serde(rename_all = "camelCase")] | |
| 1161 | + | pub struct QueueView { | |
| 1162 | + | /// Whether the repository merges through the queue. | |
| 1163 | + | pub enabled: bool, | |
| 1164 | + | pub active: Vec<QueueEntry>, | |
| 1165 | + | /// Newest first. | |
| 1166 | + | pub recent: Vec<QueueEntry>, | |
| 1167 | + | } | |
| 1168 | + | ||
| 1169 | + | /// `queue`: a repository's merge queue. Returns `Outcome<QueueView>`. | |
| 1170 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1171 | + | pub struct QueueArgs { | |
| 1172 | + | pub repo: RepoPath, | |
| 1173 | + | pub viewer: Viewer, | |
| 1174 | + | } | |
| 1175 | + | ||
| 1176 | + | /// `queue_build`: the next batch to test for a repository, if nothing is | |
| 1177 | + | /// being tested now. Returns `Vec<QueueJob>`, one per entry, each testing | |
| 1178 | + | /// the default branch with that entry and everything ahead of it. | |
| 1179 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1180 | + | #[serde(rename_all = "camelCase")] | |
| 1181 | + | pub struct QueueBuildArgs { | |
| 1182 | + | pub repo_id: String, | |
| 1183 | + | } | |
| 1184 | + | ||
| 1185 | + | /// One pull request in a state being tested: where its change is. | |
| 1186 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 1187 | + | #[serde(rename_all = "camelCase")] | |
| 1188 | + | pub struct QueueStackItem { | |
| 1189 | + | pub number: u32, | |
| 1190 | + | pub title: String, | |
| 1191 | + | /// The repository holding the change: its fork, or the repository. | |
| 1192 | + | pub source: RepoPath, | |
| 1193 | + | /// The branch of `source` holding it. | |
| 1194 | + | pub branch: String, | |
| 1195 | + | pub commit: String, | |
| 1196 | + | } | |
| 1197 | + | ||
| 1198 | + | /// What a sandbox needs to build and check one combined state. | |
| 1199 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 1200 | + | #[serde(rename_all = "camelCase")] | |
| 1201 | + | pub struct QueueJob { | |
| 1202 | + | pub entry_id: String, | |
| 1203 | + | /// Lets the sandbox, and nothing else, report this state's result. | |
| 1204 | + | pub token: String, | |
| 1205 | + | pub repo: RepoPath, | |
| 1206 | + | pub default_branch: String, | |
| 1207 | + | /// The default branch's commit to build on. | |
| 1208 | + | pub base_commit: String, | |
| 1209 | + | /// Where to push the tested state, in the repository itself. | |
| 1210 | + | pub branch: String, | |
| 1211 | + | /// The pull requests to merge in, in order; the last is the entry. | |
| 1212 | + | pub stack: Vec<QueueStackItem>, | |
| 1213 | + | /// Every acceptance check of every pull request in the stack. | |
| 1214 | + | pub checks: Vec<String>, | |
| 1215 | + | /// The checks of issues already completed: the default branch's | |
| 1216 | + | /// contract. One that fails on the base alone is not held against the | |
| 1217 | + | /// entry. | |
| 1218 | + | #[serde(default)] | |
| 1219 | + | pub contract_checks: Vec<String>, | |
| 1220 | + | /// Who the sandbox acts as: a member who can push the tested state. | |
| 1221 | + | pub actor: User, | |
| 1222 | + | } | |
| 1223 | + | ||
| 1224 | + | /// `report_queue`: a sandbox's result for one combined state. Returns | |
| 1225 | + | /// `Outcome<QueueState>`. | |
| 1226 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1227 | + | #[serde(rename_all = "camelCase")] | |
| 1228 | + | pub struct ReportQueueArgs { | |
| 1229 | + | pub entry_id: String, | |
| 1230 | + | pub token: String, | |
| 1231 | + | #[serde(default)] | |
| 1232 | + | pub combined_commit: Option<String>, | |
| 1233 | + | #[serde(default)] | |
| 1234 | + | pub results: Vec<CheckResult>, | |
| 1235 | + | /// Set when the state could not be built or checked. | |
| 1236 | + | #[serde(default)] | |
| 1237 | + | pub error: Option<String>, | |
| 1238 | + | /// For a merge conflict: the pull request whose change it collided with. | |
| 1239 | + | #[serde(default)] | |
| 1240 | + | pub conflict_with: Option<u32>, | |
| 1241 | + | } |
| 3 | 3 | version = "0.1.0" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | − | description = "Runs a coding agent on one attempt inside a sandbox and reports back to g1t." | |
| 6 | + | description = "The program inside a g1t sandbox: runs an agent, checks, a catch-up merge or a review, and reports back." | |
| 7 | 7 | ||
| 8 | 8 | [dependencies] | |
| 9 | 9 | anyhow = "1" |
| 31 | 31 | ||
| 32 | 32 | #[derive(Debug, Serialize)] | |
| 33 | 33 | #[serde(rename_all = "camelCase")] | |
| 34 | − | struct CheckResult { | |
| 35 | − | command: String, | |
| 36 | − | passed: bool, | |
| 34 | + | pub(crate) struct CheckResult { | |
| 35 | + | pub(crate) command: String, | |
| 36 | + | pub(crate) passed: bool, | |
| 37 | 37 | exit_code: Option<i32>, | |
| 38 | 38 | output: String, | |
| 39 | 39 | duration_ms: u64, | |
| 49 | 49 | format!("… (earlier output not shown)\n{kept}") | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | − | fn redact(text: &str, secrets: &[String]) -> String { | |
| 52 | + | pub(crate) fn redact(text: &str, secrets: &[String]) -> String { | |
| 53 | 53 | secrets.iter().fold(text.to_owned(), |text, secret| { | |
| 54 | 54 | text.replace(secret, "[redacted]") | |
| 55 | 55 | }) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | /// Runs one command in the checkout, without this process's credentials. | |
| 59 | − | fn run_command(command: &str, workdir: &Path, secrets: &[String]) -> CheckResult { | |
| 59 | + | pub(crate) fn run_command(command: &str, workdir: &Path, secrets: &[String]) -> CheckResult { | |
| 60 | 60 | let started = Instant::now(); | |
| 61 | 61 | let output = Command::new("timeout") | |
| 62 | 62 | .args([ | |
| 147 | 147 | pub fn main() -> i32 { | |
| 148 | 148 | let reporter = match (env("G1T_API"), env("CHECK_RUN"), env("CHECK_TOKEN")) { | |
| 149 | 149 | (Ok(api), Ok(run), Ok(token)) => Reporter { | |
| 150 | − | url: format!("{api}/v1/checks/{run}"), | |
| 150 | + | url: format!("{api}/checks/{run}"), | |
| 151 | 151 | token, | |
| 152 | 152 | }, | |
| 153 | 153 | _ => { |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.