Commit

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.

syntaqxcommitted Parentc30a639Browse files
149 files+8496−11400/149 viewed
+16−0
829829 ]
830830
831831 [[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]]
832846 name = "g1t-contracts"
833847 version = "0.1.0"
834848 dependencies = [
878892 name = "g1t-repos"
879893 version = "0.1.0"
880894 dependencies = [
895+ "futures-util",
881896 "g1t-contracts",
882897 "g1t-kit",
883898 "serde",
916931 name = "g1t-work"
917932 version = "0.1.0"
918933 dependencies = [
934+ "futures-util",
919935 "g1t-contracts",
920936 "g1t-kit",
921937 "getrandom 0.2.17",
+1−1
11 [workspace]
22 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"]
44
55 [workspace.package]
66 edition = "2024"
+28−2
1010 issue records which pull request resolved it while the others close as
1111 superseded.
1212
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+
1327 - Site: <https://g1t.sh>
1428 - Docs: <https://docs.g1t.sh>
1529 - API: <https://api.g1t.sh> · MCP: <https://mcp.g1t.sh>
1630 - Plan and design: [docs/PLAN.md](docs/PLAN.md)
31+- Demo walk-through: [docs/DEMO.md](docs/DEMO.md)
1732
1833 ## Status
1934
2237 - Accounts with email verification and password reset. Applications sign
2338 in through the browser with OAuth 2.1, so connecting an MCP client needs
2439 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.
2645 - Public and private repositories, and git over HTTPS, including creating a
2746 repository by pushing to it.
2847 - Issues with labels, acceptance checks and comments.
3453 the merge.
3554 - Review: comments on lines of a change, and approve or request-changes
3655 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.
3763 - Merging: lands a pull request on `main`, closes its issue naming the pull
3864 request that resolved it, and closes the others for that issue as
3965 superseded. Refused when the pull request is behind, so no commit is lost.
4369 - An event bus: every state change is published, logged and delivered to
4470 subscribers.
4571
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.
4773
4874 ## Try it
4975
+218−16
1010 mod operations;
1111 mod rest;
1212
13+use g1t_contracts::billing::FinishRunArgs;
1314 use g1t_contracts::identity::{
1415 DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs,
1516 };
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};
1822 use serde_json::{Value, json};
1923 use worker::{Context, Env, Method, Request, Response, Result, event};
2024
8892
8993 /// Where everything is, for someone or something exploring the API.
9094 fn index() -> Value {
91− let repo = format!("{API}/v1/repos/{{owner}}/{{name}}");
95+ let repo = format!("{API}/repos/{{owner}}/{{name}}");
9296 json!({
9397 "documentation_url": "https://docs.g1t.sh/api/reference/",
9498 "openapi_url": format!("{API}/openapi.json"),
9599 "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}}"),
99103 "repository_url": repo,
100104 "repository_events_url": format!("{repo}/events{{?before}}"),
101105 "labels_url": format!("{repo}/labels"),
107111 "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"),
108112 "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"),
109113 "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"),
112116 "oauth_metadata_url": format!("{API}/.well-known/oauth-authorization-server"),
113117 "git_url": "https://g1t.sh/{owner}/{name}.git",
114118 })
187191 }
188192 }
189193
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+
190301 async fn respond(mut request: Request, env: &Env) -> Result<Response> {
191302 let method = method_name(request.method());
192303 if method == "OPTIONS" {
193304 return Ok(Response::empty()?.with_status(204));
194305 }
195306 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+ };
197313 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)?;
199315
200316 let viewer = match authenticate(&request, &services).await? {
201317 Ok(viewer) => viewer,
202318 Err(refused) => return Ok(refused),
203319 };
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+ }
204338 if let Some(response) = oauth::handle(&mut request, &services, method, &path).await? {
205339 return Ok(response);
206340 }
209343 }
210344
211345 match (method, path.trim_end_matches('/')) {
212− ("GET", "" | "/v1") => return Response::from_json(&index()),
346+ ("GET", "") => return Response::from_json(&index()),
213347 ("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();
218356 return report_checks(&mut request, &services, &run_id).await;
219357 }
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+ }
220373 _ => {}
221374 }
222375
227380 let body = if method == "GET" {
228381 Value::Null
229382 } else {
230− json_body(&mut request).await
383+ snake_case_keys(json_body(&mut request).await)
231384 };
232385 let Some((route, input)) = rest::resolve(method, &path, &query, body) else {
233386 return fail(FailureCode::NotFound, "No such endpoint.");
238391 }
239392 }
240393
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+
241443 // The API is called from browsers too: the reference's explorer, and apps
242444 // built on g1t. It carries no cookies, so any origin may call it.
243445 #[event(fetch)]
+3−0
1212
1313 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.
1414 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.
1516 Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers.";
1617
1718 fn result(id: &Value, value: Value) -> Value {
4849 }
4950 "ping" => result(id, json!({})),
5051 "tools/list" => {
52+ // An agent sees only the tools its token may use.
5153 let tools: Vec<Value> = Op::ALL
5254 .into_iter()
55+ .filter(|op| services.scope.as_ref().is_none_or(|scope| op.allowed_by(scope)))
5356 .map(|op| {
5457 json!({
5558 "name": op.name(),
+7−7
3737 }
3838 }
3939
40−/// `/v1/repos/:owner/:name` as OpenAPI writes it: `/v1/repos/{owner}/{name}`.
40+/// `/repos/:owner/:name` as OpenAPI writes it: `/repos/{owner}/{name}`.
4141 fn openapi_path(route: &Route) -> String {
4242 route
4343 .path
129129 /// Entries for device sign-in, which is not an operation.
130130 fn onboarding() -> Map<String, Value> {
131131 let paths = json!({
132− "/v1/device/code": {
132+ "/device/code": {
133133 "post": {
134134 "operationId": "device_code",
135135 "tags": ["Accounts"],
136136 "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`.",
138138 "security": [],
139139 "requestBody": {
140140 "content": { "application/json": { "schema": {
152152 "content": { "application/json": { "schema": {
153153 "type": "object",
154154 "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." },
156156 "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." },
157157 "verification_uri": { "type": "string" },
158158 "verification_uri_complete": {
166166 } },
167167 },
168168 },
169− "/v1/device/token": {
169+ "/device/token": {
170170 "post": {
171171 "operationId": "device_token",
172172 "tags": ["Accounts"],
295295 #[test]
296296 fn path_and_query_inputs_are_not_repeated_in_the_body() {
297297 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"];
299299 let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"];
300300 assert!(body.get("keep_issue_open").is_some());
301301 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"];
303303 assert_eq!(list["parameters"][0]["name"], "q");
304304 assert!(list.get("requestBody").is_none());
305305 }
+319−31
44 //! [`Op`], so the surfaces cannot drift apart: adding a variant without
55 //! describing it or running it does not compile.
66
7+use g1t_contracts::identity::AgentScope;
78 use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
89 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};
1211 use g1t_contracts::work::*;
1312 use g1t_contracts::{FailureCode, Outcome, Viewer};
1413 use serde::Serialize;
2221 pub repos: Fetcher,
2322 pub work: Fetcher,
2423 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>,
2528 }
2629
2730 impl Services {
3134 repos: env.service("REPOS")?,
3235 work: env.service("WORK")?,
3336 events: env.service("EVENTS")?,
37+ runner: env.service("RUNNER")?,
38+ billing: env.service("BILLING")?,
39+ scope: None,
3440 })
3541 }
3642 }
4248 ListRepos,
4349 GetRepo,
4450 CreateRepo,
51+ UpdateRepo,
52+ GetRepoSettings,
53+ UpdateRepoSettings,
54+ GetMergeQueue,
4555 ListIssues,
4656 GetIssue,
4757 CreateIssue,
4858 UpdateIssue,
4959 CloseIssue,
5060 ReopenIssue,
61+ AssignIssue,
62+ PlanWork,
63+ GetPlan,
64+ ApplyPlan,
5165 ListLabels,
5266 AddComment,
5367 ReviewPullRequest,
168182 "type": "string",
169183 "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".",
170184 })
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− }
193185 }
194186
195187 impl Op {
196− pub const ALL: [Op; 24] = [
188+ pub const ALL: [Op; 32] = [
197189 Op::Whoami,
198190 Op::CreateWorkspace,
199191 Op::ListRepos,
200192 Op::GetRepo,
201193 Op::CreateRepo,
194+ Op::UpdateRepo,
195+ Op::GetRepoSettings,
196+ Op::UpdateRepoSettings,
197+ Op::GetMergeQueue,
202198 Op::ListIssues,
203199 Op::GetIssue,
204200 Op::CreateIssue,
205201 Op::UpdateIssue,
206202 Op::CloseIssue,
207203 Op::ReopenIssue,
204+ Op::AssignIssue,
205+ Op::PlanWork,
206+ Op::GetPlan,
207+ Op::ApplyPlan,
208208 Op::ListLabels,
209209 Op::AddComment,
210210 Op::ReviewPullRequest,
232232 Op::ListRepos => "list_repos",
233233 Op::GetRepo => "get_repo",
234234 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",
235239 Op::ListIssues => "list_issues",
236240 Op::GetIssue => "get_issue",
237241 Op::CreateIssue => "create_issue",
238242 Op::UpdateIssue => "update_issue",
239243 Op::CloseIssue => "close_issue",
240244 Op::ReopenIssue => "reopen_issue",
245+ Op::AssignIssue => "assign_issue",
246+ Op::PlanWork => "plan_work",
247+ Op::GetPlan => "get_plan",
248+ Op::ApplyPlan => "apply_plan",
241249 Op::ListLabels => "list_labels",
242250 Op::AddComment => "add_comment",
243251 Op::ReviewPullRequest => "review_pull_request",
256264
257265 pub fn description(self) -> &'static str {
258266 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+ }
260270 Op::CreateWorkspace => {
261271 "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."
262272 }
263273 Op::ListRepos => "Repositories you can see, optionally filtered by a search query.",
264274 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+ }
266290 Op::ListIssues => {
267291 "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."
268292 }
271295 }
272296 Op::CreateIssue => "Open an issue on a repository.",
273297 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."
275299 }
276300 Op::CloseIssue => {
277301 "Close an issue without a pull request. Merging a pull request made for an issue closes it for you."
278302 }
279303 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+ }
280316 Op::ListLabels => "The labels available on a repository's issues.",
281317 Op::AddComment => {
282318 "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change."
288324 "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed."
289325 }
290326 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."
292328 }
293329 Op::CreatePullRequest => {
294330 "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."
337373 &[],
338374 ),
339375 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+ ),
340428 Op::CreateRepo => object(
341429 json!({
342430 "workspace": {
346434 "name": { "type": "string" },
347435 "description": { "type": "string" },
348436 "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+ },
349441 }),
350442 &["name"],
351443 ),
388480 "title": { "type": "string" },
389481 "body": { "type": "string" },
390482 "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+ },
391530 })),
392531 &["repo", "number"],
393532 ),
518657 | Op::ReadSession
519658 | Op::GetPullRequestChanges
520659 | Op::ListEvents
660+ | Op::GetRepoSettings
661+ | Op::GetMergeQueue
521662 )
522663 }
523664
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+
524670 /// Whether the operation is about one repository, named by `repo`.
525671 fn needs_repo(self) -> bool {
526672 !matches!(
541687 "This needs a g1t access token.",
542688 );
543689 }
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+ }
544714 // Checked above for every operation that uses it.
545715 let actor = || viewer.clone().unwrap_or_default();
546716 let repo = match repo_path(input) {
576746 repos,
577747 work,
578748 events,
749+ runner,
750+ ..
579751 } = services;
580752
581753 match self {
617789 )
618790 .await
619791 }
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+ }
620859 Op::CreateRepo => {
621860 let owner = actor();
622861 // Someone in exactly one workspace need not name it.
635874 name: text(input, "name"),
636875 description: optional_text(input, "description"),
637876 is_private: input["private"].as_bool() == Some(true),
877+ import_url: optional_text(input, "import_url"),
638878 },
639879 )
640880 .await
679919 title: input["title"].as_str().map(str::to_owned),
680920 body: input["body"].as_str().map(str::to_owned),
681921 labels: strings(input, "labels"),
922+ assignees: strings(input, "assignees"),
682923 },
683924 )
684925 .await
685926 }
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+ }
686974 Op::CloseIssue | Op::ReopenIssue => {
687975 let reason = match input["reason"].as_str() {
688976 Some("not_planned") => IssueReason::NotPlanned,
8071095 let found: Outcome<PullDetail> = call(work, "get_pull", &view()).await?;
8081096 match found {
8091097 Outcome::Ok(detail) => {
810− pass(repos, "compare", &comparison(detail.pull, viewer)).await
1098+ pass(repos, "compare", &detail.pull.comparison(viewer)).await
8111099 }
8121100 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
8131101 }
+61−35
2828 }
2929
3030 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, &[]),
3650 route(
3751 "GET",
38− "/v1/repos/:owner/:name/events",
52+ "/repos/:owner/:name/events",
3953 Op::ListEvents,
4054 &[("before", "before")],
4155 ),
42− route("GET", "/v1/repos/:owner/:name/labels", Op::ListLabels, &[]),
56+ route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
4357 route(
4458 "GET",
45− "/v1/repos/:owner/:name/issues",
59+ "/repos/:owner/:name/issues",
4660 Op::ListIssues,
4761 &[("state", "state"), ("label", "label")],
4862 ),
49− route(
50− "POST",
51− "/v1/repos/:owner/:name/issues",
52− Op::CreateIssue,
53− &[],
54− ),
63+ route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
5564 route(
5665 "GET",
57− "/v1/repos/:owner/:name/issues/:number",
66+ "/repos/:owner/:name/issues/:number",
5867 Op::GetIssue,
5968 &[],
6069 ),
6170 route(
6271 "PATCH",
63− "/v1/repos/:owner/:name/issues/:number",
72+ "/repos/:owner/:name/issues/:number",
6473 Op::UpdateIssue,
6574 &[],
6675 ),
6776 route(
6877 "POST",
69− "/v1/repos/:owner/:name/issues/:number/close",
78+ "/repos/:owner/:name/issues/:number/close",
7079 Op::CloseIssue,
7180 &[],
7281 ),
7382 route(
7483 "POST",
75− "/v1/repos/:owner/:name/issues/:number/reopen",
84+ "/repos/:owner/:name/issues/:number/reopen",
7685 Op::ReopenIssue,
7786 &[],
7887 ),
7988 route(
8089 "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",
82105 Op::AddComment,
83106 &[],
84107 ),
85108 route(
86109 "GET",
87− "/v1/repos/:owner/:name/pulls",
110+ "/repos/:owner/:name/pulls",
88111 Op::ListPullRequests,
89112 &[("state", "state")],
90113 ),
91114 route(
92115 "POST",
93− "/v1/repos/:owner/:name/pulls",
116+ "/repos/:owner/:name/pulls",
94117 Op::CreatePullRequest,
95118 &[],
96119 ),
97120 route(
98121 "GET",
99− "/v1/repos/:owner/:name/pulls/:number",
122+ "/repos/:owner/:name/pulls/:number",
100123 Op::GetPullRequest,
101124 &[],
102125 ),
103126 route(
104127 "GET",
105− "/v1/repos/:owner/:name/pulls/:number/changes",
128+ "/repos/:owner/:name/pulls/:number/changes",
106129 Op::GetPullRequestChanges,
107130 &[],
108131 ),
109132 route(
110133 "POST",
111− "/v1/repos/:owner/:name/pulls/:number/reviews",
134+ "/repos/:owner/:name/pulls/:number/reviews",
112135 Op::ReviewPullRequest,
113136 &[],
114137 ),
115138 route(
116139 "GET",
117− "/v1/repos/:owner/:name/pulls/:number/session",
140+ "/repos/:owner/:name/pulls/:number/session",
118141 Op::ReadSession,
119142 &[("after", "after")],
120143 ),
121144 route(
122145 "POST",
123− "/v1/repos/:owner/:name/pulls/:number/session",
146+ "/repos/:owner/:name/pulls/:number/session",
124147 Op::RecordSession,
125148 &[],
126149 ),
127150 route(
128151 "POST",
129− "/v1/repos/:owner/:name/pulls/:number/ready",
152+ "/repos/:owner/:name/pulls/:number/ready",
130153 Op::MarkPullRequestReady,
131154 &[],
132155 ),
133156 route(
134157 "POST",
135− "/v1/repos/:owner/:name/pulls/:number/close",
158+ "/repos/:owner/:name/pulls/:number/close",
136159 Op::ClosePullRequest,
137160 &[],
138161 ),
139162 route(
140163 "POST",
141− "/v1/repos/:owner/:name/pulls/:number/merge",
164+ "/repos/:owner/:name/pulls/:number/merge",
142165 Op::MergePullRequest,
143166 &[],
144167 ),
203226 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
204227 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
205228 }
229+ if let Some(plan) = param("plan") {
230+ input.insert("plan".to_owned(), Value::String(plan.to_owned()));
231+ }
206232 if let Some(number) = param("number") {
207233 // Not a number: zero, which no issue or pull request has.
208234 input.insert(
223249 fn a_path_resolves_to_its_operation_and_input() {
224250 let (route, input) = resolve(
225251 "POST",
226− "/v1/repos/syntaqx/hello/pulls/14/merge",
252+ "/repos/syntaqx/hello/pulls/14/merge",
227253 &[],
228254 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
229255 )
242268 ("q".to_owned(), "parser".to_owned()),
243269 ("x".to_owned(), "y".to_owned()),
244270 ];
245− let (route, input) = resolve("GET", "/v1/repos", &query, Value::Null).unwrap();
271+ let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
246272 assert_eq!(route.op, Op::ListRepos);
247273 assert_eq!(input, json!({ "query": "parser" }));
248274 }
249275
250276 #[test]
251277 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());
256282 }
257283
258284 #[test]
+3−1
1616 { "binding": "IDENTITY", "service": "g1t-identity" },
1717 { "binding": "REPOS", "service": "g1t-repos" },
1818 { "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" }
2022 ],
2123 "observability": { "enabled": true }
2224 }
+1−1
8686
8787 ```sh
8888 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 \
9090 -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
9191 -d '{"branch": "my-change", "title": "My change", "issue": 12}'
9292 ```
+105−11
7777 A pull request is normally opened **for an issue**. It can also stand alone,
7878 with its own title, for a change nobody filed an issue about.
7979
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+
8092 ## Several pull requests for one issue
8193
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.
8598
8699 When you merge one:
87100
127140 on a single line of its change. Line comments are shown in the **Changes**
128141 tab under the line they are about.
129142
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+
130147 A reviewer can also give a verdict: **approve**, or **request changes**.
131148 The pull request shows where each reviewer stands. You cannot give a verdict
132149 on a pull request you opened, and that holds for agents too: one agent can
133150 review another's work, but not its own.
134151
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+
135171 ## Merging
136172
137173 A member of the repository's workspace merges a pull request once it is
139175
140176 A pull request can only merge if it contains everything already on `main`.
141177 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
144186 pull requests are in flight.
145187
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+
146227 ## Sessions
147228
148229 A session is the record of how a pull request was made: the prompt the agent
155236 Agents record their own session through the
156237 [`record_session`](/guides/bring-your-own-agent/) tool or the API.
157238
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+
158257 ## Events
159258
160259 Every state change in g1t is published as an event: a push, an issue being
166265
167266 g1t is under active development. These are designed but not available yet:
168267
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.**
175269 - **g1t agents for everyone.** g1t can put its own agents on an issue, each
176270 in a sandbox. This is in preview and limited to selected accounts; anyone
177271 can bring their own agent today.
+36−6
2929 there is no separate notion of an organization.
3030
3131 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.
3434
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+
3540 | Role | Can |
3641 | --- | --- |
3742 | 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. |
3944
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**.
4148
4249 ## Access tokens
4350
5663
5764 A token has the full rights of your account. Scoped tokens are planned.
5865
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+
5989 ## Signing in with OAuth
6090
6191 Applications that can open your browser, such as an agent connecting to the
102132
103133 ```sh
104134 # 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"}'
106136
107137 # 2. You open verification_uri_complete from the response and approve.
108138
109139 # 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": "…"}'
111141 ```
112142
113143 The poll answers with a `status` of `pending`, `approved`, `denied` or
+20−1
6464 | `list_repos` | Repositories you can see, optionally filtered by a query. |
6565 | `get_repo` | One repository's details. |
6666 | `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. |
6770 | `list_issues` | Issues on a repository, by state and label. |
6871 | `get_issue` | An issue with its comments and every pull request made for it. |
6972 | `create_issue` | Open an issue, with labels and acceptance checks. |
7073 | `update_issue` | Change an issue's title, description or labels. |
7174 | `close_issue` | Close an issue as completed or not planned. |
7275 | `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. |
7380 | `list_labels` | The labels in use on a repository. |
7481 | `add_comment` | Comment on an issue or a pull request, or on one line of a pull request's change. |
7582 | `review_pull_request` | Approve a pull request or request changes. |
8491 | `merge_pull_request` | Land a pull request on `main` and resolve its issue. Workspace members only. |
8592 | `list_events` | A repository's timeline, newest first. |
8693
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+
87106 ## Reviewing as an agent
88107
89108 An agent can review as well as write. Given an issue with several pull
97116
98117 Anything that holds an access token can open issues: an error tracker, a
99118 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
101120 such as `bug`. The issue is attributed to the account the token belongs to.
102121
103122 ## Session entries
+173−37
33 description: Have g1t's own agents work on an issue.
44 ---
55
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.
911
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.
1216
1317 ## Assigning agents
1418
19+One issue:
20+
1521 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**.
2025
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:
2327
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+
2449 ## What an agent does
2550
2651 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
3059 summary as its description.
3160
3261 Everything it reads, runs and decides is recorded in the pull request's
3362 **Session** as it happens. The **Changes** tab shows the resulting diff.
3463
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−
3964 If an agent fails, or finishes without changing anything, its pull request
4065 is closed and its session says why.
4166
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+
42145 ## Choosing between pull requests
43146
44147 Each pull request on the issue's page shows whether its checks passed. Open
48151 the issue close as superseded. See
49152 [merging](/concepts/overview/#merging) for what happens when `main` has moved.
50153
51−## Choosing a model
154+## Other things g1t agents do
52155
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.
54161
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.
60163
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
64165
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.
69179
180+A pull request made by a g1t agent carries the label `g1t-agent`, and its
181+commits are authored by `g1t agent`.
182+
70183 ## How model traffic is routed
71184
72−g1t agents can send every model request through
185+g1t agents send model requests through
73186 [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/). The
74187 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.
77191
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:
79193
80194 | Setting | What it does |
81195 | --- | --- |
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`. |
83197 | `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.
84220
85221 ## What a sandbox has
86222
+12−0
4141
4242 ## Private repositories
4343
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+
4456 A private repository is visible only to members of its workspace. To everyone
4557 else it looks
4658 exactly like a repository that does not exist, both on the site and to git.
+3−2
4343 git push -u g1t main
4444 ```
4545
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**.
4849
4950 ## 4. Open an issue
5051
+66−33
2424 ```json
2525 {
2626 "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}"
3131 }
3232 ```
3333
3737 [access token](https://g1t.sh/settings) as a bearer token:
3838
3939 ```sh
40−curl https://api.g1t.sh/v1/user \
40+curl https://api.g1t.sh/user \
4141 -H "Authorization: Bearer $G1T_TOKEN"
4242 ```
4343
5151
5252 | Method | Path | |
5353 | --- | --- | --- |
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`. |
5656
5757 Applications that can open a browser use OAuth instead. See
5858 [signing in with OAuth](/guides/authentication/#signing-in-with-oauth).
8888
8989 | Method | Path | |
9090 | --- | --- | --- |
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. |
97100
98101 ## Issues
99102
101104
102105 | Method | Path | |
103106 | --- | --- | --- |
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. |
112119
113120 ```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 \
115122 -H "Authorization: Bearer $G1T_TOKEN" \
116123 -H "Content-Type: application/json" \
117124 -d '{
140147
141148 | Method | Path | |
142149 | --- | --- | --- |
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. |
151158
152159 Opening a pull request returns the git remote of its fork:
153160
177184 that are still a draft or open are closed with `supersededBy` set. Send
178185 `"keep_issue_open": true` to merge without any of that.
179186
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+
180197 ### Checks
181198
182199 A pull request carries `checkStatus`: `queued`, `running`, `passed`, `failed`,
203220
204221 | Method | Path | |
205222 | --- | --- | --- |
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`. |
208225
209226 ```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 \
211228 -H "Authorization: Bearer $G1T_TOKEN" \
212229 -H "Content-Type: application/json" \
213230 -d '{"entries": [{"kind": "message", "text": "Reading src/main.rs."}]}'
223240 `pr_01jb2k7x9hfq0b3zj0f5s2m8ra`. They sort by creation time.
224241
225242 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.
+40−3
5555 background: color-mix(in srgb, var(--color-accent) 35%, transparent);
5656 }
5757
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+ }
6164 }
6265
6366 @media (prefers-reduced-motion: reduce) {
7073 }
7174
7275 /* Rendered markdown: READMEs, briefs and docs. */
76+.prose > :first-child {
77+ margin-top: 0;
78+ padding-top: 0;
79+}
7380 .prose {
7481 @apply leading-7 text-fg;
7582 }
122129 .prose hr {
123130 @apply border-line;
124131 }
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+}
125162
126163 /* Syntax-highlighted code from Shiki. */
127164 .shiki {
+339−0
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+}
+713−208
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";
415
5−import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts";
16+import type { Comment, Comparison, DiffLine } from "@g1t/contracts";
617
18+import type {
19+ HighlightedComparison,
20+ HighlightedFile,
21+ HighlightedLine,
22+} from "../lib/diff";
723 import { Markdown } from "./markdown";
824 import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui";
925
1026 const ROW_STYLES: Record<DiffLine["kind"], string> = {
1127 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",
1436 };
1537
1638 const MARKERS: Record<DiffLine["kind"], string> = {
1739 context: " ",
1840 add: "+",
19− delete: "-",
41+ delete: "−",
2042 };
2143
2244 /** Comments on lines of the change, and whether the viewer may add one. */
2345 export type LineReview = { comments: Comment[]; canComment: boolean };
2446
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 }) {
3369 const total = additions + deletions;
3470 const green = total === 0 ? 0 : Math.round((additions / total) * 5);
3571 return (
5086 );
5187 }
5288
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+
5397 /** One comment made on a line, shown under that line. */
5498 function LineComment({ comment }: { comment: Comment }) {
5599 return (
66110 );
67111 }
68112
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>
79156 );
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>
83169 );
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);
84180 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 (
85390 <section
391+ ref={section}
86392 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"
88395 >
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+ >
101416 {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+ )}
103424 <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>
104451 </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} />
132459 ))}
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+ )}
135471 </div>
136472 )}
137473 </section>
138474 );
139475 }
140476
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,
147516 }: {
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;
153522 }) {
154− // The line whose comment box is open, if any.
155− const [writing, setWriting] = useState<number | null>(null);
156523 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);
173542 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>
240560 );
241561 })}
242− </>
562+ </ul>
243563 );
244564 }
245565
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+ */
251571 export function DiffView({
252572 comparison,
253573 review,
574+ empty,
575+ fileBase,
254576 }: {
255577 comparison: Comparison;
256578 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;
257583 }) {
258584 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+
259698 if (files.length === 0) {
260699 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."}
264702 </EmptyState>
265703 );
266704 }
267705 const additions = files.reduce((sum, file) => sum + file.additions, 0);
268706 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
707+ const allCollapsed = files.every((file) => collapsed.has(file.path));
269708 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"}
275713 </span>
276714 <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+ />
280721 </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>
282755 </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+ />
297808 ))}
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>
308813 </div>
309814 );
310815 }
+23−3
7070 "The agent's full session, kept with the code",
7171 ],
7272 [
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+ [
7393 "Connecting an agent",
7494 "A vendor integration",
7595 "Any MCP client, or plain HTTP",
7696 ],
7797 ];
7898
79−const PLATFORM = ["Workers", "Artifacts", "D1", "Queues", "Rust"];
99+const PLATFORM = ["Workers", "Artifacts", "Containers", "D1", "Queues", "Rust"];
80100
81101 export function Landing({ repos }: { repos: Repo[] }) {
82102 return (
201221 <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" />
202222 <CopyLine
203223 prompt
204− text="curl https://api.g1t.sh/v1/repos/syntaqx/g1t/issues"
224+ text="curl https://api.g1t.sh/repos/syntaqx/g1t/issues"
205225 />
206226 <div className="pt-2">
207227 <EventsIllustration />
214234 <section className="mx-auto max-w-6xl px-4 py-24">
215235 <p className="text-sm font-medium text-accent">What changes</p>
216236 <h2 className="mt-2 text-3xl font-semibold tracking-tight">
217− The same git. Different primitives.
237+ The same git. Built for more hands.
218238 </h2>
219239 <div className="mt-10 overflow-hidden rounded-2xl border border-line">
220240 <div className="grid grid-cols-[1fr_1.4fr_1.4fr] border-b border-line bg-surface text-sm font-medium">
+131−0
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+}
+213−16
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";
212 import ReactMarkdown from "react-markdown";
313 import { Link } from "react-router";
14+import rehypeRaw from "rehype-raw";
15+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
416 import remarkGfm from "remark-gfm";
517
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+
629 /** An anchor id for a heading, so sections can be linked to. */
730 function slug(children: ReactNode): string {
8− const text = Array.isArray(children) ? children.join("") : String(children ?? "");
9− return text
31+ return textOf(children)
1032 .toLowerCase()
1133 .replace(/[^a-z0-9]+/g, "-")
1234 .replace(/^-|-$/g, "");
1335 }
1436
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+
1578 /**
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.
1881 */
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]);
20114 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 (
21169 <div className="prose">
22170 <ReactMarkdown
23171 remarkPlugins={[remarkGfm]}
172+ rehypePlugins={[rehypeRaw, [rehypeSanitize, SCHEMA], rehypeAlerts, [rehypeReferences, { repo }]]}
24173 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>
35215 );
36216 },
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+ },
37234 }}
38235 >
39236 {source}
+26−4
1−import { BookMarked, Lock } from "lucide-react";
1+import { BookMarked, CircleDot, GitPullRequest, Lock } from "lucide-react";
22 import { Link } from "react-router";
33
44 import type { Repo } from "@g1t/contracts";
55
66 import { EmptyState, Pill, TimeAgo } from "./ui";
77
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+}) {
916 if (repos.length === 0) {
1017 return (
1118 <div className="mt-4">
1825 {repos.map((repo) => (
1926 <li key={repo.id}>
2027 <Link
28+ prefetch="intent"
2129 to={`/${repo.namespace}/${repo.name}`}
2230 className="flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
2331 >
3644 <span className="mt-2 line-clamp-2 grow text-sm text-muted">
3745 {repo.description ?? "No description."}
3846 </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+ )}
4163 </span>
4264 </Link>
4365 </li>
+31−3
11 import { BookOpen, File, Folder, FolderGit2 } from "lucide-react";
22 import { Link } from "react-router";
33
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";
55
6+import { BlameView } from "./blame-view";
7+
68 import { CloneBox } from "./clone-box";
79 import { Markdown } from "./markdown";
810 import { Avatar, CopyLine, TimeAgo } from "./ui";
129131 </h2>
130132 <div className="p-6">
131133 {/\.(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+ />
133140 ) : (
134141 <pre className="whitespace-pre-wrap text-sm">{readme.text}</pre>
135142 )}
164171 export function BlobView({
165172 blob,
166173 html,
174+ blame,
167175 }: {
168176 blob: Blob;
169177 /** Syntax-highlighted HTML, when the language is known. */
170178 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;
171181 }) {
172182 const { repo, ref, path, size, text } = blob;
173183 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+ );
174194 return (
175195 <div>
176196 <Breadcrumbs
183203 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted">
184204 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
185205 <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+ )}
186212 </div>
187− {html ? (
213+ {blame && lines ? (
214+ <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} />
215+ ) : html ? (
188216 <div
189217 className="overflow-x-auto py-3 pr-4"
190218 // Shiki escapes the source; this is its generated markup.
+670−0
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+}
+99−7
1−import { Check, Copy } from "lucide-react";
1+import { Check, Copy, Sparkles } from "lucide-react";
22 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";
44
55 export function Field({
66 label,
2525 const CONTROL =
2626 "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";
2727
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+
2846 export function Input(props: ComponentProps<"input">) {
29− return <input {...props} className={CONTROL} />;
47+ return <input {...notACredential(props.autoComplete)} {...props} className={CONTROL} />;
3048 }
3149
3250 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+ );
3499 }
35100
36101 type Variant = "primary" | "accent" | "quiet";
85150
86151 const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
87152
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+ }
90181 let hash = 0;
91182 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
92183 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
93184 return (
94185 <span
95186 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"
97188 style={{
98189 width: size,
99190 height: size,
191+ borderRadius: square ? size * 0.24 : size,
100192 fontSize: size * 0.5,
101193 background: `oklch(0.4 0.09 ${hue})`,
102194 color: `oklch(0.93 0.08 ${hue})`,
+282−58
11 import {
2+ Bot,
23 CircleCheck,
34 CircleDot,
45 CircleSlash,
1213
1314 import type { Comment, Issue, Pull, State } from "@g1t/contracts";
1415
16+import { repoAt } from "../lib/markdown-plugins";
1517 import { Markdown } from "./markdown";
1618 import { Avatar, Button, Textarea, TimeAgo } from "./ui";
1719
4850 );
4951 }
5052
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+
5187 export function IssueIcon({
5288 issue,
5389 size = 16,
165201 * says where its changes are shown and whether the viewer may give a
166202 * verdict.
167203 */
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({
169295 comments,
170− canComment,
171296 review,
297+ base,
172298 }: {
173299 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;
176303 }) {
177304 return (
178− <div className="space-y-4">
305+ <>
179306 {comments.map((comment) => {
307+ if (comment.kind === "event") {
308+ return <TimelineEvent key={comment.id} comment={comment} base={base} />;
309+ }
180310 const verdict = comment.verdict && VERDICTS[comment.verdict];
181311 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 ? (
187318 <span className={`flex items-center gap-1 font-medium ${verdict.style}`}>
188319 {comment.verdict === "approve" ? (
189320 <CircleCheck size={14} />
194325 </span>
195326 ) : (
196327 <span>commented</span>
197− )}
198− <TimeAgo at={comment.createdAt} />
199− {comment.path && review && (
328+ )
329+ }
330+ aside={
331+ comment.path &&
332+ review && (
200333 <Link
201334 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"
203336 >
204337 {comment.path}
205338 {comment.line != null && `:${comment.line}`}
206339 </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>
215345 );
216346 })}
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>
252442 )}
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+ </>
254451 );
255452 }
256453
262459 }
263460 return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict }));
264461 }
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+}
+6−0
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[] };
+18−65
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";
272
283 const MAX_HIGHLIGHT_CHARS = 200_000;
294
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−
595 /**
606 * Highlighted HTML for a file, or null when its language is unknown or it
617 * is too large, in which case the caller shows plain text.
628 */
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);
6925 if (!lang || text.length > MAX_HIGHLIGHT_CHARS) return null;
7026 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);
7528 } catch {
7629 return null;
7730 }
+34−0
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+}
+124−0
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+}
+37−0
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+}
+2−1
11 import { env } from "cloudflare:workers";
22
3−import { identityClient, reposClient, workClient } from "@g1t/contracts";
3+import { billingClient, identityClient, reposClient, workClient } from "@g1t/contracts";
44
55 export const identity = identityClient(env.IDENTITY);
66 export const repos = reposClient(env.REPOS);
77 export const work = workClient(env.WORK);
8+export const billing = billingClient(env.BILLING);
+33−4
66 redirect,
77 } from "react-router";
88
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";
1010
1111 import { identity } from "./services.server";
1212
2525 return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
2626 }
2727
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+ */
2938 export const viewerMiddleware: MiddlewareFunction<Response> = async ({
3039 request,
3140 context,
3241 }) => {
3342 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}`);
3657 }
3758 };
3859
4263 return context.get(viewerContext);
4364 }
4465
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+
4574 export function requireUser(context: Context, request: Request): User {
4675 const viewer = getViewer(context);
4776 if (!viewer) {
+132−0
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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+}
+89−24
11 import {
22 BookOpen,
3− Building2,
43 ChevronDown,
54 LayoutDashboard,
65 LogOut,
1817 Outlet,
1918 Scripts,
2019 ScrollRestoration,
20+ type ShouldRevalidateFunctionArgs,
2121 useRouteLoaderData,
2222 useSubmit,
2323 } from "react-router";
2727 import type { Route } from "./+types/root";
2828 import "./app.css";
2929 import { Logo } from "./components/logo";
30−import { Avatar, ButtonLink } from "./components/ui";
30+import { Avatar, ButtonLink, notACredential } from "./components/ui";
3131 import {
3232 DropdownMenu,
3333 DropdownMenuContent,
3636 DropdownMenuSeparator,
3737 DropdownMenuTrigger,
3838 } 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";
4042
4143 export const links: Route.LinksFunction = () => [
4244 { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
5456
5557 export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware];
5658
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 };
5962 }
6063
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+
61114 function HeaderLink({ to, children }: { to: string; children: React.ReactNode }) {
62115 return (
63116 <NavLink
88141 />
89142 <input
90143 name="q"
144+ {...notACredential()}
91145 placeholder="Search repositories"
92146 aria-label="Search repositories"
93147 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"
126180 {(user.workspaces ?? []).map((membership) => (
127181 <DropdownMenuItem asChild key={membership.slug}>
128182 <Link to={`/${membership.slug}`}>
129− <Building2 />
183+ <Avatar name={membership.slug} size={16} square />
130184 {membership.slug}
131185 </Link>
132186 </DropdownMenuItem>
256310
257311 export function Layout({ children }: { children: React.ReactNode }) {
258312 // 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+ );
260330 return (
261331 <html lang="en">
262332 <head>
267337 <Links />
268338 </head>
269339 <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+ </>
285352 )}
286− <div className="grow">{children}</div>
287− <Footer />
288353 <ScrollRestoration />
289354 <Scripts />
290355 </body>
+16−2
1414 route("settings", "routes/settings.tsx"),
1515 route("explore", "routes/explore.tsx", { id: "explore" }),
1616 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"),
1928 route(":owner/:repo", "routes/repo/layout.tsx", [
2029 index("routes/repo/code.tsx"),
2130 route("tree/:ref/*", "routes/repo/tree.tsx"),
2231 route("blob/:ref/*", "routes/repo/blob.tsx"),
2332 route("commits", "routes/repo/commits.tsx"),
33+ route("commit/:hash", "routes/repo/commit.tsx"),
2434 route("issues", "routes/repo/issues.tsx"),
2535 route("issues/new", "routes/repo/issue-new.tsx"),
2636 route("issues/:number", "routes/repo/issue.tsx"),
2737 route("pulls", "routes/repo/pulls.tsx"),
2838 route("pulls/new", "routes/repo/pull-new.tsx"),
2939 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"),
3044 ]),
3145 ] satisfies RouteConfig;
+2−0
33
44 import type { Route } from "./+types/explore";
55 import { RepoList } from "../components/repo-list";
6+import { notACredential } from "../components/ui";
67 import { repos } from "../lib/services.server";
78 import { getViewer } from "../lib/session.server";
89
3738 />
3839 <input
3940 name="q"
41+ {...notACredential()}
4042 defaultValue={query}
4143 placeholder="Search by name or description"
4244 aria-label="Search repositories"
+188−59
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";
36
47 import type { Route } from "./+types/home";
58 import { Landing } from "../components/landing";
9+import { STAGE_LABEL, StageDots } from "../components/lifecycle";
610 import { RepoList } from "../components/repo-list";
711 import {
812 Avatar,
1115 EmptyState,
1216 TimeAgo,
1317 } from "../components/ui";
14−import { PullIcon } from "../components/work";
18+import { Assignee, IssueIcon, PullIcon } from "../components/work";
1519 import { repos as reposApi, work } from "../lib/services.server";
1620 import { getViewer } from "../lib/session.server";
1721
22+const REFRESH_MS = 5000;
23+
1824 export function meta({}: Route.MetaArgs) {
1925 return [
2026 { title: "g1t — Git for AI scale" },
2632 ];
2733 }
2834
35+export function headers({ loaderHeaders }: Route.HeadersArgs) {
36+ return { "Server-Timing": loaderHeaders.get("Server-Timing") ?? "" };
37+}
38+
2939 export async function loader({ context }: Route.LoaderArgs) {
3040 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)),
3849 ]);
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({
4464 viewer,
4565 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 }] : [];
4969 }),
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+ );
51125 }
52126
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+
53140 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+
55159 if (!viewer) return <Landing repos={repos} />;
56160
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);
58167 return (
59168 <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_20rem]">
60169 <div className="min-w-0 space-y-10">
66175 Mission control
67176 </h1>
68177 <p className="text-sm text-muted">
69− {working.length === 0
178+ {summary.length === 0
70179 ? "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(" · ")}.`}
72181 </p>
73182 </div>
74183 </div>
75184 </section>
76185
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}
106220 </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>
114243
115244 <section>
116245 <div className="flex items-center justify-between">
+12−1
3030 name: String(form.get("name") ?? ""),
3131 description: String(form.get("description") ?? ""),
3232 isPrivate: form.get("visibility") === "private",
33+ importUrl: String(form.get("importUrl") ?? "").trim() || undefined,
3334 });
3435 if (!result.ok) return { error: result.error.message };
3536 throw redirect(`/${result.value.namespace}/${result.value.name}`);
6465 <Field label="Description (optional)">
6566 <Input name="description" maxLength={200} />
6667 </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>
6778 <fieldset className="space-y-2 text-sm">
6879 <label className="flex items-center gap-2">
6980 <input type="radio" name="visibility" value="public" defaultChecked />
7182 </label>
7283 <label className="flex items-center gap-2">
7384 <input type="radio" name="visibility" value="private" />
74− Private — only you
85+ Private — only members of the workspace
7586 </label>
7687 </fieldset>
7788 <ErrorText>{actionData?.error}</ErrorText>
+1−1
5151 <Form method="post" className="space-y-4">
5252 <Field
5353 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."
5555 >
5656 <Input
5757 name="username"
+19−6
11 import type { Route } from "./+types/blob";
22 import { BlobView } from "../../components/repo-view";
3−import { highlight } from "../../lib/highlight.server";
3+import { highlight, highlightLines } from "../../lib/highlight.server";
44 import { repos } from "../../lib/services.server";
55 import { getViewer, unwrap } from "../../lib/session.server";
66
88 return [{ title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }];
99 }
1010
11−export async function loader({ params, context }: Route.LoaderArgs) {
11+export async function loader({ params, context, request }: Route.LoaderArgs) {
12+ const viewer = getViewer(context);
1213 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+ }
1628 return {
1729 blob,
1830 html: blob.text == null ? null : await highlight(blob.path, blob.text),
31+ blame: null,
1932 };
2033 }
2134
2235 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} />;
2437 }
+185−0
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+}
+64−28
1+import { GitCommitHorizontal } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { Commit } from "@g1t/contracts";
5+
16 import type { Route } from "./+types/commits";
27 import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
38 import { repos } from "../../lib/services.server";
1217 export async function loader({ params, context }: Route.LoaderArgs) {
1318 const path = { namespace: params.owner, name: params.repo };
1419 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)),
1821 };
1922 }
2023
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) {
2246 const { commits } = loaderData;
47+ const base = `/${params.owner}/${params.repo}`;
2348 if (commits.length === 0) {
2449 return <EmptyState title="No commits yet" />;
2550 }
2651 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>
5187 );
5288 }
+206−76
1818 TimeAgo,
1919 } from "../../components/ui";
2020 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";
2435
2536 const REFRESH_MS = 4000;
2637
3344 const viewer = getViewer(context);
3445 const path = { namespace: params.owner, name: params.repo };
3546 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+ ]);
3755 if (!found.ok) {
3856 // Issues and pull requests share numbers; this one may be a pull request.
3957 const pull = await work.getPull(path, number, viewer);
4058 if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
4159 throw new Response("Issue not found.", { status: 404 });
4260 }
43− const [labels, agentModels] = await Promise.all([
44− work.listLabels(path, viewer),
45− env.RUNNER.models(viewer),
46− ]);
4761 const { issue } = found.value;
4862 return {
4963 ...found.value,
5064 viewer,
5165 labels: labels.ok ? labels.value : [],
52− agentModels,
66+ agentsEnabled,
67+ members: members?.ok ? members.value.map((member) => member.username) : [],
5368 // The author and members of the workspace can change an issue.
5469 canManage:
5570 viewer != null &&
6883 switch (form.get("action")) {
6984 case "run-hosted": {
7085 const result = await env.RUNNER.run(user, path, number, {
71− count: Number(form.get("count")),
7286 instructions: String(form.get("instructions") ?? ""),
73− model: String(form.get("model") ?? "") || undefined,
7487 });
7588 return result.ok ? null : { error: result.error.message };
7689 }
89102 });
90103 return result.ok ? null : { error: result.error.message };
91104 }
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+ }
92114 case "labels": {
93115 const result = await work.updateIssue(user, path, number, {
94116 labels: [
144166 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
145167 )}
146168 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
169+ <ChangeSize files={pull.files} />
147170 <CheckBadge status={pull.checkStatus} />
148171 <span className="flex items-center gap-1 font-mono">
149172 <GitCommitHorizontal size={13} />
162185 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
163186 <span className="text-faint">· opened by {pull.author.username}</span>
164187 </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+ )}
166191 </Link>
167192 </li>
168193 );
169194 }
170195
171196 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;
173198
174199 // Follow agents at work without a manual reload.
175200 const revalidator = useRevalidator();
180205 pull.checkStatus === "queued" ||
181206 pull.checkStatus === "running",
182207 );
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+ );
183217 useEffect(() => {
184− if (!running) return;
218+ if (!running && !assigned) return;
185219 const timer = setInterval(() => {
186220 if (document.visibilityState === "visible") revalidator.revalidate();
187221 }, REFRESH_MS);
188222 return () => clearInterval(timer);
189− }, [running, revalidator]);
223+ }, [running, assigned, revalidator]);
190224
191225 const starting = navigation.formData?.get("action") === "run-hosted";
192226 const base = `/${params.owner}/${params.repo}`;
217251 {issue.labels.map((name) => (
218252 <Label key={name} name={name} />
219253 ))}
254+ {open && issue.agent && <Assignee agent={issue.agent} />}
220255 </div>
221256
222257 {issue.resolvedBy != null && (
242277
243278 {issue.body && (
244279 <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 }} />
246281 </div>
247282 )}
248283
273308
274309 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
275310 <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>
277333 </div>
278334 <div className="mt-2">
279335 <ErrorText>{actionData?.error}</ErrorText>
281337 </div>
282338
283339 <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." : "."}
293419 </p>
420+ )}
421+
422+ {open && agentsEnabled && !assigned && !issue.queued && (
294423 <Form method="post" className="mt-3 space-y-2">
295424 <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)" />
324425 <div className="*:w-full">
325426 <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"}
328429 </Button>
329430 </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>
330447 </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>
333481
334482 {open && (
335483 <section className="rounded-xl border border-line bg-surface p-4">
418566 </details>
419567 )}
420568
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− )}
439569 </aside>
440570 </div>
441571 );
+107−13
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";
34
45 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";
714 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;
919
1020 export function meta({ params }: Route.MetaArgs) {
1121 return [{ title: `Issues · ${params.owner}/${params.repo} · g1t` }];
1727 const query = new URL(request.url).searchParams;
1828 const state = query.get("state") === "closed" ? "closed" : "open";
1929 const label = query.get("label") ?? "";
20− const [issues, labels] = await Promise.all([
30+ const [issues, labels, agentsEnabled] = await Promise.all([
2131 work.listIssues(path, viewer, { state, label: label || undefined }),
2232 work.listLabels(path, viewer),
33+ env.RUNNER.enabled(viewer),
2334 ]);
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+ };
2569 }
2670
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;
2973 const repo = `/${params.owner}/${params.repo}`;
3074 const base = `${repo}/issues`;
3175 const stateQuery = state === "closed" ? "state=closed" : "";
76+ const assignable = agentsEnabled && state === "open" && issues.length > 0;
77+ const assigning = useNavigation().state === "submitting";
3278 return (
3379 <div>
3480 <StateTabs
62108 );
63109 })}
64110 </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+ )}
66137 {issues.length === 0 ? (
67138 <EmptyState
68139 title={
79150 ) : (
80151 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
81152 {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+ )}
83165 <Link
166+ prefetch="intent"
84167 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"
86169 >
87170 <span className="mt-0.5">
88171 <IssueIcon issue={issue} />
93176 {issue.labels.map((name) => (
94177 <Label key={name} name={name} />
95178 ))}
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+ )}
96189 </span>
97190 <span className="mt-0.5 block text-xs text-faint">
98191 #{issue.number} opened <TimeAgo at={issue.createdAt} /> by{" "}
103196 </span>
104197 </span>
105198 <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted">
199+ <AssigneeStack people={issue.assignees} />
106200 {issue.pullCount > 0 && (
107201 <span
108202 className="flex items-center gap-1"
124218 ))}
125219 </ul>
126220 )}
127− </div>
221+ </Form>
128222 </div>
129223 );
130224 }
+57−46
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";
412
513 import type { Route } from "./+types/layout";
6−import { Pill } from "../../components/ui";
14+import { Pill, TabLink as Tab } from "../../components/ui";
715 import { repos, work } from "../../lib/services.server";
8−import { getViewer, unwrap } from "../../lib/session.server";
16+import { getViewer, roleIn, unwrap } from "../../lib/session.server";
917
1018 export function meta({ params }: Route.MetaArgs) {
1119 return [{ title: `${params.owner}/${params.repo} · g1t` }];
2129 return {
2230 repo: unwrap(repo),
2331 open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
32+ member: roleIn(viewer, params.owner) != null,
2433 };
2534 }
2635
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−
6736 export default function RepoLayout({ loaderData }: Route.ComponentProps) {
68− const { repo, open } = loaderData;
37+ const { repo, open, member } = loaderData;
6938 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+ }
7071 return (
7172 <>
7273 {/* The repository's own header band, under the site header. */}
110111 <Tab to={`${base}/commits`} icon={<History size={15} />}>
111112 Commits
112113 </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+ )}
113124 </nav>
114125 </div>
115126 </div>
+234−0
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+}
+137−0
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+}
+525−99
88 CircleCheck,
99 CircleSlash,
1010 GitMerge,
11+ Layers,
12+ GitPullRequestArrow,
13+ Hand,
14+ Loader,
15+ Radar,
16+ Sparkles,
1117 MessageSquare,
1218 MessagesSquare,
1319 StickyNote,
1420 User,
1521 Wrench,
1622 } from "lucide-react";
17−import { useEffect } from "react";
23+import { useEffect, useState } from "react";
1824 import { Form, Link, redirect, useRevalidator } from "react-router";
1925
2026 import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts";
2127
2228 import type { Route } from "./+types/pull";
2329 import { DiffView } from "../../components/diff-view";
30+import { LifecyclePanel } from "../../components/lifecycle";
2431 import { Markdown } from "../../components/markdown";
2532 import {
2633 Avatar,
2734 Button,
35+ ButtonLink,
2836 CopyLine,
2937 EmptyState,
3038 ErrorText,
3240 TimeAgo,
3341 } from "../../components/ui";
3442 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";
3753 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
3854
3955 const REFRESH_MS = 4000;
5571 const asked = new URL(request.url).searchParams.get("tab");
5672 const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
5773
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([
5979 work.getPull(path, number, viewer),
6080 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,
6185 ]);
6286 if (!found.ok) {
6387 // Issues and pull requests share numbers; this one may be an issue.
7397 ? repos.compare(range.repoId, viewer, range.base, range.head)
7498 : null,
7599 ]);
76− const member = (viewer?.workspaces ?? []).some(
77− (membership) => membership.slug === params.owner,
78− );
79100 return {
80101 ...found.value,
81102 tab,
86107 // Members of the repository's workspace can merge.
87108 canMerge: member,
88109 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,
89119 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
90120 };
91121 }
99129 const action = form.get("action");
100130 const verdict = form.get("verdict");
101131 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+ }
102144 const result =
103145 action === "merge"
104146 ? await work.mergePull(user, path, number, {
105147 keepIssueOpen: form.get("keepIssueOpen") === "on",
106148 ignoreChecks: form.get("ignoreChecks") === "on",
107149 })
150+ : action === "unqueue"
151+ ? await work.removeFromQueue(user, path, number)
108152 : action === "close"
109153 ? await work.closePull(user, path, number)
110154 : action === "recheck"
111155 ? 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") })
112164 : action === "comment"
113165 ? await work.addComment(user, path, number, {
114166 body: String(form.get("body") ?? ""),
200252 <div className="mt-1">
201253 <Markdown source={entry.text} />
202254 </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>
203259 ) : (
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">
209261 {entry.text}
210262 </p>
211263 )}
221273 );
222274 }
223275
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+
224308 export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
225309 const {
226310 pull,
227311 issue,
228312 comments,
229313 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,
230327 tab,
231328 session,
232329 comparison,
249346 const reviews = verdicts(comments);
250347 // What stands between this pull request and a merge, if anything.
251348 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";
252366 useEffect(() => {
253− if (!working && !checking) return;
367+ if (!working && !checking && !reviewPending && !catchingUp && !moving && !landing) return;
254368 const timer = setInterval(() => {
255369 if (document.visibilityState === "visible") revalidator.revalidate();
256370 }, REFRESH_MS);
257371 return () => clearInterval(timer);
258− }, [working, checking, revalidator]);
372+ }, [working, checking, reviewPending, catchingUp, moving, landing, revalidator]);
259373
260374 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]"}`}>
262377 <div className="min-w-0">
263378 <h2 className="text-2xl font-semibold tracking-tight text-balance">
264379 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
266381 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
267382 <PullState status={pull.status} />
268383 <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+ />
270388 <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>{" "}
272392 {pull.status === "merged" ? "merged" : "wants to merge"}
273393 {pull.branch && (
274394 <>
306426 </p>
307427 )}
308428
429+ {lifecycle && <LifecyclePanel lifecycle={lifecycle} />}
430+
309431 {issue && (
310432 <Link
311433 to={`${base}/issues/${issue.number}`}
333455 <span>
334456 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
335457 <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, "}
336460 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
337461 {pull.headCommit && (
338462 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
353477 </p>
354478 )}
355479
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+
356505 <nav className="mt-8 flex gap-6 border-b border-line">
357506 <TabLink to={here} active={tab === "conversation"}>
358507 <MessageSquare size={15} />
391540 )
392541 ) : (
393542 <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>
404762 )}
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}
413768 />
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>}
414780 </div>
415781 )}
416782 {actionData?.action === "comment" && (
421787 </div>
422788 </div>
423789
424− <aside className="space-y-6">
790+ <aside className={tab === "changes" ? "hidden" : "space-y-6"}>
425791 <ChecksPanel
426792 run={checks}
427793 commands={issue?.checks ?? []}
429795 />
430796 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
431797
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>
448850 )}
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>
468876
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>
493888 )}
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>
496922
497923 <section>
498924 <h3 className="text-sm font-medium">Working copy</h3>
499925 {pull.branch ? (
500926 <>
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]" />
503929 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
504930 repository. Pushes to it show up here.
505931 </p>
+5−1
44 import type { Route } from "./+types/pulls";
55 import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
66 import { CheckBadge } from "../../components/checks";
7−import { PullIcon, StateTabs } from "../../components/work";
7+import { ChangeSize, PullIcon, StateTabs } from "../../components/work";
88 import { work } from "../../lib/services.server";
99 import { getViewer, unwrap } from "../../lib/session.server";
1010
5151 {pulls.map((pull) => (
5252 <li key={pull.id}>
5353 <Link
54+ prefetch="intent"
5455 to={`${base}/pull/${pull.number}`}
5556 className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface"
5657 >
7374 {pull.supersededBy != null && <> · superseded by #{pull.supersededBy}</>}
7475 </span>
7576 </span>
77+ <span className="mt-0.5 hidden sm:block">
78+ <ChangeSize files={pull.files} />
79+ </span>
7680 <span className="mt-0.5">
7781 <CheckBadge status={pull.checkStatus} />
7882 </span>
+229−0
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+}
+296−0
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+}
+95−0
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+}
+35−5
11 import { identity } from "../lib/services.server";
2−import { Form } from "react-router";
2+import { Form, Link } from "react-router";
33
44 import type { Route } from "./+types/settings";
55 import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui";
4040 case "add-token": {
4141 const created = await identity.createAccessToken(
4242 user,
43− String(form.get("name") ?? ""),
43+ String(form.get("label") ?? ""),
4444 );
4545 return { newToken: created.token };
4646 }
117117 <h2 className="font-medium">Access tokens</h2>
118118 <p className="mt-1 text-sm text-muted">
119119 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.
121121 </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+ )}
122140 {actionData?.newToken && (
123141 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
124142 <p className="text-sm">Copy it now. It will not be shown again.</p>
130148 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
131149 {tokens.map((token) => (
132150 <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>
134164 <DeleteButton intent="delete-token" id={token.id} />
135165 </li>
136166 ))}
139169 <input type="hidden" name="intent" value="add-token" />
140170 <div className="grow">
141171 <Field label="Name">
142− <Input name="name" maxLength={100} placeholder="laptop" />
172+ <Input name="label" maxLength={100} placeholder="laptop" />
143173 </Field>
144174 </div>
145175 <Button type="submit">Create token</Button>
+3−2
3636 Email confirmed
3737 </h1>
3838 <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.
4041 </p>
4142 <div className="mt-8">
42− <ButtonLink to="/">Go to g1t</ButtonLink>
43+ <ButtonLink to="/">Continue</ButtonLink>
4344 </div>
4445 </>
4546 ) : (
+0−70
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−}
+0−128
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−}
+204−0
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+}
+87−0
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+}
+79−0
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+}
+202−0
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+}
+81−0
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+}
+61−0
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+}
+185−0
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+}
+5−1
1313 },
1414 "dependencies": {
1515 "@g1t/contracts": "*",
16+ "@g1t/theme": "*",
1617 "class-variance-authority": "^0.7.1",
1718 "clsx": "^2.1.1",
1819 "isbot": "^5.1.36",
1920 "lucide-react": "^1.49.0",
21+ "mdast-util-find-and-replace": "^3.0.2",
2022 "radix-ui": "^1.6.7",
2123 "react": "^19.2.8",
2224 "react-dom": "^19.2.8",
2325 "react-markdown": "^10.1.0",
2426 "react-router": "^8.4.0",
27+ "rehype-raw": "^7.0.0",
28+ "rehype-sanitize": "^6.0.0",
2529 "remark-gfm": "^4.0.1",
2630 "shiki": "^4.5.0",
2731 "tailwind-merge": "^3.7.0",
28− "@g1t/theme": "*"
32+ "unist-util-visit": "^5.1.0"
2933 },
3034 "devDependencies": {
3135 "@cloudflare/vite-plugin": "^1.62.4",
+15−8
1515 1. **Start a sign-in.**
1616
1717 ```sh
18− curl -X POST https://api.g1t.sh/v1/device/code \
18+ curl -X POST https://api.g1t.sh/device/code \
1919 -H "Content-Type: application/json" \
2020 -d '{"client_name": "Claude Code"}'
2121 ```
3737 3. **Collect the token.** Poll every `interval` seconds, not faster:
3838
3939 ```sh
40− curl -X POST https://api.g1t.sh/v1/device/token \
40+ curl -X POST https://api.g1t.sh/device/token \
4141 -H "Content-Type: application/json" \
4242 -d '{"device_code": "DEVICE_CODE"}'
4343 ```
6161 person in through their browser instead (in Claude Code: `/mcp`, then
6262 choose g1t). The MCP server always needs one or the other.
6363
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
6565 repositories and is the first part of their address. Ask the person what
6666 to call it; their username is a sensible default.
6767
6868 ```sh
69− curl -X POST https://api.g1t.sh/v1/workspaces \
69+ curl -X POST https://api.g1t.sh/workspaces \
7070 -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
7171 -d '{"slug": "WORKSPACE"}'
7272 ```
7373
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
7579 workspace the person belongs to, creates it, public by default.
7680
7781 ```sh
8892
8993 Issues and pull requests are addressed by repository and number, and share
9094 one sequence of numbers: `#12` is one or the other. Below, `{repo}` stands
91−for `/v1/repos/{owner}/{name}`.
95+for `/repos/{owner}/{name}`.
9296
9397 - **Find work:** `GET {repo}/issues?state=open`, optionally `&label=bug`.
9498 - **Open an issue:** `POST {repo}/issues` with `title`, `body`, and
112116 - **Mark it ready:** `POST {repo}/pulls/{number}/ready` with `summary`, which
113117 becomes the pull request's description.
114118 - **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.
115122 - **Checks:** once a pull request is ready, g1t runs the issue's `checks`
116123 against it in a clean sandbox. `GET {repo}/pulls/{number}` returns
117124 `checks.results`, each with `passed` and `output`. If they failed, push a
130137 `https://g1t.sh/{owner}/{name}.git` into the fork, push, and merge again.
131138
132139 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`,
134141 `list_labels`, `add_comment`, `list_pull_requests`, `get_pull_request`,
135142 `create_pull_request`, `record_session`, `read_session`,
136143 `mark_pull_request_ready`, `close_pull_request`,
137144 `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`.
139146 MCP tools take the repository as `repo`, written `owner/name`.
140147
141148 ## Facts
+1−0
88 REPOS: ServiceBinding & { fetch(request: Request): Promise<Response> };
99 WORK: ServiceBinding;
1010 RUNNER: RunnerApi;
11+ BILLING: ServiceBinding;
1112 }
1213 }
1314 interface Env extends Cloudflare.Env {}
+5−1
33 "name": "g1t",
44 "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
55 "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" },
69 "main": "./workers/app.ts",
710 "routes": [{ "pattern": "g1t.sh", "custom_domain": true }],
811 // The site holds no data of its own; everything goes through services.
1013 { "binding": "IDENTITY", "service": "g1t-identity" },
1114 { "binding": "REPOS", "service": "g1t-repos" },
1215 { "binding": "WORK", "service": "g1t-work" },
13− { "binding": "RUNNER", "service": "g1t-runner" }
16+ { "binding": "RUNNER", "service": "g1t-runner" },
17+ { "binding": "BILLING", "service": "g1t-billing" }
1418 ],
1519 "observability": { "enabled": true },
1620 "upload_source_maps": true
+156−0
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+}
+32−2
4848 pub default_branch: bool,
4949 }
5050
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.
5353 #[derive(Debug, Default, Serialize)]
5454 #[serde(rename_all = "camelCase")]
5555 pub struct IssueEvent {
6464 /// On close: the number of the pull request whose merge closed it.
6565 #[serde(skip_serializing_if = "Option::is_none")]
6666 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>>,
6770 }
6871
6972 /// The payload of `pull.opened`, `pull.ready`, `pull.updated` (its head
101104 pub commit: String,
102105 }
103106
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+
104120 /// `comment.created`. `number` is the issue or pull request commented on.
105121 #[derive(Debug, Serialize)]
106122 #[serde(rename_all = "camelCase")]
108124 pub comment_id: String,
109125 pub repo_id: String,
110126 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>,
111133 }
112134
113135 #[derive(Debug, Serialize)]
180202 #[serde(default)]
181203 pub limit: Option<u32>,
182204 }
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+}
+74−1
2424 pub name: String,
2525 /// RFC 3339.
2626 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>,
2732 }
2833
2934 /// `sign_in`: verifies a username and password for website sign-in.
96101 pub id: String,
97102 }
98103
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.
100107 #[derive(Debug, Serialize, Deserialize)]
101108 #[serde(rename_all = "camelCase")]
102109 pub struct CreateAccessTokenArgs {
222229 pub id: String,
223230 pub slug: String,
224231 pub name: String,
232+ /// One line saying what the workspace is for.
233+ pub description: Option<String>,
225234 /// RFC 3339.
226235 pub created_at: String,
227236 pub member_count: u32,
264273 pub username: String,
265274 }
266275
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+
267312 /// `oauth_authorize`: the signed-in person approved an application. The
268313 /// caller has checked the client and that it may be redirected to
269314 /// `redirect_uri`. Returns `OAuthCode`.
326371 /// RFC 3339.
327372 pub last_used_at: String,
328373 }
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";
+20−0
44 //! arguments of each of its methods. Services and their callers depend on
55 //! this crate, never on each other's code.
66
7+pub mod billing;
78 pub mod events;
89 pub mod identity;
910 mod ids;
3738 pub role: Role,
3839 }
3940
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+
4058 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
4159 pub struct User {
4260 pub id: String,
4361 pub username: String,
62+ #[serde(default)]
63+ pub kind: PrincipalKind,
4464 /// Whether the account's email address has been confirmed. Unverified
4565 /// accounts can sign in but cannot create or change anything.
4666 #[serde(default)]
+2−0
5656 })
5757 && !value.starts_with('.')
5858 && !value.ends_with(".git")
59+ // `/<workspace>/-/…` holds the workspace's own pages.
60+ && value != "-"
5961 }
+3−0
99 Unauthenticated,
1010 Conflict,
1111 Invalid,
12+ /// The workspace has to pay before this can happen.
13+ PaymentRequired,
1214 }
1315
1416 impl FailureCode {
1921 FailureCode::Unauthenticated => 401,
2022 FailureCode::Conflict => 409,
2123 FailureCode::Invalid => 422,
24+ FailureCode::PaymentRequired => 402,
2225 }
2326 }
2427 }
+74−0
2020 pub default_branch: String,
2121 /// Set when this repo is a pull request's working copy of another repo.
2222 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,
2327 /// RFC 3339.
2428 pub created_at: String,
2529 }
162166 pub description: Option<String>,
163167 #[serde(default)]
164168 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>,
165173 }
166174
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+
167191 /// `tree`. Returns `Outcome<TreeView>`.
168192 #[derive(Debug, Serialize, Deserialize)]
169193 #[serde(rename_all = "camelCase")]
307331 pub head: Option<String>,
308332 }
309333
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+
310370 /// A branch and the commit it points to.
311371 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
312372 pub struct Branch {
322382 pub viewer: Viewer,
323383 }
324384
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+
325399 /// `head`: the commit a branch points to, or null. For services reacting
326400 /// to a push, which have no viewer; it reveals nothing but a commit hash.
327401 /// Returns `Option<String>`.
+673−3
88
99 use serde::{Deserialize, Serialize};
1010
11−use crate::repos::RepoPath;
11+use crate::repos::{CompareArgs, RepoPath};
1212 use crate::{User, Viewer};
1313
1414 /// Labels every repository starts with. Any other label comes into being
7474 /// Pull requests made against this issue, in any state.
7575 pub pull_count: u32,
7676 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>,
7792 }
7893
7994 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
151166 /// Where the latest run of the issue's acceptance checks stands, if
152167 /// there has been one against the current head.
153168 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>,
154179 pub author: User,
155180 /// RFC 3339.
156181 pub created_at: String,
158183 pub updated_at: String,
159184 }
160185
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+
161234 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
162235 #[serde(rename_all = "lowercase")]
163236 pub enum CheckStatus {
234307 }
235308 }
236309
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+
237321 /// A comment on an issue or a pull request. On a pull request it can sit
238322 /// on one line of the change, and it can carry a reviewer's verdict.
239323 #[derive(Clone, Debug, Serialize, Deserialize)]
240324 #[serde(rename_all = "camelCase")]
241325 pub struct Comment {
242326 pub id: String,
327+ /// Something a person wrote, or something that happened.
328+ #[serde(default)]
329+ pub kind: CommentKind,
243330 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".
245333 pub body: String,
246334 /// The file commented on, for a comment on a line.
247335 pub path: Option<String>,
302390 pub comments: Vec<Comment>,
303391 /// The latest run of the issue's acceptance checks.
304392 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 },
305472 }
306473
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+
307591 /// `open_issue`. Returns `Outcome<Issue>`.
308592 #[derive(Debug, Serialize, Deserialize)]
309593 pub struct OpenIssueArgs {
375659 pub body: Option<String>,
376660 #[serde(default)]
377661 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>>,
378666 }
379667
380668 /// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`.
451739 pub ignore_checks: bool,
452740 }
453741
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+
454903 /// `start_checks`: begins a run of the acceptance checks for a pull request
455904 /// that is ready for review. Called by the runner service, which starts the
456905 /// sandbox. Returns `Outcome<CheckJob>`.
500949 pub struct ActivePull {
501950 pub pull: Pull,
502951 pub issue: Option<Issue>,
952+ /// Where it stands, for one g1t is seeing through.
953+ #[serde(default)]
954+ pub lifecycle: Option<Lifecycle>,
503955 }
504956
505957 /// `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>`.
507961 #[derive(Debug, Serialize, Deserialize)]
508962 pub struct ViewerArgs {
509963 pub viewer: Viewer,
523977 pub count: u32,
524978 }
525979
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+
5261039 /// Lowercases, trims and de-duplicates labels, dropping empty ones.
5271040 /// Returns `None` if there are too many or one is too long.
5281041 pub fn normalize_labels(labels: &[String]) -> Option<Vec<String>> {
5691082 assert_eq!(normalize_labels(&many), None);
5701083 }
5711084 }
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+}
+1−1
33 version = "0.1.0"
44 edition.workspace = true
55 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."
77
88 [dependencies]
99 anyhow = "1"
+6−6
3131
3232 #[derive(Debug, Serialize)]
3333 #[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,
3737 exit_code: Option<i32>,
3838 output: String,
3939 duration_ms: u64,
4949 format!("… (earlier output not shown)\n{kept}")
5050 }
5151
52−fn redact(text: &str, secrets: &[String]) -> String {
52+pub(crate) fn redact(text: &str, secrets: &[String]) -> String {
5353 secrets.iter().fold(text.to_owned(), |text, secret| {
5454 text.replace(secret, "[redacted]")
5555 })
5656 }
5757
5858 /// 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 {
6060 let started = Instant::now();
6161 let output = Command::new("timeout")
6262 .args([
147147 pub fn main() -> i32 {
148148 let reporter = match (env("G1T_API"), env("CHECK_RUN"), env("CHECK_TOKEN")) {
149149 (Ok(api), Ok(run), Ok(token)) => Reporter {
150− url: format!("{api}/v1/checks/{run}"),
150+ url: format!("{api}/checks/{run}"),
151151 token,
152152 },
153153 _ => {
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.