Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step
- MCP: 103 tools become 13 resource tools with an action, filtered to what the token may do, with read-only and destructive hints. - Tokens: personal, workspace and OAuth tokens carry scopes (read and write per resource, admin apart), checked on REST, MCP and git; presets and expiry; untick-only consent; legacy tokens keep full access until narrowed. - Confidence: every agent change is rated high, medium or low from what g1t can see and what the agent says, and low ones wait for a person before merging. - Put an agent on it: one action opens the issue and starts g1t-agent, from Mission control, New issue, the palette, the API and MCP.
| 9 | 9 | use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 10 | 10 | use g1t_contracts::credentials::{Decision, as_person, decide_operation, is_read}; | |
| 11 | 11 | use g1t_contracts::repos::RepoPath; | |
| 12 | + | use g1t_contracts::scopes; | |
| 12 | 13 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 13 | 14 | use serde_json::Value; | |
| 14 | 15 | use worker::{Request, Result, console_error}; | |
| 103 | 104 | } | |
| 104 | 105 | } | |
| 105 | 106 | ||
| 106 | − | /// Whether the API refused an agent before anything ran, and why. | |
| 107 | + | /// Whether the API refused a caller before anything ran, and why: an | |
| 108 | + | /// agent against its run's scope, or an access token against its scopes. | |
| 109 | + | /// `None` for a signed-in session, which only the person's role limits. | |
| 110 | + | /// A token reaches whatever the one it acts as can reach; the service | |
| 111 | + | /// that owns what was asked about checks that. | |
| 107 | 112 | pub fn decide(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Option<Decision> { | |
| 108 | − | let user = viewer | |
| 109 | − | .as_ref() | |
| 110 | − | .filter(|user| user.kind == PrincipalKind::Agent)?; | |
| 111 | − | let scope = services.scope.as_ref()?; | |
| 112 | − | Some(decide_operation( | |
| 113 | − | user, | |
| 114 | − | scope, | |
| 115 | − | op.name(), | |
| 116 | − | repo_path(input).as_ref(), | |
| 117 | − | op.needs_repo(), | |
| 118 | − | number(input), | |
| 119 | − | )) | |
| 113 | + | let user = viewer.as_ref()?; | |
| 114 | + | if user.kind == PrincipalKind::Agent { | |
| 115 | + | let scope = services.scope.as_ref()?; | |
| 116 | + | return Some(decide_operation( | |
| 117 | + | user, | |
| 118 | + | scope, | |
| 119 | + | op.name(), | |
| 120 | + | repo_path(input).as_ref(), | |
| 121 | + | op.needs_repo(), | |
| 122 | + | number(input), | |
| 123 | + | )); | |
| 124 | + | } | |
| 125 | + | token_decision(op, user, input) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// What an access token's scopes decide about a call; `None` for a | |
| 129 | + | /// caller without one. | |
| 130 | + | fn token_decision(op: Op, user: &User, input: &Value) -> Option<Decision> { | |
| 131 | + | let access = user.token.as_deref()?; | |
| 132 | + | Some(scopes::decide(access, op.name(), input)) | |
| 120 | 133 | } | |
| 121 | 134 | ||
| 135 | + | /// The scope a refused call lacked, for the error the caller is sent. | |
| 136 | + | pub fn missing_scope(op: Op, viewer: &Viewer, input: &Value) -> Option<scopes::Scope> { | |
| 137 | + | let access = viewer.as_ref()?.token.as_deref()?; | |
| 138 | + | scopes::needed(op.name(), input) | |
| 139 | + | .into_iter() | |
| 140 | + | .find(|scope| !access.allows(*scope)) | |
| 141 | + | } | |
| 142 | + | ||
| 122 | 143 | /// Runs `op` for `viewer`: enforcing an agent's scope first, and recording | |
| 123 | 144 | /// what happened. | |
| 124 | 145 | pub async fn run( | |
| 212 | 233 | use super::*; | |
| 213 | 234 | use g1t_contracts::credentials::{Acting, Principal}; | |
| 214 | 235 | use g1t_contracts::identity::AgentScope; | |
| 236 | + | use g1t_contracts::scopes::{Scope, TokenAccess}; | |
| 215 | 237 | use serde_json::json; | |
| 216 | 238 | ||
| 217 | 239 | #[test] | |
| 235 | 257 | ); | |
| 236 | 258 | } | |
| 237 | 259 | ||
| 260 | + | fn with_token(scopes: Option<&[Scope]>, legacy: bool) -> User { | |
| 261 | + | User { | |
| 262 | + | id: "usr_1".to_owned(), | |
| 263 | + | username: "syntaqx".to_owned(), | |
| 264 | + | token: Some(Box::new(TokenAccess { | |
| 265 | + | token_id: "tok_1".to_owned(), | |
| 266 | + | scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 267 | + | legacy, | |
| 268 | + | })), | |
| 269 | + | ..User::default() | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[test] | |
| 274 | + | fn a_session_is_limited_only_by_the_person_s_role() { | |
| 275 | + | let person = User { id: "usr_1".to_owned(), ..User::default() }; | |
| 276 | + | assert!(token_decision(Op::DeleteRepo, &person, &json!({})).is_none()); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[test] | |
| 280 | + | fn a_legacy_token_still_does_everything() { | |
| 281 | + | let legacy = with_token(None, true); | |
| 282 | + | for op in Op::ALL { | |
| 283 | + | let decision = token_decision(op, &legacy, &json!({ "repo": "acme/rocket" })).unwrap(); | |
| 284 | + | assert!(decision.allowed, "{}", op.name()); | |
| 285 | + | assert_eq!(decision.rule, "token:legacy"); | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn a_token_without_the_scope_is_refused_and_told_which() { | |
| 291 | + | let reader = with_token(Some(&[Scope::IssuesRead]), false); | |
| 292 | + | let input = json!({ "repo": "acme/rocket", "title": "x" }); | |
| 293 | + | assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed); | |
| 294 | + | let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap(); | |
| 295 | + | assert!(!refused.allowed); | |
| 296 | + | assert!(refused.reason.unwrap().contains("issues:write")); | |
| 297 | + | assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite)); | |
| 298 | + | assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn a_token_reaches_what_its_owner_can() { | |
| 303 | + | // Scopes are the only limit a token adds: which workspaces and | |
| 304 | + | // repositories it reaches is the owner's, decided downstream. | |
| 305 | + | let admin = with_token(Some(&[Scope::RepoAdmin]), false); | |
| 306 | + | let moved = token_decision(Op::TransferRepo, &admin, &json!({ "repo": "acme/rocket", "to": "elsewhere" })).unwrap(); | |
| 307 | + | assert!(moved.allowed); | |
| 308 | + | assert_eq!(moved.rule, "token:scope"); | |
| 309 | + | let full = with_token(None, false); | |
| 310 | + | assert!(token_decision(Op::ListWebhooks, &full, &json!({ "workspace": "other" })).unwrap().allowed); | |
| 311 | + | } | |
| 312 | + | ||
| 238 | 313 | #[test] | |
| 239 | 314 | fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() { | |
| 240 | 315 | let agent = User { |
| 14 | 14 | #[cfg(test)] | |
| 15 | 15 | mod responses; | |
| 16 | 16 | mod rest; | |
| 17 | + | mod tools; | |
| 17 | 18 | ||
| 18 | 19 | use g1t_contracts::billing::FinishRunArgs; | |
| 19 | 20 | use g1t_contracts::identity::{ | |
| 607 | 608 | Outcome::Fail(refused) => failure(&refused), | |
| 608 | 609 | }; | |
| 609 | 610 | } | |
| 611 | + | // How sure a run's agent is of its change; the same token. | |
| 612 | + | ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/confidence") => { | |
| 613 | + | let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/confidence"); | |
| 614 | + | let body = json_body(&mut request).await; | |
| 615 | + | let said = json!({ | |
| 616 | + | "runId": run_id, | |
| 617 | + | "token": body["token"].as_str().unwrap_or_default(), | |
| 618 | + | "confidence": body["confidence"].as_str().unwrap_or_default(), | |
| 619 | + | "uncertainAbout": body["uncertain_about"] | |
| 620 | + | .as_array() | |
| 621 | + | .map(|items| items.iter().filter_map(Value::as_str).collect::<Vec<_>>()) | |
| 622 | + | .unwrap_or_default(), | |
| 623 | + | }); | |
| 624 | + | let recorded: Outcome<Value> = g1t_kit::call(&services.work, "report_confidence", &said).await?; | |
| 625 | + | return match recorded { | |
| 626 | + | Outcome::Ok(recorded) => reply(&json!({ "recorded": recorded })), | |
| 627 | + | Outcome::Fail(refused) => failure(&refused), | |
| 628 | + | }; | |
| 629 | + | } | |
| 610 | 630 | ("POST", path) if path.starts_with("/checks/") => { | |
| 611 | 631 | let run_id = path.trim_start_matches("/checks/").to_owned(); | |
| 612 | 632 | return report_checks(&mut request, &services, &run_id).await; | |
| 643 | 663 | }; | |
| 644 | 664 | match audit::run(route.op, &services, &viewer, &input).await? { | |
| 645 | 665 | Outcome::Ok(value) => reply(&value), | |
| 646 | − | Outcome::Fail(refused) => failure(&refused), | |
| 666 | + | // A token without the scope a call needs is told which one. | |
| 667 | + | Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) { | |
| 668 | + | (FailureCode::Forbidden, Some(scope)) => Ok(reply(&json!({ | |
| 669 | + | "error": { | |
| 670 | + | "code": refused.code, | |
| 671 | + | "message": refused.message, | |
| 672 | + | "needed_scope": scope.as_str(), | |
| 673 | + | } | |
| 674 | + | }))? | |
| 675 | + | .with_status(403)), | |
| 676 | + | _ => failure(&refused), | |
| 677 | + | }, | |
| 647 | 678 | } | |
| 648 | 679 | } | |
| 649 | 680 |
| 7 | 7 | ||
| 8 | 8 | use crate::oauth::MCP_CHALLENGE; | |
| 9 | 9 | use crate::operations::{Op, Services}; | |
| 10 | + | use crate::tools::{Gate, TOOLS, Tool}; | |
| 10 | 11 | ||
| 11 | 12 | const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 12 | 13 | ||
| 13 | − | const INSTRUCTIONS: &str = "g1t is a git forge with issues and pull requests, built so that many agents can work on the same issue at once. | |
| 14 | − | 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. | |
| 16 | − | Before you start, recall what the project and its workspace remember; when you learn something the next agent would need, remember it (scope project for this codebase, workspace for what holds across projects). Never a secret. | |
| 17 | − | Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers."; | |
| 14 | + | const INSTRUCTIONS: &str = "g1t is a git forge where people and agents work through issues and pull requests. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers. | |
| 15 | + | Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, webhook, access, workspace, account. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 16 | + | Find a repository: account whoami lists your workspaces; repository list or search finds one. | |
| 17 | + | Work on an issue: issue get (read it and the pull requests already made for it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Record your reasoning with pull_request record_session as you go, push, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and read the acceptance checks' output there; push a fix if they fail. | |
| 18 | + | Hand work to g1t's agent: agent delegate opens an issue and starts it in one step; agent assign starts it on an existing issue. Each costs the workspace money. | |
| 19 | + | When you learn something the next agent needs, memory remember it (scope project or workspace). Never a secret."; | |
| 18 | 20 | ||
| 19 | 21 | fn result(id: &Value, value: Value) -> Value { | |
| 20 | 22 | json!({ "jsonrpc": "2.0", "id": id, "result": value }) | |
| 24 | 26 | json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 29 | + | /// What decides the actions a caller sees: an agent's run scope, an | |
| 30 | + | /// access token's scopes, or nothing beyond the person's role. | |
| 31 | + | fn gate<'a>(services: &'a Services, viewer: &'a Viewer) -> Gate<'a> { | |
| 32 | + | if let Some(scope) = &services.scope { | |
| 33 | + | return Gate::Agent(scope); | |
| 34 | + | } | |
| 35 | + | match viewer.as_ref().and_then(|user| user.token.as_deref()) { | |
| 36 | + | Some(access) => Gate::Token(access), | |
| 37 | + | None => Gate::Everything, | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 27 | 41 | /// Answers one JSON-RPC request, or `None` for a notification. | |
| 28 | 42 | async fn answer(services: &Services, viewer: &Viewer, request: &Value) -> Result<Option<Value>> { | |
| 29 | 43 | // Notifications carry no id and get no response. | |
| 50 | 64 | } | |
| 51 | 65 | "ping" => result(id, json!({})), | |
| 52 | 66 | "tools/list" => { | |
| 53 | − | // An agent sees only the tools its token may use. | |
| 54 | − | let tools: Vec<Value> = Op::ALL | |
| 55 | − | .into_iter() | |
| 56 | − | .filter(|op| services.scope.as_ref().is_none_or(|scope| op.allowed_by(scope))) | |
| 57 | − | .map(|op| { | |
| 58 | − | json!({ | |
| 59 | − | "name": op.name(), | |
| 60 | − | "description": op.description(), | |
| 61 | − | "inputSchema": op.input(), | |
| 62 | − | }) | |
| 63 | − | }) | |
| 64 | − | .collect(); | |
| 67 | + | // A caller sees the tools, and the actions of each, that its | |
| 68 | + | // token may use. | |
| 69 | + | let gate = gate(services, viewer); | |
| 70 | + | let tools: Vec<Value> = TOOLS.iter().filter_map(|tool| tool.listed(&gate)).collect(); | |
| 65 | 71 | result(id, json!({ "tools": tools })) | |
| 66 | 72 | } | |
| 67 | 73 | "tools/call" => { | |
| 68 | − | let Some(op) = Op::by_name(params["name"].as_str().unwrap_or_default()) else { | |
| 69 | − | return Ok(Some(error(id, -32602, "Unknown tool."))); | |
| 74 | + | let name = params["name"].as_str().unwrap_or_default(); | |
| 75 | + | let arguments = ¶ms["arguments"]; | |
| 76 | + | let op = match Tool::by_name(name) { | |
| 77 | + | Some(tool) => match crate::tools::resolve(tool, arguments) { | |
| 78 | + | Ok(op) => op, | |
| 79 | + | Err(problem) => { | |
| 80 | + | return Ok(Some(result( | |
| 81 | + | id, | |
| 82 | + | json!({ "content": [{ "type": "text", "text": problem }], "isError": true }), | |
| 83 | + | ))); | |
| 84 | + | } | |
| 85 | + | }, | |
| 86 | + | // A tool per operation, as the server had before its | |
| 87 | + | // resource tools. Still answered, no longer listed. | |
| 88 | + | None => match Op::by_name(name) { | |
| 89 | + | Some(op) => op, | |
| 90 | + | None => return Ok(Some(error(id, -32602, "Unknown tool."))), | |
| 91 | + | }, | |
| 70 | 92 | }; | |
| 71 | − | let outcome = crate::audit::run(op, services, viewer, ¶ms["arguments"]).await?; | |
| 93 | + | let outcome = crate::audit::run(op, services, viewer, arguments).await?; | |
| 72 | 94 | // A failed operation is a tool result the model can read and | |
| 73 | 95 | // act on, not a protocol error. | |
| 74 | 96 | let (text, failed) = match outcome { | |
| 93 | 115 | /// What someone sees when they open the server's address in a browser: | |
| 94 | 116 | /// what this is, how to connect, and what it offers. | |
| 95 | 117 | fn card() -> Value { | |
| 96 | − | let tools: Vec<Value> = Op::ALL | |
| 97 | − | .into_iter() | |
| 98 | − | .map(|op| json!({ "name": op.name(), "description": op.description() })) | |
| 118 | + | let tools: Vec<Value> = TOOLS | |
| 119 | + | .iter() | |
| 120 | + | .map(|tool| { | |
| 121 | + | let actions: Vec<&crate::tools::Action> = tool.actions.iter().collect(); | |
| 122 | + | json!({ | |
| 123 | + | "name": tool.name, | |
| 124 | + | "title": tool.title, | |
| 125 | + | "description": tool.description, | |
| 126 | + | "actions": tool.actions.iter().map(|action| json!({ | |
| 127 | + | "name": action.name, | |
| 128 | + | "description": action.summary, | |
| 129 | + | "operation": action.op.name(), | |
| 130 | + | "scope": g1t_contracts::scopes::scope_for(action.op.name()).map(|scope| scope.as_str()), | |
| 131 | + | })).collect::<Vec<_>>(), | |
| 132 | + | "input_schema": tool.discriminated(&actions), | |
| 133 | + | }) | |
| 134 | + | }) | |
| 99 | 135 | .collect(); | |
| 100 | 136 | json!({ | |
| 101 | 137 | "name": "g1t", |
| 119 | 119 | "grant_types_supported": ["authorization_code", "refresh_token"], | |
| 120 | 120 | "code_challenge_methods_supported": ["S256"], | |
| 121 | 121 | "token_endpoint_auth_methods_supported": ["none"], | |
| 122 | + | // A client may ask for some of these with `scope`; the person | |
| 123 | + | // approving can trim them. Asking for none gives the agent preset. | |
| 124 | + | "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()), | |
| 122 | 125 | "service_documentation": "https://docs.g1t.sh/guides/authentication/", | |
| 123 | 126 | }) | |
| 124 | 127 | } | |
| 219 | 222 | "token_type": "Bearer", | |
| 220 | 223 | "expires_in": tokens.expires_in, | |
| 221 | 224 | "refresh_token": tokens.refresh_token, | |
| 225 | + | "scope": tokens.scope, | |
| 222 | 226 | }))?; | |
| 223 | 227 | response.headers_mut().set("cache-control", "no-store")?; | |
| 224 | 228 | Ok(response) | |
| 243 | 247 | "authorization_servers": [ISSUER], | |
| 244 | 248 | "bearer_methods_supported": ["header"], | |
| 245 | 249 | "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 250 | + | "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()), | |
| 246 | 251 | }))? | |
| 247 | 252 | } | |
| 248 | 253 | ("POST", "/oauth/register") => register(request).await?, |
| 4 | 4 | //! `apps/docs/src/data/openapi.json`. A test keeps the copy current: run | |
| 5 | 5 | //! `G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi` to rewrite it. | |
| 6 | 6 | ||
| 7 | + | use g1t_contracts::scopes::scope_for; | |
| 7 | 8 | use serde_json::{Map, Value, json}; | |
| 8 | 9 | ||
| 9 | 10 | use crate::operations::Op; | |
| 86 | 87 | Op::CloseIssue, | |
| 87 | 88 | Op::ReopenIssue, | |
| 88 | 89 | Op::AssignIssue, | |
| 90 | + | Op::Delegate, | |
| 89 | 91 | Op::AddComment, | |
| 90 | 92 | Op::ListLabels, | |
| 91 | 93 | ], | |
| 244 | 246 | Op::CloseIssue => "Close an issue", | |
| 245 | 247 | Op::ReopenIssue => "Reopen an issue", | |
| 246 | 248 | Op::AssignIssue => "Assign an issue to the g1t agent", | |
| 249 | + | Op::Delegate => "Put an agent on it", | |
| 247 | 250 | Op::PlanWork => "Plan work", | |
| 248 | 251 | Op::GetPlan => "Get a plan", | |
| 249 | 252 | Op::ApplyPlan => "Apply a plan", | |
| 466 | 469 | if let Some(reason) = may_need_payment(op) { | |
| 467 | 470 | responses.insert("402".into(), error_response(reason)); | |
| 468 | 471 | } | |
| 469 | − | responses.insert("403".into(), error_response("Signed in, but not allowed to do this.")); | |
| 472 | + | responses.insert( | |
| 473 | + | "403".into(), | |
| 474 | + | error_response("Signed in, but not allowed to do this: the role you have is not enough, or the token lacks the scope it needs, which `needed_scope` names."), | |
| 475 | + | ); | |
| 470 | 476 | if !matches!(op, Op::Whoami | Op::ListRepos | Op::Search) { | |
| 471 | 477 | responses.insert("404".into(), error_response("It does not exist, or you cannot see it.")); | |
| 472 | 478 | } | |
| 480 | 486 | responses.insert("422".into(), error_response("The input is not valid.")); | |
| 481 | 487 | } | |
| 482 | 488 | // Public data can be read without a token; everything else needs one. | |
| 489 | + | let scope: Vec<&str> = scope_for(op.name()).map(|scope| scope.as_str()).into_iter().collect(); | |
| 483 | 490 | let security = if op.needs_user() { | |
| 484 | − | json!([{ "token": [] }]) | |
| 491 | + | json!([{ "token": scope }]) | |
| 485 | 492 | } else { | |
| 486 | − | json!([{ "token": [] }, {}]) | |
| 493 | + | json!([{ "token": scope }, {}]) | |
| 487 | 494 | }; | |
| 495 | + | let (tool, action) = crate::tools::TOOLS | |
| 496 | + | .iter() | |
| 497 | + | .find_map(|tool| { | |
| 498 | + | tool.actions | |
| 499 | + | .iter() | |
| 500 | + | .find(|action| action.op == op) | |
| 501 | + | .map(|action| (tool.name, action.name)) | |
| 502 | + | }) | |
| 503 | + | .unwrap_or_default(); | |
| 488 | 504 | let mut described = json!({ | |
| 489 | 505 | "operationId": id, | |
| 490 | 506 | "tags": [tag(op)], | |
| 491 | 507 | "summary": summary(route, &id), | |
| 492 | 508 | "description": op.description(), | |
| 493 | − | "x-mcp-tool": op.name(), | |
| 509 | + | "x-operation": op.name(), | |
| 510 | + | "x-mcp-tool": tool, | |
| 511 | + | "x-mcp-action": action, | |
| 512 | + | "x-scope": scope.first().copied(), | |
| 494 | 513 | "security": security, | |
| 495 | 514 | "parameters": parameters, | |
| 496 | 515 | "responses": responses, | |
| 591 | 610 | let Some(methods) = methods.as_object_mut() else { continue }; | |
| 592 | 611 | for operation in methods.values_mut() { | |
| 593 | 612 | let id = operation["operationId"].as_str().unwrap_or_default().to_owned(); | |
| 594 | − | let tool = operation["x-mcp-tool"].as_str().unwrap_or_default().to_owned(); | |
| 595 | − | let Some(example) = examples.get(&id).or_else(|| examples.get(&tool)) else { | |
| 613 | + | let name = operation["x-operation"].as_str().unwrap_or_default().to_owned(); | |
| 614 | + | let Some(example) = examples.get(&id).or_else(|| examples.get(&name)) else { | |
| 596 | 615 | continue; | |
| 597 | 616 | }; | |
| 598 | 617 | if let Some(notes) = example.get("notes").and_then(Value::as_str) { | |
| 658 | 677 | "token": { | |
| 659 | 678 | "type": "http", | |
| 660 | 679 | "scheme": "bearer", | |
| 661 | − | "description": "An access token, `g1t_…`. Public data needs none.", | |
| 680 | + | "description": "An access token, `g1t_…`. Public data needs none. Each operation names the scope a token needs for it; see https://docs.g1t.sh/guides/authentication/#scopes.", | |
| 662 | 681 | }, | |
| 663 | 682 | }, | |
| 664 | 683 | "schemas": { | |
| 672 | 691 | "properties": { | |
| 673 | 692 | "code": { "type": "string", "enum": codes }, | |
| 674 | 693 | "message": { "type": "string" }, | |
| 694 | + | "needed_scope": { | |
| 695 | + | "type": "string", | |
| 696 | + | "description": "On a 403 for an access token without the scope the call needs: that scope, such as `issues:write`.", | |
| 697 | + | }, | |
| 675 | 698 | }, | |
| 676 | 699 | }, | |
| 677 | 700 | }, |
| 108 | 108 | CloseIssue, | |
| 109 | 109 | ReopenIssue, | |
| 110 | 110 | AssignIssue, | |
| 111 | + | Delegate, | |
| 111 | 112 | PlanWork, | |
| 112 | 113 | GetPlan, | |
| 113 | 114 | ApplyPlan, | |
| 350 | 351 | } | |
| 351 | 352 | ||
| 352 | 353 | impl Op { | |
| 353 | − | pub const ALL: [Op; 102] = [ | |
| 354 | + | pub const ALL: [Op; 103] = [ | |
| 354 | 355 | Op::Whoami, | |
| 355 | 356 | Op::CreateWorkspace, | |
| 356 | 357 | Op::DeleteWorkspace, | |
| 396 | 397 | Op::CloseIssue, | |
| 397 | 398 | Op::ReopenIssue, | |
| 398 | 399 | Op::AssignIssue, | |
| 400 | + | Op::Delegate, | |
| 399 | 401 | Op::PlanWork, | |
| 400 | 402 | Op::GetPlan, | |
| 401 | 403 | Op::ApplyPlan, | |
| 507 | 509 | Op::CloseIssue => "close_issue", | |
| 508 | 510 | Op::ReopenIssue => "reopen_issue", | |
| 509 | 511 | Op::AssignIssue => "assign_issue", | |
| 512 | + | Op::Delegate => "delegate", | |
| 510 | 513 | Op::PlanWork => "plan_work", | |
| 511 | 514 | Op::GetPlan => "get_plan", | |
| 512 | 515 | Op::ApplyPlan => "apply_plan", | |
| 703 | 706 | Op::AssignIssue => { | |
| 704 | 707 | "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. Needs the Write role or higher. In preview: only for accounts g1t agents are enabled for." | |
| 705 | 708 | } | |
| 709 | + | Op::Delegate => { | |
| 710 | + | "Put an agent on something in one step: open an issue and assign it to the g1t agent at once. Say what you want done in plain words; give checks, commands that must pass, when you know them. Needs the Write role or higher, and nothing is opened without it. The issue is opened whatever happens next: agent.status is started (pull is the draft pull request the agent opened; follow it with get_pull_request), queued (every agent slot of the workspace is busy; it starts by itself when one frees up) or not_started, with agent.code saying why (not_paid, trial_used, limit, paused, issue_cap, billing_unavailable or no_model), agent.message saying what to do, and agent.fix_url where. There is no model or agent count to choose." | |
| 711 | + | } | |
| 706 | 712 | Op::ListLabels => "The labels available on a repository's issues.", | |
| 707 | 713 | Op::AddComment => { | |
| 708 | 714 | "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change." | |
| 1163 | 1169 | "type": "integer", | |
| 1164 | 1170 | "description": "How many times a g1t agent is sent back before a person is asked.", | |
| 1165 | 1171 | }, | |
| 1172 | + | "hold_low_confidence": { | |
| 1173 | + | "type": "boolean", | |
| 1174 | + | "description": "Ask a person before merging a g1t agent's change whose confidence is low: auto-merge and the merge queue leave it until a person approves it. On by default.", | |
| 1175 | + | }, | |
| 1166 | 1176 | }), | |
| 1167 | 1177 | &["repo"], | |
| 1168 | 1178 | ), | |
| 1262 | 1272 | }), | |
| 1263 | 1273 | &["repo", "plan"], | |
| 1264 | 1274 | ), | |
| 1275 | + | Op::Delegate => object( | |
| 1276 | + | json!({ | |
| 1277 | + | "repo": repo_schema(), | |
| 1278 | + | "title": { "type": "string", "description": "What should be true when it is done, in one line." }, | |
| 1279 | + | "body": { | |
| 1280 | + | "type": "string", | |
| 1281 | + | "description": "Markdown. What you want done, in plain words: what is wrong or wanted, and anything the agent cannot see for itself.", | |
| 1282 | + | }, | |
| 1283 | + | "checks": { | |
| 1284 | + | "type": "array", | |
| 1285 | + | "items": { "type": "string" }, | |
| 1286 | + | "description": "Commands that must pass for its pull request to be accepted, e.g. \"npm test\".", | |
| 1287 | + | }, | |
| 1288 | + | "labels": { | |
| 1289 | + | "type": "array", | |
| 1290 | + | "items": { "type": "string" }, | |
| 1291 | + | "description": "What kind of issue this is, e.g. \"bug\".", | |
| 1292 | + | }, | |
| 1293 | + | }), | |
| 1294 | + | &["repo", "title"], | |
| 1295 | + | ), | |
| 1265 | 1296 | Op::AssignIssue => object( | |
| 1266 | 1297 | numbered(json!({ | |
| 1267 | 1298 | "instructions": { | |
| 2258 | 2289 | agent_review: flag("agent_review", current.agent_review), | |
| 2259 | 2290 | max_revisions: integer(input, "max_revisions").unwrap_or(current.max_revisions), | |
| 2260 | 2291 | merge_queue: flag("merge_queue", current.merge_queue), | |
| 2292 | + | hold_low_confidence: flag("hold_low_confidence", current.hold_low_confidence), | |
| 2261 | 2293 | ..current | |
| 2262 | 2294 | }; | |
| 2263 | 2295 | pass( | |
| 2374 | 2406 | ) | |
| 2375 | 2407 | .await | |
| 2376 | 2408 | } | |
| 2409 | + | Op::Delegate => { | |
| 2410 | + | pass( | |
| 2411 | + | runner, | |
| 2412 | + | "delegate", | |
| 2413 | + | &json!({ | |
| 2414 | + | "actor": actor(), | |
| 2415 | + | "repo": repo, | |
| 2416 | + | "title": text(input, "title"), | |
| 2417 | + | "body": text(input, "body"), | |
| 2418 | + | "labels": strings(input, "labels").unwrap_or_default(), | |
| 2419 | + | "checks": strings(input, "checks").unwrap_or_default(), | |
| 2420 | + | }), | |
| 2421 | + | ) | |
| 2422 | + | .await | |
| 2423 | + | } | |
| 2377 | 2424 | Op::AssignIssue => { | |
| 2378 | 2425 | pass( | |
| 2379 | 2426 | runner, |
| 40 | 40 | "role": "owner", | |
| 41 | 41 | "base_permission": "write" | |
| 42 | 42 | } | |
| 43 | − | ] | |
| 43 | + | ], | |
| 44 | + | "token": { | |
| 45 | + | "token_id": "tok_01kkntd3p2v8x6ym5r0c1q7a9e", | |
| 46 | + | "scopes": ["repo:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write"] | |
| 47 | + | } | |
| 44 | 48 | }, | |
| 45 | − | "notes": "`kind` is `user`, `workspace` for a [workspace access token](/guides/workspaces/#workspace-access-tokens), or `agent` for the token a g1t agent works with. For a workspace token, `username` is the workspace's slug and `workspaces` holds only that workspace. Each workspace carries its `base_permission`: what a member gets on each of its repositories (owners have Admin). Roles given on single repositories are in `grants`, each with `repo_id`, `workspace` and `role`; it is left out when there are none. See [Access and roles](/guides/access-and-roles/)." | |
| 49 | + | "notes": "`token` is what the access token you called with may do: its `scopes` (left out for full access) and `legacy` when it was made before tokens had scopes. A token reaches every workspace and repository whoever it acts as can; its scopes say what it may do there. See [Scopes](/guides/authentication/#scopes). `kind` is `user`, `workspace` for a [workspace access token](/guides/workspaces/#workspace-access-tokens), or `agent` for the token a g1t agent works with. For a workspace token, `username` is the workspace's slug and `workspaces` holds only that workspace. Each workspace carries its `base_permission`: what a member gets on each of its repositories (owners have Admin). Roles given on single repositories are in `grants`, each with `repo_id`, `workspace` and `role`; it is left out when there are none. See [Access and roles](/guides/access-and-roles/)." | |
| 46 | 50 | }, | |
| 47 | 51 | "create_workspace": { | |
| 48 | 52 | "request": { | |
| 693 | 697 | "agent_review": true, | |
| 694 | 698 | "max_revisions": 2, | |
| 695 | 699 | "merge_queue": false, | |
| 700 | + | "hold_low_confidence": true, | |
| 696 | 701 | "updated_by": null, | |
| 697 | 702 | "updated_at": null | |
| 698 | 703 | } | |
| 712 | 717 | "agent_review": true, | |
| 713 | 718 | "max_revisions": 2, | |
| 714 | 719 | "merge_queue": true, | |
| 720 | + | "hold_low_confidence": true, | |
| 715 | 721 | "updated_by": "syntaqx", | |
| 716 | 722 | "updated_at": "2026-10-04T16:20:37.508Z" | |
| 717 | 723 | }, | |
| 718 | − | "notes": "`required_approvals` is at most 6 and `max_revisions` at most 5. See [what a repository can ask for](/guides/g1t-agents/#what-a-repository-can-ask-for)." | |
| 724 | + | "notes": "`required_approvals` is at most 6 and `max_revisions` at most 5. See [what a repository can ask for](/guides/g1t-agents/#what-a-repository-can-ask-for). `hold_low_confidence` is on unless turned off: see [confidence](/guides/g1t-agents/#how-sure-the-agent-is)." | |
| 719 | 725 | }, | |
| 720 | 726 | "list_events": { | |
| 721 | 727 | "query": { | |
| 1129 | 1135 | "workspaces": [] | |
| 1130 | 1136 | }, | |
| 1131 | 1137 | "created_at": "2026-10-01T18:20:02.117Z", | |
| 1132 | − | "updated_at": "2026-10-01T18:20:02.117Z" | |
| 1138 | + | "updated_at": "2026-10-01T18:20:02.117Z", | |
| 1139 | + | "confidence": null | |
| 1133 | 1140 | }, | |
| 1134 | 1141 | "notes": "The response is the pull request the g1t agent opened, still a draft. Follow it with [get a pull request](/reference/api/pull-requests/get-pull-request/): `lifecycle` says what the agent is doing." | |
| 1135 | 1142 | }, | |
| 1143 | + | "delegate": { | |
| 1144 | + | "request": { | |
| 1145 | + | "title": "Retry webhooks with exponential backoff", | |
| 1146 | + | "body": "Deliveries that fail are dropped today. Retry them with exponential backoff, up to six times, then mark the delivery failed.", | |
| 1147 | + | "checks": [ | |
| 1148 | + | "npm test" | |
| 1149 | + | ] | |
| 1150 | + | }, | |
| 1151 | + | "response": { | |
| 1152 | + | "issue": { | |
| 1153 | + | "id": "iss_01m4a2c8v1t7k3q9x5n0r2w6yd", | |
| 1154 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 1155 | + | "number": 41, | |
| 1156 | + | "title": "Retry webhooks with exponential backoff", | |
| 1157 | + | "body": "Deliveries that fail are dropped today. Retry them with exponential backoff, up to six times, then mark the delivery failed.", | |
| 1158 | + | "labels": [], | |
| 1159 | + | "checks": [ | |
| 1160 | + | "npm test" | |
| 1161 | + | ], | |
| 1162 | + | "state": "open", | |
| 1163 | + | "reason": null, | |
| 1164 | + | "resolved_by": null, | |
| 1165 | + | "author": { | |
| 1166 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1167 | + | "username": "syntaqx", | |
| 1168 | + | "kind": "user", | |
| 1169 | + | "verified": false, | |
| 1170 | + | "workspaces": [] | |
| 1171 | + | }, | |
| 1172 | + | "created_at": "2026-10-05T14:02:11.204Z", | |
| 1173 | + | "updated_at": "2026-10-05T14:02:11.204Z", | |
| 1174 | + | "closed_at": null, | |
| 1175 | + | "pull_count": 1, | |
| 1176 | + | "comment_count": 0, | |
| 1177 | + | "assignees": [], | |
| 1178 | + | "blocked_by": [], | |
| 1179 | + | "queued": false, | |
| 1180 | + | "agent": "g1t-agent" | |
| 1181 | + | }, | |
| 1182 | + | "pull": { | |
| 1183 | + | "id": "pr_01m4a2c9b6e0h4m8q2t6x0a4d8", | |
| 1184 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 1185 | + | "number": 42, | |
| 1186 | + | "issue": 41, | |
| 1187 | + | "title": "Retry webhooks with exponential backoff", | |
| 1188 | + | "body": null, | |
| 1189 | + | "agent": "g1t-agent", | |
| 1190 | + | "runtime": "hosted", | |
| 1191 | + | "status": "draft", | |
| 1192 | + | "fork": { | |
| 1193 | + | "namespace": "pulls", | |
| 1194 | + | "name": "pr_01m4a2c9b6e0h4m8q2t6x0a4d8" | |
| 1195 | + | }, | |
| 1196 | + | "fork_repo_id": "rep_01m4a2c9d2g6k0n4r8v2z6c0f4", | |
| 1197 | + | "branch": null, | |
| 1198 | + | "head_commit": null, | |
| 1199 | + | "merge_base": null, | |
| 1200 | + | "merged_by": null, | |
| 1201 | + | "merged_at": null, | |
| 1202 | + | "superseded_by": null, | |
| 1203 | + | "check_status": null, | |
| 1204 | + | "files": [], | |
| 1205 | + | "assignees": [], | |
| 1206 | + | "reviewers": [], | |
| 1207 | + | "author": { | |
| 1208 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1209 | + | "username": "syntaqx", | |
| 1210 | + | "kind": "user", | |
| 1211 | + | "verified": false, | |
| 1212 | + | "workspaces": [] | |
| 1213 | + | }, | |
| 1214 | + | "created_at": "2026-10-05T14:02:12.880Z", | |
| 1215 | + | "updated_at": "2026-10-05T14:02:12.880Z", | |
| 1216 | + | "confidence": null | |
| 1217 | + | }, | |
| 1218 | + | "agent": { | |
| 1219 | + | "status": "started", | |
| 1220 | + | "code": null, | |
| 1221 | + | "message": null, | |
| 1222 | + | "fix_url": null | |
| 1223 | + | } | |
| 1224 | + | }, | |
| 1225 | + | "notes": "The issue is opened whatever becomes of the agent. When the workspace's plan does not let it start, `pull` is null and `agent` says why and where to fix it, for example `{ \"status\": \"not_started\", \"code\": \"not_paid\", \"message\": \"Agents need a paid workspace. Start the $20 plan or try it with $5 of free usage after a card check: /acme/-/billing\", \"fix_url\": \"https://g1t.sh/acme/-/billing\" }`. With every agent slot busy, `status` is `queued` and it starts by itself when one frees up. See [putting an agent on something](/guides/g1t-agents/#put-an-agent-on-it-in-one-step)." | |
| 1226 | + | }, | |
| 1136 | 1227 | "add_comment": { | |
| 1137 | 1228 | "request": { | |
| 1138 | 1229 | "body": "Should an empty name fall back to \"world\"?" | |
| 1442 | 1533 | "workspaces": [] | |
| 1443 | 1534 | }, | |
| 1444 | 1535 | "created_at": "2026-10-01T18:20:02.117Z", | |
| 1445 | − | "updated_at": "2026-10-01T18:35:44.902Z" | |
| 1536 | + | "updated_at": "2026-10-01T18:35:44.902Z", | |
| 1537 | + | "confidence": null | |
| 1446 | 1538 | }, | |
| 1447 | 1539 | "issue": { | |
| 1448 | 1540 | "id": "iss_01m43shrzpfe49x74ga7sj1c6v", |
| 105 | 105 | through::<work::Issue>(op, sent) | |
| 106 | 106 | } | |
| 107 | 107 | Op::GetIssue => through::<work::IssueDetail>(op, sent), | |
| 108 | + | Op::Delegate => through::<work::Delegated>(op, sent), | |
| 108 | 109 | Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent), | |
| 109 | 110 | Op::GetPullRequest => through::<work::PullDetail>(op, sent), | |
| 110 | 111 | Op::MarkPullRequestReady | Op::ClosePullRequest | Op::MergePullRequest | Op::AssignIssue => { | |
| 164 | 165 | for (path, methods) in document["paths"].as_object().unwrap() { | |
| 165 | 166 | for (method, operation) in methods.as_object().unwrap() { | |
| 166 | 167 | let example = &operation["responses"]["200"]["content"]["application/json"]["example"]; | |
| 167 | − | let tool = operation["x-mcp-tool"].as_str().unwrap_or_default(); | |
| 168 | + | let tool = operation["x-operation"].as_str().unwrap_or_default(); | |
| 168 | 169 | let Some(op) = Op::by_name(tool) else { | |
| 169 | 170 | // Device sign-in, which is written in `snake_case` by hand. | |
| 170 | 171 | assert!(wire::camel_case_keys(example).is_empty(), "{method} {path}"); |
| 189 | 189 | &[], | |
| 190 | 190 | ), | |
| 191 | 191 | route( | |
| 192 | + | "POST", | |
| 193 | + | "/repos/:owner/:name/issues/delegate", | |
| 194 | + | Op::Delegate, | |
| 195 | + | &[], | |
| 196 | + | ), | |
| 197 | + | route( | |
| 192 | 198 | "GET", | |
| 193 | 199 | "/repos/:owner/:name/context", | |
| 194 | 200 | Op::GetContext, |
| 1 | + | //! The MCP server's tools: a few resource tools, each with an `action`. | |
| 2 | + | //! | |
| 3 | + | //! Every operation is one action of one tool. A call is dispatched to the | |
| 4 | + | //! operation it names, so permissions, the audit log, billing and outcomes | |
| 5 | + | //! are exactly those of the REST API. A token sees only the actions its | |
| 6 | + | //! scopes allow, and a tool none of whose actions it may use is not listed. | |
| 7 | + | //! | |
| 8 | + | //! The listed input schema is one flat object: `action`, then every field | |
| 9 | + | //! any of its actions takes. Which fields each action needs is in the | |
| 10 | + | //! `action` field's description and checked on every call. Claude's API, | |
| 11 | + | //! and so most MCP clients, refuse a tool whose input schema has `oneOf` | |
| 12 | + | //! at its top level, so the schema keyed by action, with each action's | |
| 13 | + | //! required fields, is [`discriminated`], published on the server's card | |
| 14 | + | //! and in the docs. | |
| 15 | + | ||
| 16 | + | use g1t_contracts::credentials::NEVER; | |
| 17 | + | use g1t_contracts::identity::AgentScope; | |
| 18 | + | use g1t_contracts::scopes::{Level, NO_SCOPE, TokenAccess, scope_for}; | |
| 19 | + | use serde_json::{Map, Value, json}; | |
| 20 | + | ||
| 21 | + | use crate::operations::Op; | |
| 22 | + | ||
| 23 | + | pub struct Action { | |
| 24 | + | pub name: &'static str, | |
| 25 | + | pub op: Op, | |
| 26 | + | /// One line, for the `action` field's description. | |
| 27 | + | pub summary: &'static str, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | pub struct Tool { | |
| 31 | + | pub name: &'static str, | |
| 32 | + | pub title: &'static str, | |
| 33 | + | /// What it is for, in a sentence or two. | |
| 34 | + | pub description: &'static str, | |
| 35 | + | pub actions: &'static [Action], | |
| 36 | + | /// The action a call without one runs. | |
| 37 | + | pub default_action: Option<&'static str>, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | const fn a(name: &'static str, op: Op, summary: &'static str) -> Action { | |
| 41 | + | Action { name, op, summary } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | pub const TOOLS: &[Tool] = &[ | |
| 45 | + | Tool { | |
| 46 | + | name: "search", | |
| 47 | + | title: "Search", | |
| 48 | + | description: "Find things. `code` (the default) searches all of g1t you can see: repositories, code, issues, pull requests and people, with qualifiers like repo:owner/name, language:rust, is:issue. `context` searches one workspace's catalog, docs, issues and memory by meaning.", | |
| 49 | + | default_action: Some("code"), | |
| 50 | + | actions: &[ | |
| 51 | + | a("code", Op::Search, "Search all of g1t: repositories, code, issues, pull requests, people"), | |
| 52 | + | a("context", Op::SearchContext, "Search a workspace's context hub by meaning"), | |
| 53 | + | a("entity", Op::GetEntity, "One catalog entry and its relations"), | |
| 54 | + | a("ticket", Op::GetContext, "A Jira, Linear or Sentry item the work refers to, as it is now"), | |
| 55 | + | ], | |
| 56 | + | }, | |
| 57 | + | Tool { | |
| 58 | + | name: "repository", | |
| 59 | + | title: "Repositories", | |
| 60 | + | description: "Repositories: find, read and create them, and change their settings. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 61 | + | default_action: None, | |
| 62 | + | actions: &[ | |
| 63 | + | a("list", Op::ListRepos, "Repositories you can see"), | |
| 64 | + | a("get", Op::GetRepo, "One repository"), | |
| 65 | + | a("create", Op::CreateRepo, "Create one, empty or copied from a public git URL"), | |
| 66 | + | a("update", Op::UpdateRepo, "Change description, website, topics, default branch, protection"), | |
| 67 | + | a("get_settings", Op::GetRepoSettings, "How pull requests merge"), | |
| 68 | + | a("update_settings", Op::UpdateRepoSettings, "Change how pull requests merge"), | |
| 69 | + | a("list_labels", Op::ListLabels, "Labels in use"), | |
| 70 | + | a("list_events", Op::ListEvents, "Timeline: pushes, issues, pull requests, comments"), | |
| 71 | + | a("rename_branch", Op::RenameBranch, "Rename a branch"), | |
| 72 | + | a("rename", Op::RenameRepo, "Rename it; old addresses redirect"), | |
| 73 | + | a("transfer", Op::TransferRepo, "Move it to another workspace you own"), | |
| 74 | + | a("archive", Op::ArchiveRepo, "Make it read-only"), | |
| 75 | + | a("unarchive", Op::UnarchiveRepo, "Make it writable again"), | |
| 76 | + | a("set_visibility", Op::SetRepoVisibility, "Make it public or private"), | |
| 77 | + | a("delete", Op::DeleteRepo, "Delete it; restorable for 30 days"), | |
| 78 | + | a("list_deleted", Op::ListDeletedRepos, "A workspace's deleted repositories"), | |
| 79 | + | a("restore", Op::RestoreRepo, "Restore a deleted one"), | |
| 80 | + | a("purge", Op::PurgeRepo, "Remove a deleted one for good"), | |
| 81 | + | ], | |
| 82 | + | }, | |
| 83 | + | Tool { | |
| 84 | + | name: "issue", | |
| 85 | + | title: "Issues", | |
| 86 | + | description: "Issues: what should change. Read one before working on it to see the pull requests already made for it. Issues and pull requests share numbers; `comment` works on either.", | |
| 87 | + | default_action: None, | |
| 88 | + | actions: &[ | |
| 89 | + | a("list", Op::ListIssues, "Issues on a repository, newest first"), | |
| 90 | + | a("get", Op::GetIssue, "One issue with comments, checks and its pull requests"), | |
| 91 | + | a("create", Op::CreateIssue, "Open an issue"), | |
| 92 | + | a("update", Op::UpdateIssue, "Change title, body, labels or assignees"), | |
| 93 | + | a("close", Op::CloseIssue, "Close it without a pull request"), | |
| 94 | + | a("reopen", Op::ReopenIssue, "Reopen it"), | |
| 95 | + | a("comment", Op::AddComment, "Comment on an issue or pull request; path and line for one line of a change"), | |
| 96 | + | a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"), | |
| 97 | + | ], | |
| 98 | + | }, | |
| 99 | + | Tool { | |
| 100 | + | name: "pull_request", | |
| 101 | + | title: "Pull requests", | |
| 102 | + | description: "Pull requests: start a change for an issue, record your session, mark it ready, review and merge. Read `overlaps` and `behind` on `get` before going far.", | |
| 103 | + | default_action: None, | |
| 104 | + | actions: &[ | |
| 105 | + | a("list", Op::ListPullRequests, "Pull requests on a repository, newest first"), | |
| 106 | + | a("get", Op::GetPullRequest, "Status, checks, reviews, overlaps and whether it is behind"), | |
| 107 | + | a("changes", Op::GetPullRequestChanges, "Files and line-by-line diff"), | |
| 108 | + | a("create", Op::CreatePullRequest, "Start a draft with its own fork to push to, or open one from a pushed branch"), | |
| 109 | + | a("record_session", Op::RecordSession, "Append prompt, reasoning and tool entries to its session"), | |
| 110 | + | a("read_session", Op::ReadSession, "Its recorded session"), | |
| 111 | + | a("ready", Op::MarkPullRequestReady, "Mark a draft ready, with a summary"), | |
| 112 | + | a("review", Op::ReviewPullRequest, "Approve or request changes"), | |
| 113 | + | a("close", Op::ClosePullRequest, "Close without merging"), | |
| 114 | + | a("merge", Op::MergePullRequest, "Land it, or join the merge queue"), | |
| 115 | + | a("merge_queue", Op::GetMergeQueue, "The repository's merge queue"), | |
| 116 | + | ], | |
| 117 | + | }, | |
| 118 | + | Tool { | |
| 119 | + | name: "agent", | |
| 120 | + | title: "g1t agents", | |
| 121 | + | description: "Put g1t's agent to work and talk to it. One agent per issue; to do more at once, use more issues. Starting an agent uses the workspace's money.", | |
| 122 | + | default_action: None, | |
| 123 | + | actions: &[ | |
| 124 | + | a("delegate", Op::Delegate, "Open an issue and put an agent on it in one step"), | |
| 125 | + | a("assign", Op::AssignIssue, "Put an agent on an existing issue"), | |
| 126 | + | a("message", Op::MessageAgent, "Tell the agent on a pull request something, or ask another agent"), | |
| 127 | + | a("answer", Op::AnswerMessage, "Answer a question or handoff sent to you"), | |
| 128 | + | a("take_messages", Op::TakeMessages, "For a g1t agent: messages not seen yet"), | |
| 129 | + | ], | |
| 130 | + | }, | |
| 131 | + | Tool { | |
| 132 | + | name: "plan", | |
| 133 | + | title: "Plans", | |
| 134 | + | description: "Turn an outcome into issues: an agent proposes them with checks and dependencies; nothing opens until you apply the plan.", | |
| 135 | + | default_action: None, | |
| 136 | + | actions: &[ | |
| 137 | + | a("create", Op::PlanWork, "Ask an agent for a plan; read it with get until ready"), | |
| 138 | + | a("get", Op::GetPlan, "A plan and the issues it proposes"), | |
| 139 | + | a("apply", Op::ApplyPlan, "Open its issues; with assign, agents start in dependency order"), | |
| 140 | + | ], | |
| 141 | + | }, | |
| 142 | + | Tool { | |
| 143 | + | name: "memory", | |
| 144 | + | title: "Memory", | |
| 145 | + | description: "What the project and its workspace remember for the next agent: how to build, conventions, decisions, traps. Recall before you start; remember one short fact at a time, never a secret.", | |
| 146 | + | default_action: None, | |
| 147 | + | actions: &[ | |
| 148 | + | a("recall", Op::Recall, "Search memory, or list it all"), | |
| 149 | + | a("remember", Op::Remember, "Save one fact"), | |
| 150 | + | ], | |
| 151 | + | }, | |
| 152 | + | Tool { | |
| 153 | + | name: "workflow", | |
| 154 | + | title: "Workflows", | |
| 155 | + | description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them.", | |
| 156 | + | default_action: None, | |
| 157 | + | actions: &[ | |
| 158 | + | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| 159 | + | a("list_runs", Op::ListWorkflowRuns, "Runs, newest first"), | |
| 160 | + | a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps"), | |
| 161 | + | a("job_logs", Op::GetJobLogs, "A job's log after a sequence number"), | |
| 162 | + | a("dispatch", Op::DispatchWorkflow, "Run a workflow_dispatch workflow"), | |
| 163 | + | a("cancel", Op::CancelWorkflowRun, "Cancel a run"), | |
| 164 | + | a("rerun", Op::RerunWorkflowRun, "Run a finished run again"), | |
| 165 | + | a("update", Op::UpdateWorkflow, "Turn a workflow on or off"), | |
| 166 | + | ], | |
| 167 | + | }, | |
| 168 | + | Tool { | |
| 169 | + | name: "secret", | |
| 170 | + | title: "Secrets and variables", | |
| 171 | + | description: "A repository's or workspace's secrets and variables, read by workflows and deployments. Secret values are never returned.", | |
| 172 | + | default_action: None, | |
| 173 | + | actions: &[ | |
| 174 | + | a("list_secrets", Op::ListActionsSecrets, "Secrets, without values"), | |
| 175 | + | a("set_secret", Op::SetActionsSecret, "Add or change a secret"), | |
| 176 | + | a("delete_secret", Op::DeleteActionsSecret, "Remove a secret"), | |
| 177 | + | a("list_variables", Op::ListActionsVariables, "Variables, with values"), | |
| 178 | + | a("set_variable", Op::SetActionsVariable, "Add or change a variable"), | |
| 179 | + | a("delete_variable", Op::DeleteActionsVariable, "Remove a variable"), | |
| 180 | + | ], | |
| 181 | + | }, | |
| 182 | + | Tool { | |
| 183 | + | name: "webhook", | |
| 184 | + | title: "Webhooks", | |
| 185 | + | description: "HTTPS addresses sent signed events as they happen, for a repository or a whole workspace.", | |
| 186 | + | default_action: None, | |
| 187 | + | actions: &[ | |
| 188 | + | a("list", Op::ListWebhooks, "Webhooks, without secrets"), | |
| 189 | + | a("create", Op::CreateWebhook, "Register one; a ping is sent"), | |
| 190 | + | a("update", Op::UpdateWebhook, "Change address, events or active"), | |
| 191 | + | a("delete", Op::DeleteWebhook, "Remove one"), | |
| 192 | + | a("ping", Op::PingWebhook, "Send a ping"), | |
| 193 | + | a("list_deliveries", Op::ListWebhookDeliveries, "Latest deliveries"), | |
| 194 | + | a("redeliver", Op::RedeliverWebhook, "Send a delivery again"), | |
| 195 | + | ], | |
| 196 | + | }, | |
| 197 | + | Tool { | |
| 198 | + | name: "access", | |
| 199 | + | title: "Who has access", | |
| 200 | + | description: "Who has access to a repository and with which role (read, triage, write, maintain, admin), outside collaborators, and a workspace's base permission.", | |
| 201 | + | default_action: None, | |
| 202 | + | actions: &[ | |
| 203 | + | a("list_collaborators", Op::ListCollaborators, "Everyone with a role, and pending invitations"), | |
| 204 | + | a("get_permission", Op::GetCollaboratorPermission, "One person's role and capabilities"), | |
| 205 | + | a("add_collaborator", Op::AddCollaborator, "Give someone a role, by username or email"), | |
| 206 | + | a("update_collaborator", Op::UpdateCollaborator, "Change a direct role"), | |
| 207 | + | a("remove_collaborator", Op::RemoveCollaborator, "Take away a direct role"), | |
| 208 | + | a("list_invitations", Op::ListRepoInvitations, "Pending invitations to a repository"), | |
| 209 | + | a("revoke_invitation", Op::RevokeRepoInvitation, "Withdraw one"), | |
| 210 | + | a("set_base_permission", Op::SetBasePermission, "What every member gets on each repository"), | |
| 211 | + | a("list_outside_collaborators", Op::ListOutsideCollaborators, "People with roles who are not members"), | |
| 212 | + | ], | |
| 213 | + | }, | |
| 214 | + | Tool { | |
| 215 | + | name: "workspace", | |
| 216 | + | title: "Workspaces", | |
| 217 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create or delete one, invite members, and connect integrations and model providers.", | |
| 218 | + | default_action: None, | |
| 219 | + | actions: &[ | |
| 220 | + | a("create", Op::CreateWorkspace, "Create a workspace"), | |
| 221 | + | a("delete", Op::DeleteWorkspace, "Delete an empty workspace"), | |
| 222 | + | a("list_invites", Op::ListWorkspaceInvites, "Its invites"), | |
| 223 | + | a("invite_member", Op::InviteMember, "Invite an email address"), | |
| 224 | + | a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"), | |
| 225 | + | a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"), | |
| 226 | + | a("connect_integration", Op::ConnectIntegration, "Connect one"), | |
| 227 | + | a("disconnect_integration", Op::DisconnectIntegration, "Remove one"), | |
| 228 | + | a("test_integration", Op::TestIntegration, "Check its credentials"), | |
| 229 | + | a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"), | |
| 230 | + | a("set_model_routes", Op::SetModelRoutes, "Replace them"), | |
| 231 | + | ], | |
| 232 | + | }, | |
| 233 | + | Tool { | |
| 234 | + | name: "account", | |
| 235 | + | title: "Your account", | |
| 236 | + | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.", | |
| 237 | + | default_action: Some("whoami"), | |
| 238 | + | actions: &[ | |
| 239 | + | a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"), | |
| 240 | + | a("list_emails", Op::ListEmails, "Your addresses"), | |
| 241 | + | a("add_email", Op::AddEmail, "Add an address"), | |
| 242 | + | a("remove_email", Op::RemoveEmail, "Remove an address"), | |
| 243 | + | a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"), | |
| 244 | + | a("list_invites", Op::ListInvites, "Your invites to g1t"), | |
| 245 | + | a("create_invite", Op::CreateInvite, "Make an invite"), | |
| 246 | + | a("revoke_invite", Op::RevokeInvite, "Revoke one"), | |
| 247 | + | a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"), | |
| 248 | + | a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"), | |
| 249 | + | a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"), | |
| 250 | + | ], | |
| 251 | + | }, | |
| 252 | + | ]; | |
| 253 | + | ||
| 254 | + | /// Operations that cannot be undone, or reach beyond g1t's own records: | |
| 255 | + | /// clients ask before running a tool that has any of them. | |
| 256 | + | fn destructive(op: Op) -> bool { | |
| 257 | + | matches!( | |
| 258 | + | op, | |
| 259 | + | Op::DeleteWorkspace | |
| 260 | + | | Op::DeleteRepo | |
| 261 | + | | Op::PurgeRepo | |
| 262 | + | | Op::TransferRepo | |
| 263 | + | | Op::SetRepoVisibility | |
| 264 | + | | Op::RemoveEmail | |
| 265 | + | | Op::RemoveCollaborator | |
| 266 | + | | Op::DisconnectIntegration | |
| 267 | + | | Op::DeleteWebhook | |
| 268 | + | | Op::DeleteActionsSecret | |
| 269 | + | | Op::DeleteActionsVariable | |
| 270 | + | | Op::SetActionsSecret | |
| 271 | + | | Op::SetActionsVariable | |
| 272 | + | | Op::SetModelRoutes | |
| 273 | + | | Op::SetBasePermission | |
| 274 | + | | Op::MergePullRequest | |
| 275 | + | ) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// Whether an operation only reads. | |
| 279 | + | pub fn reads_only(op: Op) -> bool { | |
| 280 | + | NO_SCOPE.contains(&op.name()) | |
| 281 | + | || scope_for(op.name()).is_some_and(|scope| scope.level() == Level::Read) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// What decides which actions a caller sees. | |
| 285 | + | pub enum Gate<'a> { | |
| 286 | + | /// No limit beyond the person's own role. | |
| 287 | + | Everything, | |
| 288 | + | /// A g1t agent's token: the operations its run lists. | |
| 289 | + | Agent(&'a AgentScope), | |
| 290 | + | /// An access token with scopes. | |
| 291 | + | Token(&'a TokenAccess), | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | impl Gate<'_> { | |
| 295 | + | pub fn allows(&self, op: Op) -> bool { | |
| 296 | + | match self { | |
| 297 | + | Gate::Everything => true, | |
| 298 | + | Gate::Agent(scope) => op.allowed_by(scope) && !NEVER.contains(&op.name()), | |
| 299 | + | Gate::Token(access) => { | |
| 300 | + | if NO_SCOPE.contains(&op.name()) { | |
| 301 | + | return true; | |
| 302 | + | } | |
| 303 | + | match scope_for(op.name()) { | |
| 304 | + | Some(scope) => access.allows(scope), | |
| 305 | + | None => access.scopes.is_none(), | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | impl Tool { | |
| 313 | + | pub fn by_name(name: &str) -> Option<&'static Tool> { | |
| 314 | + | TOOLS.iter().find(|tool| tool.name == name) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | pub fn action(&self, name: &str) -> Option<&'static Action> { | |
| 318 | + | // The tools are 'static; find through TOOLS to keep the lifetime. | |
| 319 | + | TOOLS | |
| 320 | + | .iter() | |
| 321 | + | .find(|tool| tool.name == self.name) | |
| 322 | + | .and_then(|tool| tool.actions.iter().find(|action| action.name == name)) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | pub fn visible(&self, gate: &Gate) -> Vec<&'static Action> { | |
| 326 | + | TOOLS | |
| 327 | + | .iter() | |
| 328 | + | .find(|tool| tool.name == self.name) | |
| 329 | + | .map(|tool| tool.actions.iter().filter(|action| gate.allows(action.op)).collect()) | |
| 330 | + | .unwrap_or_default() | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// The flat input schema of the actions given. | |
| 334 | + | pub fn input_schema(&self, actions: &[&Action]) -> Value { | |
| 335 | + | let mut properties = Map::new(); | |
| 336 | + | let lines: Vec<String> = actions | |
| 337 | + | .iter() | |
| 338 | + | .map(|action| { | |
| 339 | + | let required: Vec<String> = action.op.required(); | |
| 340 | + | if required.is_empty() { | |
| 341 | + | format!("{}: {}.", action.name, action.summary) | |
| 342 | + | } else { | |
| 343 | + | format!("{} ({}): {}.", action.name, required.join(", "), action.summary) | |
| 344 | + | } | |
| 345 | + | }) | |
| 346 | + | .collect(); | |
| 347 | + | let mut action_schema = json!({ | |
| 348 | + | "type": "string", | |
| 349 | + | "enum": actions.iter().map(|action| action.name).collect::<Vec<_>>(), | |
| 350 | + | "description": lines.join("\n"), | |
| 351 | + | }); | |
| 352 | + | if let Some(default) = self.default_action.filter(|name| actions.iter().any(|action| action.name == *name)) { | |
| 353 | + | action_schema["default"] = json!(default); | |
| 354 | + | } | |
| 355 | + | properties.insert("action".to_owned(), action_schema); | |
| 356 | + | for action in actions { | |
| 357 | + | for (name, schema) in action.op.properties() { | |
| 358 | + | merge_property(&mut properties, name, schema); | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | let mut required = vec![]; | |
| 362 | + | if self.default_action.is_none() { | |
| 363 | + | required.push("action"); | |
| 364 | + | } | |
| 365 | + | let mut schema = json!({ "type": "object", "properties": properties }); | |
| 366 | + | if !required.is_empty() { | |
| 367 | + | schema["required"] = json!(required); | |
| 368 | + | } | |
| 369 | + | schema | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | /// The input schema keyed by action: one `oneOf` branch per action, | |
| 373 | + | /// each with its own fields and the ones it needs. | |
| 374 | + | pub fn discriminated(&self, actions: &[&Action]) -> Value { | |
| 375 | + | let branches: Vec<Value> = actions | |
| 376 | + | .iter() | |
| 377 | + | .map(|action| { | |
| 378 | + | let mut properties = Map::new(); | |
| 379 | + | properties.insert("action".to_owned(), json!({ "const": action.name })); | |
| 380 | + | properties.extend(action.op.properties()); | |
| 381 | + | let mut required = vec![Value::String("action".to_owned())]; | |
| 382 | + | // The default action may leave `action` out. | |
| 383 | + | if self.default_action == Some(action.name) { | |
| 384 | + | required.clear(); | |
| 385 | + | } | |
| 386 | + | required.extend(action.op.required().into_iter().map(Value::String)); | |
| 387 | + | json!({ | |
| 388 | + | "title": action.name, | |
| 389 | + | "description": action.summary, | |
| 390 | + | "type": "object", | |
| 391 | + | "properties": properties, | |
| 392 | + | "required": required, | |
| 393 | + | }) | |
| 394 | + | }) | |
| 395 | + | .collect(); | |
| 396 | + | json!({ "type": "object", "oneOf": branches }) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | /// MCP's hints about the actions given: whether the tool only reads, | |
| 400 | + | /// whether it can destroy something, and whether calling it twice is | |
| 401 | + | /// the same as once. | |
| 402 | + | pub fn annotations(&self, actions: &[&Action]) -> Value { | |
| 403 | + | let read_only = actions.iter().all(|action| reads_only(action.op)); | |
| 404 | + | json!({ | |
| 405 | + | "title": self.title, | |
| 406 | + | "readOnlyHint": read_only, | |
| 407 | + | "destructiveHint": !read_only && actions.iter().any(|action| destructive(action.op)), | |
| 408 | + | "idempotentHint": read_only, | |
| 409 | + | "openWorldHint": false, | |
| 410 | + | }) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | /// The tool as `tools/list` gives it, for a caller behind `gate`, or | |
| 414 | + | /// `None` when it may use none of its actions. | |
| 415 | + | pub fn listed(&self, gate: &Gate) -> Option<Value> { | |
| 416 | + | let actions = self.visible(gate); | |
| 417 | + | if actions.is_empty() { | |
| 418 | + | return None; | |
| 419 | + | } | |
| 420 | + | Some(json!({ | |
| 421 | + | "name": self.name, | |
| 422 | + | "title": self.title, | |
| 423 | + | "description": self.description, | |
| 424 | + | "inputSchema": self.input_schema(&actions), | |
| 425 | + | "annotations": self.annotations(&actions), | |
| 426 | + | })) | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | /// Adds a property to a tool's flat schema. The first action to use a name | |
| 431 | + | /// describes it; a later one with other allowed values adds them. | |
| 432 | + | fn merge_property(properties: &mut Map<String, Value>, name: String, schema: Value) { | |
| 433 | + | match properties.get_mut(&name) { | |
| 434 | + | None => { | |
| 435 | + | properties.insert(name, schema); | |
| 436 | + | } | |
| 437 | + | Some(existing) => { | |
| 438 | + | if let (Some(Value::Array(had)), Some(Value::Array(more))) = | |
| 439 | + | (existing.get("enum").cloned(), schema.get("enum")) | |
| 440 | + | { | |
| 441 | + | let mut merged = had; | |
| 442 | + | for value in more { | |
| 443 | + | if !merged.contains(value) { | |
| 444 | + | merged.push(value.clone()); | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | existing["enum"] = Value::Array(merged); | |
| 448 | + | } | |
| 449 | + | // Different kinds of value under one name: say less, accept both. | |
| 450 | + | if existing.get("type") != schema.get("type") | |
| 451 | + | && let Some(fields) = existing.as_object_mut() | |
| 452 | + | { | |
| 453 | + | fields.remove("type"); | |
| 454 | + | fields.remove("items"); | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | /// What a call to a tool runs: the operation its action names, or why not. | |
| 461 | + | pub fn resolve(tool: &Tool, arguments: &Value) -> Result<Op, String> { | |
| 462 | + | let names = || { | |
| 463 | + | tool.actions | |
| 464 | + | .iter() | |
| 465 | + | .map(|action| action.name) | |
| 466 | + | .collect::<Vec<_>>() | |
| 467 | + | .join(", ") | |
| 468 | + | }; | |
| 469 | + | let Some(name) = arguments["action"].as_str().or(tool.default_action) else { | |
| 470 | + | return Err(format!("Give an action: one of {}.", names())); | |
| 471 | + | }; | |
| 472 | + | let Some(action) = tool.action(name) else { | |
| 473 | + | return Err(format!("{} has no action {name}. Its actions: {}.", tool.name, names())); | |
| 474 | + | }; | |
| 475 | + | let missing: Vec<String> = action | |
| 476 | + | .op | |
| 477 | + | .required() | |
| 478 | + | .into_iter() | |
| 479 | + | .filter(|field| arguments.get(field).is_none_or(Value::is_null)) | |
| 480 | + | .collect(); | |
| 481 | + | if !missing.is_empty() { | |
| 482 | + | return Err(format!("{}.{name} needs {}.", tool.name, missing.join(", "))); | |
| 483 | + | } | |
| 484 | + | Ok(action.op) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | #[cfg(test)] | |
| 488 | + | mod tests { | |
| 489 | + | use super::*; | |
| 490 | + | use g1t_contracts::scopes::{Preset, Scope}; | |
| 491 | + | ||
| 492 | + | fn listed(gate: &Gate) -> Vec<Value> { | |
| 493 | + | TOOLS.iter().filter_map(|tool| tool.listed(gate)).collect() | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | fn token(scopes: Option<Vec<Scope>>) -> TokenAccess { | |
| 497 | + | TokenAccess { | |
| 498 | + | token_id: "tok_1".to_owned(), | |
| 499 | + | scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 500 | + | legacy: false, | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | #[test] | |
| 505 | + | fn every_operation_is_exactly_one_action_of_one_tool() { | |
| 506 | + | for op in Op::ALL { | |
| 507 | + | let count = TOOLS | |
| 508 | + | .iter() | |
| 509 | + | .flat_map(|tool| tool.actions.iter()) | |
| 510 | + | .filter(|action| action.op == op) | |
| 511 | + | .count(); | |
| 512 | + | assert_eq!(count, 1, "{} is {count} actions", op.name()); | |
| 513 | + | } | |
| 514 | + | for tool in TOOLS { | |
| 515 | + | let mut names = std::collections::HashSet::new(); | |
| 516 | + | for action in tool.actions { | |
| 517 | + | assert!(names.insert(action.name), "{}.{} twice", tool.name, action.name); | |
| 518 | + | } | |
| 519 | + | if let Some(default) = tool.default_action { | |
| 520 | + | assert!(tool.action(default).is_some(), "{}", tool.name); | |
| 521 | + | } | |
| 522 | + | } | |
| 523 | + | assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len()); | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | #[test] | |
| 527 | + | fn every_operation_needs_exactly_one_scope_or_none() { | |
| 528 | + | use g1t_contracts::scopes::OPERATIONS; | |
| 529 | + | for op in Op::ALL { | |
| 530 | + | let mapped = OPERATIONS.iter().filter(|(name, _)| *name == op.name()).count(); | |
| 531 | + | let free = NO_SCOPE.contains(&op.name()); | |
| 532 | + | assert_eq!(mapped + usize::from(free), 1, "{}", op.name()); | |
| 533 | + | } | |
| 534 | + | for (name, _) in OPERATIONS { | |
| 535 | + | assert!(Op::by_name(name).is_some(), "{name} is not an operation"); | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn each_tool_schema_is_valid_with_one_branch_per_action() { | |
| 541 | + | for tool in TOOLS { | |
| 542 | + | let actions: Vec<&Action> = tool.actions.iter().collect(); | |
| 543 | + | let flat = tool.input_schema(&actions); | |
| 544 | + | assert_eq!(flat["type"], "object"); | |
| 545 | + | assert!(flat.get("oneOf").is_none(), "no oneOf at the top level"); | |
| 546 | + | let listed: Vec<&str> = flat["properties"]["action"]["enum"] | |
| 547 | + | .as_array() | |
| 548 | + | .unwrap() | |
| 549 | + | .iter() | |
| 550 | + | .map(|name| name.as_str().unwrap()) | |
| 551 | + | .collect(); | |
| 552 | + | assert_eq!(listed, tool.actions.iter().map(|action| action.name).collect::<Vec<_>>()); | |
| 553 | + | for action in tool.actions { | |
| 554 | + | for field in action.op.required() { | |
| 555 | + | assert!(flat["properties"].get(&field).is_some(), "{}.{}: {field}", tool.name, action.name); | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | let keyed = tool.discriminated(&actions); | |
| 559 | + | let branches = keyed["oneOf"].as_array().unwrap(); | |
| 560 | + | assert_eq!(branches.len(), tool.actions.len()); | |
| 561 | + | for (branch, action) in branches.iter().zip(tool.actions) { | |
| 562 | + | assert_eq!(branch["properties"]["action"]["const"], action.name); | |
| 563 | + | for field in branch["required"].as_array().unwrap() { | |
| 564 | + | assert!(branch["properties"].get(field.as_str().unwrap()).is_some(), "{}.{}: {field}", tool.name, action.name); | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | // A well-formed JSON Schema object throughout. | |
| 568 | + | let text = serde_json::to_string(&flat).unwrap(); | |
| 569 | + | assert!(serde_json::from_str::<Value>(&text).is_ok()); | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | #[test] | |
| 574 | + | fn a_read_only_token_sees_read_actions_only() { | |
| 575 | + | let access = token(Preset::ReadOnly.scopes()); | |
| 576 | + | let gate = Gate::Token(&access); | |
| 577 | + | for tool in TOOLS { | |
| 578 | + | for action in tool.visible(&gate) { | |
| 579 | + | assert!(reads_only(action.op), "{}.{}", tool.name, action.name); | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | let tools = listed(&gate); | |
| 583 | + | for tool in &tools { | |
| 584 | + | assert_eq!(tool["annotations"]["readOnlyHint"], true, "{}", tool["name"]); | |
| 585 | + | assert_eq!(tool["annotations"]["destructiveHint"], false); | |
| 586 | + | } | |
| 587 | + | let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap(); | |
| 588 | + | assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get"])); | |
| 589 | + | // Nothing of the agent tool is a read. | |
| 590 | + | assert!(!tools.iter().any(|tool| tool["name"] == "agent")); | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | #[test] | |
| 594 | + | fn a_narrow_token_sees_only_its_tools() { | |
| 595 | + | let access = token(Some(vec![Scope::IssuesWrite])); | |
| 596 | + | let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect(); | |
| 597 | + | assert_eq!(names, vec![json!("issue"), json!("plan"), json!("account")]); | |
| 598 | + | let full = token(None); | |
| 599 | + | assert_eq!(listed(&Gate::Token(&full)).len(), TOOLS.len()); | |
| 600 | + | assert_eq!(listed(&Gate::Everything).len(), TOOLS.len()); | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | #[test] | |
| 604 | + | fn a_tool_that_can_destroy_says_so() { | |
| 605 | + | let tools = listed(&Gate::Everything); | |
| 606 | + | let repository = tools.iter().find(|tool| tool["name"] == "repository").unwrap(); | |
| 607 | + | assert_eq!(repository["annotations"]["destructiveHint"], true); | |
| 608 | + | assert_eq!(repository["annotations"]["readOnlyHint"], false); | |
| 609 | + | let memory = tools.iter().find(|tool| tool["name"] == "memory").unwrap(); | |
| 610 | + | assert_eq!(memory["annotations"]["destructiveHint"], false); | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | #[test] | |
| 614 | + | fn calls_resolve_to_their_operation_or_say_what_is_missing() { | |
| 615 | + | let issue = Tool::by_name("issue").unwrap(); | |
| 616 | + | assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b", "number": 1 })), Ok(Op::GetIssue)); | |
| 617 | + | assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b" })), Err("issue.get needs number.".to_owned())); | |
| 618 | + | assert!(resolve(issue, &json!({})).unwrap_err().starts_with("Give an action")); | |
| 619 | + | assert!(resolve(issue, &json!({ "action": "explode" })).unwrap_err().contains("no action explode")); | |
| 620 | + | let search = Tool::by_name("search").unwrap(); | |
| 621 | + | assert_eq!(resolve(search, &json!({ "query": "x" })), Ok(Op::Search)); | |
| 622 | + | let account = Tool::by_name("account").unwrap(); | |
| 623 | + | assert_eq!(resolve(account, &json!({})), Ok(Op::Whoami)); | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | /// How much smaller `tools/list` is than one tool per operation. Run | |
| 627 | + | /// with `--nocapture` to see the numbers. | |
| 628 | + | #[test] | |
| 629 | + | fn the_tool_list_is_much_smaller_than_one_tool_per_operation() { | |
| 630 | + | let before: Vec<Value> = Op::ALL | |
| 631 | + | .into_iter() | |
| 632 | + | .map(|op| json!({ "name": op.name(), "description": op.description(), "inputSchema": op.input() })) | |
| 633 | + | .collect(); | |
| 634 | + | let after = listed(&Gate::Everything); | |
| 635 | + | let before_bytes = serde_json::to_string(&json!({ "tools": before })).unwrap().len(); | |
| 636 | + | let after_bytes = serde_json::to_string(&json!({ "tools": after })).unwrap().len(); | |
| 637 | + | let agent = token(Preset::Agent.scopes()); | |
| 638 | + | let agent_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&agent)) })).unwrap().len(); | |
| 639 | + | let read = token(Preset::ReadOnly.scopes()); | |
| 640 | + | let read_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&read)) })).unwrap().len(); | |
| 641 | + | println!( | |
| 642 | + | "tools/list: before {} tools, {before_bytes} bytes (~{} tokens); after {} tools, {after_bytes} bytes (~{} tokens); agent preset {agent_bytes} bytes (~{} tokens); read only {read_bytes} bytes (~{} tokens)", | |
| 643 | + | before.len(), | |
| 644 | + | before_bytes / 4, | |
| 645 | + | after.len(), | |
| 646 | + | after_bytes / 4, | |
| 647 | + | agent_bytes / 4, | |
| 648 | + | read_bytes / 4, | |
| 649 | + | ); | |
| 650 | + | assert!(after_bytes * 2 < before_bytes, "{after_bytes} vs {before_bytes}"); | |
| 651 | + | } | |
| 652 | + | } |
| 60 | 60 | }); | |
| 61 | 61 | } | |
| 62 | 62 | } | |
| 63 | − | // By section, then by the section's own reading order (the MCP tool's | |
| 63 | + | // By section, then by the section's own reading order (the operation's | |
| 64 | 64 | // place in it), then a repository's address before a workspace's. | |
| 65 | − | const order = new Map(document.tags.flatMap((tag) => (tag['x-tools'] ?? []).map((tool, i) => [tool, i]))); | |
| 65 | + | const order = new Map(document.tags.flatMap((tag) => (tag['x-tools'] ?? []).map((name, i) => [name, i]))); | |
| 66 | 66 | return found.sort( | |
| 67 | 67 | (a, b) => | |
| 68 | 68 | tags.indexOf(a.tag) - tags.indexOf(b.tag) || | |
| 69 | − | (order.get(a['x-mcp-tool']) ?? -1) - (order.get(b['x-mcp-tool']) ?? -1) || | |
| 69 | + | (order.get(a['x-operation']) ?? -1) - (order.get(b['x-operation']) ?? -1) || | |
| 70 | 70 | Number(a.operationId.endsWith('_for_workspace')) - Number(b.operationId.endsWith('_for_workspace')) || | |
| 71 | 71 | a.position - b.position, | |
| 72 | 72 | ); | |
| 181 | 181 | return { lead: match[1], rest: [first.slice(match[0].length), ...paragraphs.slice(1)].join('\n\n') }; | |
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | − | /** Each MCP tool's page: the first of its addresses, a repository's. */ | |
| 184 | + | /** Each operation's page: the first of its addresses, a repository's. */ | |
| 185 | 185 | const pages = new Map(); | |
| 186 | 186 | ||
| 187 | 187 | /** Names of other operations in running text, linked to their pages. */ | |
| 196 | 196 | /** The page for one operation. */ | |
| 197 | 197 | function page(operation, all) { | |
| 198 | 198 | const { lead, rest } = split(operation.description); | |
| 199 | + | const name = operation['x-operation']; | |
| 199 | 200 | const tool = operation['x-mcp-tool']; | |
| 201 | + | const action = operation['x-mcp-action']; | |
| 202 | + | const scope = operation['x-scope']; | |
| 200 | 203 | const security = operation.security ?? [{ token: [] }]; | |
| 201 | 204 | const auth = | |
| 202 | 205 | security.length === 0 | |
| 204 | 207 | : security.some((entry) => Object.keys(entry).length === 0) | |
| 205 | 208 | ? 'Optional. Public data can be read without a token; send one to see what is private.' | |
| 206 | 209 | : 'Required. Send an [access token](/reference/api/#authentication) as `Authorization: Bearer`.'; | |
| 207 | − | const siblings = all.filter((other) => tool && other['x-mcp-tool'] === tool && other !== operation); | |
| 210 | + | const siblings = all.filter((other) => name && other['x-operation'] === name && other !== operation); | |
| 208 | 211 | ||
| 209 | 212 | const out = []; | |
| 210 | 213 | out.push('---'); | |
| 219 | 222 | ); | |
| 220 | 223 | out.push(''); | |
| 221 | 224 | if (rest) { | |
| 222 | − | out.push(linkTools(prose(rest), tool)); | |
| 225 | + | out.push(linkTools(prose(rest), name)); | |
| 223 | 226 | out.push(''); | |
| 224 | 227 | } | |
| 225 | 228 | const facts = [['Authentication', auth]]; | |
| 226 | 229 | facts.push([ | |
| 227 | 230 | 'MCP tool', | |
| 228 | − | tool ? `[\`${tool}\`](/reference/mcp/), with the same inputs` : 'None. Signing in is on the REST API only.', | |
| 231 | + | tool | |
| 232 | + | ? `[\`${tool}\`](/reference/mcp/#${tool}) with \`action\` \`${action}\`, and the same inputs` | |
| 233 | + | : 'None. Signing in is on the REST API only.', | |
| 229 | 234 | ]); | |
| 235 | + | if (tool) { | |
| 236 | + | facts.push([ | |
| 237 | + | 'Scope', | |
| 238 | + | scope | |
| 239 | + | ? `An access token needs [\`${scope}\`](/guides/authentication/#scopes).` | |
| 240 | + | : 'None. Any access token may use it.', | |
| 241 | + | ]); | |
| 242 | + | } | |
| 230 | 243 | if (siblings.length) { | |
| 231 | 244 | facts.push([ | |
| 232 | 245 | 'Also at', | |
| 294 | 307 | const all = operations(document); | |
| 295 | 308 | pages.clear(); | |
| 296 | 309 | for (const operation of all) { | |
| 297 | − | const tool = operation['x-mcp-tool']; | |
| 298 | − | // A tool's name links to its first address: the repository's. | |
| 299 | − | if (tool && !pages.has(tool)) pages.set(tool, operation.slug); | |
| 310 | + | const name = operation['x-operation']; | |
| 311 | + | // An operation's name links to its first address: the repository's. | |
| 312 | + | if (name && !pages.has(name)) pages.set(name, operation.slug); | |
| 300 | 313 | } | |
| 301 | 314 | ||
| 302 | 315 | rmSync(OUT, { recursive: true, force: true }); |
| 173 | 173 | alternatives, and one will be merged. Two for *different* issues are heading | |
| 174 | 174 | for a conflict, and g1t says so while the work is still going on rather than | |
| 175 | 175 | when the second one tries to merge. Agents get the same list from | |
| 176 | − | `get_pull_request`, as `overlaps`. | |
| 176 | + | the `pull_request` tool's `get` action, as `overlaps`. | |
| 177 | 177 | ||
| 178 | 178 | A g1t agent is told about the other work before it starts. Its instructions | |
| 179 | 179 | list every pull request in progress in the repository, what each is for and |
| 177 | 177 | ||
| 178 | 178 | ## From the API | |
| 179 | 179 | ||
| 180 | − | The routes are GitHub's, so scripts written for GitHub's API mostly work | |
| 181 | − | with `https://api.g1t.sh` in place of `https://api.github.com`. | |
| 180 | + | The routes follow the standard Actions REST shape, so existing scripts | |
| 181 | + | usually work once they point at `https://api.g1t.sh`. | |
| 182 | 182 | ||
| 183 | − | | Tool | Route | | |
| 183 | + | | `workflow` action | Route | | |
| 184 | 184 | | --- | --- | | |
| 185 | − | | `list_workflows` | `GET /repos/{owner}/{repo}/actions/workflows` | | |
| 186 | − | | `list_workflow_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` | | |
| 187 | − | | `get_workflow_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` | | |
| 188 | − | | `get_job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=` | | |
| 189 | − | | `dispatch_workflow` | `POST /repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches` with `ref` and `inputs` | | |
| 190 | − | | `cancel_workflow_run` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel` | | |
| 191 | − | | `rerun_workflow_run` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs` | | |
| 192 | − | | `update_workflow` | `PUT …/workflows/{workflow}/enable` and `…/disable` | | |
| 185 | + | | `list` | `GET /repos/{owner}/{repo}/actions/workflows` | | |
| 186 | + | | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` | | |
| 187 | + | | `get_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` | | |
| 188 | + | | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=` | | |
| 189 | + | | `dispatch` | `POST /repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches` with `ref` and `inputs` | | |
| 190 | + | | `cancel` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel` | | |
| 191 | + | | `rerun` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs` | | |
| 192 | + | | `update` | `PUT …/workflows/{workflow}/enable` and `…/disable` | | |
| 193 | 193 | | `list_actions_secrets`, `set_actions_secret`, `delete_actions_secret` | `GET`, `PUT` and `DELETE /repos/{owner}/{repo}/actions/secrets/{name}` | | |
| 194 | 194 | | `list_actions_variables`, `set_actions_variable`, `delete_actions_variable` | `GET` and `POST /repos/{owner}/{repo}/actions/variables`, `PATCH` and `DELETE …/variables/{name}` | | |
| 195 | 195 |
| 99 | 99 | A session's page shows its runs, each with its steps, then the session | |
| 100 | 100 | itself: prompts, what the agent said, the tools it ran and, with **Show | |
| 101 | 101 | tool results**, what they returned. Sessions from your own agents, recorded | |
| 102 | − | with `record_session`, are listed the same way. See | |
| 102 | + | with the `pull_request` tool's `record_session` action, are listed the | |
| 103 | + | same way. See | |
| 103 | 104 | [sessions and why-blame](/guides/why-blame/) for what a session records. | |
| 104 | 105 | ||
| 105 | 106 | A session is as visible as its project. The model and the cost of a run | |
| 140 | 141 | notes from colleagues: usually right, sometimes out of date, and where it | |
| 141 | 142 | disagrees with the code, the code wins. | |
| 142 | 143 | ||
| 143 | − | Agents add to it as they work with the `remember` tool, choosing the scope | |
| 144 | − | themselves: `project` for this codebase, `workspace` for what holds across | |
| 144 | + | Agents add to it as they work with the `memory` tool's `remember` action, | |
| 145 | + | choosing the scope themselves: `project` for this codebase, `workspace` for what holds across | |
| 145 | 146 | projects. Each memory records where it came from: the person who wrote it, | |
| 146 | 147 | or the agent's run and the pull request it was working on, linked from the | |
| 147 | 148 | memory. | |
| 162 | 163 | live addresses, the kept memories closest to its task, and recent | |
| 163 | 164 | decisions. | |
| 164 | 165 | ||
| 165 | − | Your own agents can use memory too, through the [MCP tools](/reference/mcp/#memory) | |
| 166 | − | `remember` and `recall` or the API: | |
| 166 | + | Your own agents can use memory too, through the [`memory` tool](/reference/mcp/#memory) | |
| 167 | + | and its `remember` and `recall` actions, or the API: | |
| 167 | 168 | ||
| 168 | 169 | ```sh | |
| 169 | 170 | curl -X POST https://api.g1t.sh/repos/acme/web/memory \ |
| 1 | 1 | --- | |
| 2 | 2 | title: Accounts and authentication | |
| 3 | − | description: Accounts, invites, email addresses, confirming them, personal access tokens, OAuth, signing in from a tool, password reset and your security log. | |
| 3 | + | description: Accounts, invites, email addresses, confirming them, personal access tokens and their scopes, OAuth, signing in from a tool, password reset and your security log. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | ## Creating an account | |
| 127 | 127 | ||
| 128 | 128 | ### Invites through the API | |
| 129 | 129 | ||
| 130 | − | | Route | MCP tool | What it does | | |
| 130 | + | | Route | MCP tool and action | What it does | | |
| 131 | 131 | | --- | --- | --- | | |
| 132 | − | | [`GET /user/invites`](/reference/api/invites/list-invites/) | `list_invites` | Your invites and how many you have left | | |
| 133 | − | | [`POST /user/invites`](/reference/api/invites/create-invite/) | `create_invite` | Make an invite, optionally for one `email` | | |
| 134 | − | | [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | `revoke_invite` | Revoke a pending invite | | |
| 135 | − | | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | `invite_member` | Invite an address into a workspace. Owners only. | | |
| 132 | + | | [`GET /user/invites`](/reference/api/invites/list-invites/) | `account` `list_invites` | Your invites and how many you have left | | |
| 133 | + | | [`POST /user/invites`](/reference/api/invites/create-invite/) | `account` `create_invite` | Make an invite, optionally for one `email` | | |
| 134 | + | | [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | `account` `revoke_invite` | Revoke a pending invite | | |
| 135 | + | | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | `workspace` `invite_member` | Invite an address into a workspace. Owners only. | | |
| 136 | 136 | ||
| 137 | 137 | ## Confirming your email | |
| 138 | 138 | ||
| 245 | 245 | ||
| 246 | 246 | ### Email addresses through the API | |
| 247 | 247 | ||
| 248 | − | | Route | MCP tool | What it does | | |
| 248 | + | | Route | MCP tool and action | What it does | | |
| 249 | 249 | | --- | --- | --- | | |
| 250 | − | | [`GET /user/emails`](/reference/api/accounts/list-emails/) | `list_emails` | Your addresses and email settings | | |
| 251 | − | | [`POST /user/emails`](/reference/api/accounts/add-email/) | `add_email` | Add an address; takes `email` and `password` | | |
| 252 | − | | [`DELETE /user/emails/{email}`](/reference/api/accounts/remove-email/) | `remove_email` | Remove an address; takes `password` | | |
| 253 | − | | [`PATCH /user/email-settings`](/reference/api/accounts/update-email-settings/) | `update_email_settings` | Change `primary`, `backup`, `private_email` or `block_private_pushes` | | |
| 250 | + | | [`GET /user/emails`](/reference/api/accounts/list-emails/) | `account` `list_emails` | Your addresses and email settings | | |
| 251 | + | | [`POST /user/emails`](/reference/api/accounts/add-email/) | `account` `add_email` | Add an address; takes `email` and `password` | | |
| 252 | + | | [`DELETE /user/emails/{email}`](/reference/api/accounts/remove-email/) | `account` `remove_email` | Remove an address; takes `password` | | |
| 253 | + | | [`PATCH /user/email-settings`](/reference/api/accounts/update-email-settings/) | `account` `update_email_settings` | Change `primary`, `backup`, `private_email` or `block_private_pushes` | | |
| 254 | 254 | ||
| 255 | 255 | Through the API, `password` is the proof a sensitive change needs. Without | |
| 256 | 256 | it, or with the wrong one, the answer is `403` with the code | |
| 274 | 274 | | API | `Authorization: Bearer g1t_…` | | |
| 275 | 275 | | MCP | The same header, set when you add the server. | | |
| 276 | 276 | ||
| 277 | − | Create one in [Settings → Access tokens](https://g1t.sh/settings/tokens). A token is shown once, | |
| 278 | − | when it is created; g1t stores only a hash of it. If you lose one, delete it | |
| 279 | − | and create another. Delete a token the moment you think someone else has | |
| 280 | − | seen it. | |
| 277 | + | A token is shown once, when it is created; g1t stores only a hash of it. | |
| 278 | + | If you lose one, delete it and create another. Delete a token the moment | |
| 279 | + | you think someone else has seen it. | |
| 281 | 280 | ||
| 282 | − | A token has the full rights of your account. Scoped tokens are planned. | |
| 281 | + | A token reaches everything you can reach, and its [scopes](#scopes) say | |
| 282 | + | what it may do there. Give each token only the scopes the thing using it | |
| 283 | + | needs. | |
| 283 | 284 | ||
| 284 | 285 | For CI and integrations that work for a team, a workspace can have tokens | |
| 285 | 286 | of its own that act as the workspace and keep working when their creator | |
| 286 | − | leaves. See [workspace access tokens](/guides/workspaces/#workspace-access-tokens). | |
| 287 | + | leaves. See [workspace tokens](#workspace-tokens). | |
| 288 | + | ||
| 289 | + | ### Create a token | |
| 290 | + | ||
| 291 | + | 1. Open [Settings → Access tokens](https://g1t.sh/settings/tokens). | |
| 292 | + | 2. Under **New token**, give it a **Name** after what will use it. | |
| 293 | + | 3. Choose when it **Expires**: 7 days, 30 days, 90 days (the default), | |
| 294 | + | 1 year, or No expiry. An expired token stops working; make a new one. | |
| 295 | + | No expiry shows a warning: the token works until someone deletes it. | |
| 296 | + | 4. Under **Scopes**, tick the boxes for what it may do. They are grouped | |
| 297 | + | by area. The form starts on the **Agent** [preset](#presets); select | |
| 298 | + | another preset to tick its boxes instead. | |
| 299 | + | 5. Select **Create token**, and copy the token. It is not shown again. | |
| 300 | + | ||
| 301 | + | The list shows each token's name, when it was made and last used, when it | |
| 302 | + | expires, and its access: a preset's name, its scopes, or Full access. To | |
| 303 | + | change what a token may do, select **Edit access**, tick or untick boxes, | |
| 304 | + | and select **Save access**. The token stays the same; the change applies | |
| 305 | + | from its next request. | |
| 306 | + | ||
| 307 | + | ## Scopes | |
| 308 | + | ||
| 309 | + | A scope is a resource and a level, written `resource:level`, such as | |
| 310 | + | `issues:write`. A higher level includes the lower ones of the same | |
| 311 | + | resource: `repo:admin` includes `repo:write`, which includes `repo:read`. | |
| 312 | + | It never includes another resource: `repo:admin` does not let a token push, | |
| 313 | + | which is `code:write`. | |
| 314 | + | ||
| 315 | + | On the form, scopes are a checklist grouped by area: | |
| 316 | + | ||
| 317 | + | | Group | Scopes | | |
| 318 | + | | --- | --- | | |
| 319 | + | | Repositories & code | `repo:read`, `repo:write`, `code:read`, `code:write` | | |
| 320 | + | | Issues & pull requests | `issues:read`, `issues:write`, `pull_requests:read`, `pull_requests:write` | | |
| 321 | + | | Agents | `agents:run` | | |
| 322 | + | | Workflows | `workflows:read`, `workflows:write` | | |
| 323 | + | | Memory & search | `memory:read`, `memory:write` | | |
| 324 | + | | Account | `account:read`, `account:write` | | |
| 325 | + | | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` | | |
| 326 | + | | Dangerous | `repo:admin`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin` | | |
| 327 | + | ||
| 328 | + | Ticking a higher level ticks the lower ones of its resource and greys | |
| 329 | + | them out: tick `issues:write` and `issues:read` is ticked too. Untick | |
| 330 | + | `issues:write` and `issues:read` stays ticked. | |
| 331 | + | ||
| 332 | + | | Scope | What it lets a token do | | |
| 333 | + | | --- | --- | | |
| 334 | + | | `repo:read` | See repositories, their settings, labels and timelines, and search | | |
| 335 | + | | `repo:write` | Create repositories, rename branches and change how pull requests merge | | |
| 336 | + | | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository | | |
| 337 | + | | `code:read` | Clone and fetch private repositories with git | | |
| 338 | + | | `code:write` | Push commits with git | | |
| 339 | + | | `issues:read` | Read issues, comments and plans | | |
| 340 | + | | `issues:write` | Open, edit, close and comment on issues | | |
| 341 | + | | `pull_requests:read` | Read pull requests, their changes, sessions and merge queues | | |
| 342 | + | | `pull_requests:write` | Open, review, close and merge pull requests | | |
| 343 | + | | `agents:run` | Put g1t agents to work and message them, which uses the workspace's money | | |
| 344 | + | | `workflows:read` | Read workflows, runs and logs | | |
| 345 | + | | `workflows:write` | Run, cancel, rerun and turn workflows on or off | | |
| 346 | + | | `memory:read` | Recall memory and search the workspace's context | | |
| 347 | + | | `memory:write` | Save memory for the next agent | | |
| 348 | + | | `account:read` | Read your email addresses, invites and invitations | | |
| 349 | + | | `account:write` | Change your email addresses, make invites and answer invitations | | |
| 350 | + | | `workspace:read` | Read workspace invites, integrations and model routes | | |
| 351 | + | | `workspace:admin` | Create and delete workspaces, invite members, connect integrations | | |
| 352 | + | | `access:read` | See who has access to repositories | | |
| 353 | + | | `access:admin` | Give and take away access to repositories | | |
| 354 | + | | `webhooks:read` | See webhooks and their deliveries | | |
| 355 | + | | `webhooks:admin` | Create, change and delete webhooks | | |
| 356 | + | | `secrets:read` | List secrets (never their values) and read variables | | |
| 357 | + | | `secrets:admin` | Set and delete secrets and variables | | |
| 358 | + | ||
| 359 | + | Every operation of the API and the MCP server needs exactly one of these, | |
| 360 | + | except `whoami` (`GET /user`), which any token may use. Each endpoint's page | |
| 361 | + | in the [API reference](/reference/api/) names its scope, and so does each | |
| 362 | + | action in [MCP tools](/reference/mcp/). A few calls need a second scope for | |
| 363 | + | what they ask: | |
| 364 | + | ||
| 365 | + | | Call | Also needs | | |
| 366 | + | | --- | --- | | |
| 367 | + | | `delegate` (`POST /repos/{owner}/{name}/issues/delegate`, the `agent` tool's `delegate`), which opens an issue | `issues:write`, beside `agents:run` | | |
| 368 | + | | `apply_plan` or `import_issue` (the `plan` tool's `apply`, the `issue` tool's `import`) with `assign: true` | `agents:run` | | |
| 369 | + | | `update_repo` with `private` or `default_branch` | `repo:admin` | | |
| 370 | + | ||
| 371 | + | ### What a token can do | |
| 372 | + | ||
| 373 | + | What a request may do is where two things overlap: | |
| 374 | + | ||
| 375 | + | 1. **Your role.** A token reaches every workspace and repository you can, | |
| 376 | + | including ones you join later, and never does more there than you could | |
| 377 | + | on the website. A token with `repo:admin` still cannot delete a | |
| 378 | + | repository unless you are an owner of its workspace. See | |
| 379 | + | [access and roles](/guides/access-and-roles/). | |
| 380 | + | 2. **Its scopes.** What kinds of thing it may do. | |
| 381 | + | ||
| 382 | + | To keep a token away from a workspace, use a | |
| 383 | + | [workspace token](#workspace-tokens) instead: it reaches only its own | |
| 384 | + | workspace. | |
| 385 | + | ||
| 386 | + | ### Presets | |
| 387 | + | ||
| 388 | + | A preset ticks a starting set of boxes. Select one, then tick or untick | |
| 389 | + | any box. | |
| 390 | + | ||
| 391 | + | | Preset | Scopes | | |
| 392 | + | | --- | --- | | |
| 393 | + | | Read only | Every `read` scope. Changes nothing. | | |
| 394 | + | | Agent | Every `read` scope, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Reads everything, works on issues and pull requests, pushes code and runs g1t agents. No admin scope. | | |
| 395 | + | | CI | `repo:read`, `code:read`, `code:write`, `workflows:read` and `workflows:write`. Clones and pushes code, and runs workflows. | | |
| 396 | + | | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. | | |
| 397 | + | ||
| 398 | + | Admin scopes change things that are hard to undo, or decide who can reach | |
| 399 | + | what. They are under **Dangerous**, with a warning. Give them only to | |
| 400 | + | something you trust as much as yourself. | |
| 401 | + | ||
| 402 | + | ### Git and scopes | |
| 403 | + | ||
| 404 | + | Over HTTPS, git checks the same token: | |
| 405 | + | ||
| 406 | + | | To | Needs | | |
| 407 | + | | --- | --- | | |
| 408 | + | | Clone or fetch a public repository | No scope | | |
| 409 | + | | Clone or fetch a private repository | `code:read` | | |
| 410 | + | | Push | `code:write` | | |
| 411 | + | ||
| 412 | + | Your role on the repository applies too, as on the website. A refused push | |
| 413 | + | or clone says which scope is missing. | |
| 414 | + | ||
| 415 | + | ### When a token lacks a scope | |
| 416 | + | ||
| 417 | + | The API answers `403` with the scope that was missing in `needed_scope`: | |
| 418 | + | ||
| 419 | + | ```json | |
| 420 | + | { | |
| 421 | + | "error": { | |
| 422 | + | "code": "forbidden", | |
| 423 | + | "message": "This access token needs the issues:write scope to use create_issue.", | |
| 424 | + | "needed_scope": "issues:write" | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ``` | |
| 428 | + | ||
| 429 | + | Through MCP the same message comes back as a tool result with `isError` | |
| 430 | + | set. Give the token that scope with **Edit access**, or make a new token. | |
| 431 | + | ||
| 432 | + | ### Tokens made before scopes | |
| 433 | + | ||
| 434 | + | Tokens and OAuth sign-ins made before tokens had scopes keep full access, | |
| 435 | + | so nothing that uses them stops working. Settings marks each one | |
| 436 | + | **Legacy · full access**, and says to narrow it to what it needs. For a | |
| 437 | + | token, select **Narrow this token**; for an application, **Change access** | |
| 438 | + | in [Connected applications](https://g1t.sh/settings/applications). Then | |
| 439 | + | tick its scopes. A token you make with Full access on purpose is not marked | |
| 440 | + | legacy. | |
| 441 | + | ||
| 442 | + | A token from [signing in from a tool](#signing-in-from-a-tool), such as the | |
| 443 | + | g1t CLI, has full access. | |
| 444 | + | ||
| 445 | + | ### Workspace tokens | |
| 446 | + | ||
| 447 | + | A workspace's own tokens act as the workspace rather than a person. An | |
| 448 | + | owner makes them in the workspace's **Settings → Access tokens**, with the | |
| 449 | + | same checklist and expiry choices; the form starts on the CI preset. A | |
| 450 | + | workspace token reaches all of that workspace's repositories, never | |
| 451 | + | another workspace, and cannot manage people, tokens or workspaces. See | |
| 452 | + | [workspace access tokens](/guides/workspaces/#workspace-access-tokens). | |
| 287 | 453 | ||
| 288 | 454 | ## Signing in with OAuth | |
| 289 | 455 | ||
| 293 | 459 | back, and you approve or deny. The application never sees your password and | |
| 294 | 460 | there is no token to copy. | |
| 295 | 461 | ||
| 462 | + | The page lists what the application will be able to do, as the same | |
| 463 | + | checklist a token has, with only the scopes it asked for, all ticked. | |
| 464 | + | Untick anything you would rather it could not do, leaving at least one; | |
| 465 | + | you cannot give it more than it asked for. Like a token, it reaches | |
| 466 | + | everything you can. | |
| 467 | + | ||
| 468 | + | An application that asks for no scopes in particular gets the | |
| 469 | + | [Agent preset](#presets): every `read` scope, and `code:write`, | |
| 470 | + | `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. | |
| 471 | + | It never gets an admin scope unless it asks for one and you leave it | |
| 472 | + | ticked. | |
| 473 | + | ||
| 296 | 474 | Applications you have approved are listed in | |
| 297 | − | [Settings → Connected applications](https://g1t.sh/settings/applications). Signing one out ends its access at | |
| 298 | − | once. | |
| 475 | + | [Settings → Connected applications](https://g1t.sh/settings/applications), | |
| 476 | + | each with its access. Select **Change access** to tick or untick its | |
| 477 | + | scopes, then **Save access**: it stays signed in, the change applies at | |
| 478 | + | once, and its next refresh keeps it. Select **Sign out** to end its access | |
| 479 | + | at once. | |
| 299 | 480 | ||
| 300 | 481 | For people building a client: | |
| 301 | 482 | ||
| 313 | 494 | client on `localhost` may use any port. | |
| 314 | 495 | - Registration stores nothing. The client id it returns encodes what was | |
| 315 | 496 | registered, so it cannot be used to fill g1t with junk. | |
| 497 | + | - Ask for scopes with `scope` on the authorization request, separated by | |
| 498 | + | spaces, such as `scope=repo:read issues:write pull_requests:write`. | |
| 499 | + | Names g1t does not know are left out. Leave `scope` out for the Agent | |
| 500 | + | preset. The authorization server's metadata and | |
| 501 | + | `https://mcp.g1t.sh/.well-known/oauth-protected-resource` list every | |
| 502 | + | scope in `scopes_supported`. | |
| 503 | + | - The token response's `scope` holds the scopes the person granted, | |
| 504 | + | separated by spaces, or `*` for a sign-in with full access. Refreshing | |
| 505 | + | keeps them. | |
| 316 | 506 | - An access token lasts 30 days. The refresh token returned with it works | |
| 317 | 507 | once and returns the next pair; the previous access token stops working. | |
| 318 | 508 | - An authorization code lasts five minutes and works once. | |
| 320 | 510 | ## Signing in from a tool | |
| 321 | 511 | ||
| 322 | 512 | A tool that cannot receive a redirect, such as a script on a remote machine, | |
| 323 | − | gets a token without ever handling your password, the same way | |
| 324 | − | `gh auth login` works: | |
| 513 | + | gets a token without ever handling your password: | |
| 325 | 514 | ||
| 326 | 515 | 1. The tool asks g1t for a code and shows you a link and a short code such | |
| 327 | 516 | as `WDJB-MJHT`. | |
| 345 | 534 | [Settings → Access tokens](https://g1t.sh/settings/tokens) under the tool's name, where you | |
| 346 | 535 | can delete it. | |
| 347 | 536 | ||
| 348 | − | Only approve a code you asked for. Approving gives the tool the full rights | |
| 349 | − | of your account. | |
| 537 | + | Only approve a code you asked for. The token has full access: it can do | |
| 538 | + | everything you can. To give a tool less, make an | |
| 539 | + | [access token](#create-a-token) with only the scopes it needs instead. | |
| 350 | 540 | ||
| 351 | 541 | ## Resetting your password | |
| 352 | 542 |
| 15 | 15 | ||
| 16 | 16 | <AgentSetup /> | |
| 17 | 17 | ||
| 18 | − | Signing in opens g1t in your browser; you approve, and the agent is | |
| 19 | − | connected. There is no token to copy. It shows up in | |
| 18 | + | Signing in opens g1t in your browser. The page lists what the agent will be | |
| 19 | + | able to do; untick anything you would rather it could not, and approve. | |
| 20 | + | There is no token to copy. An agent that asks for nothing in particular gets the | |
| 21 | + | [Agent preset](/guides/authentication/#presets), which never includes an | |
| 22 | + | admin scope. It shows up in | |
| 20 | 23 | [Settings → Connected applications](https://g1t.sh/settings/applications), | |
| 21 | − | where you can sign it out. | |
| 24 | + | where you can change what it may do, or sign it out. | |
| 22 | 25 | ||
| 23 | 26 | The agent also needs to push with git, which asks for a username and a | |
| 24 | 27 | password: use your g1t username and an | |
| 25 | 28 | [access token](/guides/authentication/#access-tokens). | |
| 26 | 29 | ||
| 27 | 30 | Ask it to list the open issues on a repository, or to work on one, and it | |
| 28 | − | will use g1t's tools. [MCP tools](/reference/mcp/) lists every one. | |
| 31 | + | will use g1t's tools. [MCP tools](/reference/mcp/) lists every one. The | |
| 32 | + | agent sees only the tools and actions its access allows. | |
| 29 | 33 | ||
| 30 | 34 | ### With a token instead | |
| 31 | 35 | ||
| 35 | 39 | ||
| 36 | 40 | <AgentSetup variant="token" /> | |
| 37 | 41 | ||
| 42 | + | ### What to give an agent | |
| 43 | + | ||
| 44 | + | Give an agent the least that lets it do its work: | |
| 45 | + | ||
| 46 | + | | Choose | For an agent | | |
| 47 | + | | --- | --- | | |
| 48 | + | | Scopes | The **Agent** preset: every `read` scope, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Untick `agents:run` if it should not start g1t's agents, which spends the workspace's money. | | |
| 49 | + | | Expires | The shortest that fits the work, such as 30 days. | | |
| 50 | + | ||
| 51 | + | A token reaches every workspace and repository you can. To keep an agent | |
| 52 | + | to one workspace, give it that workspace's own | |
| 53 | + | [workspace token](/guides/workspaces/#workspace-access-tokens) instead. | |
| 54 | + | ||
| 55 | + | The same choices are on the sign-in page for an agent that connects with | |
| 56 | + | OAuth, and in [Settings → Access tokens](https://g1t.sh/settings/tokens) | |
| 57 | + | for a token. A call the agent's access does not allow is refused with the | |
| 58 | + | scope it needs; see [scopes](/guides/authentication/#scopes). | |
| 59 | + | ||
| 38 | 60 | ### Repository instructions | |
| 39 | 61 | ||
| 40 | 62 | Each agent reads a repository's instructions from a file at its root: | |
| 45 | 67 | ||
| 46 | 68 | ### Recording sessions automatically | |
| 47 | 69 | ||
| 48 | − | This is for Claude Code. An agent can record its own session with `record_session`, but it has to | |
| 49 | − | remember to. To have every session recorded without asking, install g1t's | |
| 50 | − | hook: | |
| 70 | + | This is for Claude Code. An agent can record its own session with the | |
| 71 | + | `pull_request` tool's `record_session` action, but it has to remember to. | |
| 72 | + | To have every session recorded without asking, install g1t's hook: | |
| 51 | 73 | ||
| 52 | 74 | ```sh | |
| 53 | 75 | curl -fsSL https://g1t.sh/install/claude.sh | sh | |
| 67 | 89 | ||
| 68 | 90 | ## How an agent works on an issue | |
| 69 | 91 | ||
| 70 | − | 1. `get_issue` to read the description and acceptance checks, and to see | |
| 71 | − | which pull requests already exist for it. | |
| 72 | − | 2. `create_pull_request` with the issue's number. This opens a draft pull | |
| 73 | − | request and returns the git remote of its fork. | |
| 74 | − | 3. Clone the fork, make changes, commit and push. Use the access token as the | |
| 75 | − | git password. | |
| 76 | − | 4. `record_session` as it goes, so people can see its reasoning. | |
| 77 | − | 5. `mark_pull_request_ready` with a summary of what changed and why. | |
| 92 | + | 1. `issue` with `get`, to read the description and acceptance checks, and | |
| 93 | + | to see which pull requests already exist for it. | |
| 94 | + | 2. `memory` with `recall`, to read what the project remembers: how to | |
| 95 | + | build, conventions and traps. | |
| 96 | + | 3. `pull_request` with `create` and the issue's number. This opens a draft | |
| 97 | + | pull request and returns the git remote of its fork. | |
| 98 | + | 4. Clone the fork, make changes, commit and push. Use the access token as the | |
| 99 | + | git password; it needs `code:write`. | |
| 100 | + | 5. `pull_request` with `record_session` as it goes, so people can see its | |
| 101 | + | reasoning. | |
| 102 | + | 6. `pull_request` with `ready` and a summary of what changed and why. | |
| 103 | + | ||
| 104 | + | For example, the call that starts the pull request: | |
| 78 | 105 | ||
| 106 | + | ```json | |
| 107 | + | { "name": "pull_request", "arguments": { "action": "create", "repo": "flagon-io/hello", "issue": 42, "agent": "claude-code" } } | |
| 108 | + | ``` | |
| 109 | + | ||
| 79 | 110 | When the pull request is ready, g1t runs the issue's acceptance checks | |
| 80 | − | against it in a clean sandbox. `get_pull_request` returns each command's | |
| 111 | + | against it in a clean sandbox. `pull_request` with `get` returns each command's | |
| 81 | 112 | result and output, so an agent whose checks failed can read why, push a fix, | |
| 82 | 113 | and have them run again. | |
| 83 | 114 | ||
| 86 | 117 | ||
| 87 | 118 | ## Tools | |
| 88 | 119 | ||
| 89 | − | Repositories are given as `owner/name`, and issues and pull requests as the | |
| 90 | − | repository and a `number`. [MCP tools](/reference/mcp/) lists every tool | |
| 91 | − | with its required inputs and its REST route. | |
| 120 | + | Each tool is a kind of thing on g1t, such as `issue` or `pull_request`, | |
| 121 | + | and takes an `action`, such as `get` or `create`. Repositories are given as | |
| 122 | + | `owner/name`, and issues and pull requests as the repository and a | |
| 123 | + | `number`. [MCP tools](/reference/mcp/) lists every tool and action with its | |
| 124 | + | required inputs, its scope and its REST route. | |
| 92 | 125 | ||
| 93 | 126 | ## Staying out of each other's way | |
| 94 | 127 | ||
| 95 | − | `get_pull_request` returns `overlaps`: other pull requests in progress that | |
| 128 | + | `pull_request` with `get` returns `overlaps`: other pull requests in progress that | |
| 96 | 129 | change files this one changes, with the paths. An agent should look before | |
| 97 | 130 | it goes far. An overlap with a pull request for a different issue will | |
| 98 | 131 | become a conflict for whichever merges second, so it is worth narrowing the | |
| 105 | 138 | ## Talking to g1t agents | |
| 106 | 139 | ||
| 107 | 140 | Your agent can send the g1t agent working on a pull request a message with | |
| 108 | − | `message_agent`; it arrives at that agent's next step. g1t agents also ask | |
| 141 | + | `agent` and `message`; it arrives at that agent's next step. g1t agents also ask | |
| 109 | 142 | each other questions and hand each other work. See | |
| 110 | 143 | [talk to agents](/guides/talking-to-agents/). | |
| 111 | 144 | ||
| 112 | 145 | ## Reviewing as an agent | |
| 113 | 146 | ||
| 114 | 147 | An agent can review as well as write. Given an issue with several pull | |
| 115 | − | requests, it can call `get_pull_request_changes` and `read_session` on each, | |
| 116 | − | compare them, and read each one's check results from `get_pull_request`. It | |
| 117 | − | can leave findings on specific lines with `add_comment`, give a verdict with | |
| 118 | − | `review_pull_request`, and, if its account is a member of the workspace, | |
| 119 | − | `merge_pull_request` the best one. It cannot review a pull request it opened. | |
| 148 | + | requests, it can call `pull_request` with `changes` and `read_session` on | |
| 149 | + | each, compare them, and read each one's check results from `get`. It can | |
| 150 | + | leave findings on specific lines with `issue` and `comment`, give a verdict | |
| 151 | + | with `pull_request` and `review`, and, if its account is a member of the | |
| 152 | + | workspace, `merge` the best one. It cannot review a pull request it opened. | |
| 120 | 153 | ||
| 121 | 154 | ## Filing issues from another system | |
| 122 | 155 | ||
| 123 | 156 | Anything that holds an access token can open issues: an error tracker, a | |
| 124 | − | monitor, a script. Call `create_issue`, or `POST | |
| 157 | + | monitor, a script. Call the `issue` tool with `create`, or `POST | |
| 125 | 158 | /repos/{owner}/{name}/issues`, with a title, a description and labels | |
| 126 | − | such as `bug`. The issue is attributed to the account the token belongs to. | |
| 159 | + | such as `bug`. Its token needs `issues:write`. The issue is attributed to the account the token belongs to. | |
| 127 | 160 | ||
| 128 | 161 | ## Session entries | |
| 129 | 162 | ||
| 130 | − | `record_session` takes a list of entries. Each has a `kind` (`prompt`, | |
| 131 | − | `message`, `tool_call`, `tool_result` or `note`) and `text`, and tool | |
| 132 | − | entries also carry the `tool` name. See | |
| 163 | + | The `record_session` action takes a list of `entries`. Each has a `kind` | |
| 164 | + | (`prompt`, `message`, `tool_call`, `tool_result` or `note`) and `text`, | |
| 165 | + | and tool entries also carry the `tool` name. See | |
| 133 | 166 | [sessions and why-blame](/guides/why-blame/#sessions) for what each kind is | |
| 134 | 167 | for and how sessions explain each line. | |
| 135 | 168 | ||
| 149 | 182 | your browser. See [signing in with OAuth](/guides/authentication/#signing-in-with-oauth). | |
| 150 | 183 | ||
| 151 | 184 | A client that does not can send `Authorization: Bearer <token>` with an | |
| 152 | − | access token. | |
| 185 | + | access token. A client that asks for scopes sends them in `scope` on the | |
| 186 | + | authorization request; see | |
| 187 | + | [signing in with OAuth](/guides/authentication/#signing-in-with-oauth). |
| 20 | 20 | | **Scorecards** | A few rules every project should meet, each failing one a click away from an issue an agent fixes | | |
| 21 | 21 | ||
| 22 | 22 | Every g1t agent run starts with a **Context** section drawn from the hub, | |
| 23 | − | and your own agents can ask it through the [MCP tools](#mcp-tools) | |
| 24 | − | `search_context` and `get_entity`. | |
| 23 | + | and your own agents can ask it through the [MCP tools](#mcp-tools): | |
| 24 | + | the `search` tool's `context` and `entity` actions. | |
| 25 | 25 | ||
| 26 | 26 | ## The catalog | |
| 27 | 27 | ||
| 68 | 68 | ## Memory that fills itself | |
| 69 | 69 | ||
| 70 | 70 | [Memory](/guides/agents-and-memory/#memory) is what agents are told about a | |
| 71 | − | project and its workspace. Besides what agents save with `remember` and | |
| 72 | − | what people add by hand, the hub captures it from four places: | |
| 71 | + | project and its workspace. Besides what agents save with the `memory` tool's | |
| 72 | + | `remember` action and what people add by hand, the hub captures it from four places: | |
| 73 | 73 | ||
| 74 | 74 | | Source | What it captures | Kind | | |
| 75 | 75 | | --- | --- | --- | | |
| 206 | 206 | ||
| 207 | 207 | ## MCP tools | |
| 208 | 208 | ||
| 209 | − | | Tool | Takes | Does | | |
| 209 | + | Both are actions of the [`search` tool](/reference/mcp/#search), and need | |
| 210 | + | the `memory:read` scope. | |
| 211 | + | ||
| 212 | + | | `search` action | Takes | Does | | |
| 210 | 213 | | --- | --- | --- | | |
| 211 | − | | `search_context` | `query`, and `workspace` or `repo`; optional `project`, `kinds`, `limit` | One search across the catalog, docs, issues, pull requests and memory, as [Search](#search) | | |
| 212 | − | | `get_entity` | `kind`, `id`, and `workspace` or `repo` | One catalog entry by its id or key (a project's slug, `npm:<name>`, a username), with every relation | | |
| 214 | + | | `context` | `query`, and `workspace` or `repo`; optional `project`, `kinds`, `limit` | One search across the catalog, docs, issues, pull requests and memory, as [Search](#search) | | |
| 215 | + | | `entity` | `kind`, `id`, and `workspace` or `repo` | One catalog entry by its id or key (a project's slug, `npm:<name>`, a username), with every relation | | |
| 213 | 216 | ||
| 214 | 217 | g1t's own agents have both. Over REST: | |
| 215 | 218 |
| 18 | 18 | To hand over a whole outcome rather than one issue at a time, have an agent | |
| 19 | 19 | plan it first: see [hand off an outcome](/guides/outcomes/). | |
| 20 | 20 | ||
| 21 | + | ## Put an agent on it in one step | |
| 22 | + | ||
| 23 | + | When the work is not written down yet, open the issue and hand it to the | |
| 24 | + | agent at once: | |
| 25 | + | ||
| 26 | + | 1. On Mission control, choose **Put an agent on it**. | |
| 27 | + | 2. Pick the project, give a title, and say what you want done in plain | |
| 28 | + | words. Add acceptance checks, one command per line, if you know them. | |
| 29 | + | 3. Choose **Put an agent on it**. | |
| 30 | + | ||
| 31 | + | You land on the new issue with the agent already at work on its pull | |
| 32 | + | request. The same choice is on a project's **New issue** page, as **Assign | |
| 33 | + | g1t-agent now**, and in the ⌘K palette as **Put an agent on …** followed by | |
| 34 | + | a project's name. | |
| 35 | + | ||
| 36 | + | Putting an agent to work needs the Write role on the project. Without it, | |
| 37 | + | nothing is opened. With it, the issue is always opened, even when the | |
| 38 | + | agent cannot start: | |
| 39 | + | ||
| 40 | + | | What happened | What you see | | |
| 41 | + | | --- | --- | | |
| 42 | + | | The agent started | The issue, with its draft pull request under **Assignees**. | | |
| 43 | + | | Every agent slot of the workspace is busy | The issue, queued for g1t-agent. It starts by itself when a slot frees up. | | |
| 44 | + | | The workspace's plan or limits refused it | The issue is opened, and the composer says why and links to the fix: start the plan or the trial (`not_paid`, `trial_used`), raise the monthly limit (`limit`) or the cap per issue (`issue_cap`), or connect a model (`no_model`). A workspace g1t `paused` says to contact support. | | |
| 45 | + | ||
| 46 | + | From the API or an agent of your own, it is one call: | |
| 47 | + | ||
| 48 | + | ```sh | |
| 49 | + | curl -X POST https://api.g1t.sh/repos/<workspace>/<repo>/issues/delegate \ | |
| 50 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 51 | + | -H "Content-Type: application/json" \ | |
| 52 | + | -d '{"title": "Retry webhooks with exponential backoff", "body": "Deliveries that fail are dropped today. Retry them up to six times.", "checks": ["npm test"]}' | |
| 53 | + | ``` | |
| 54 | + | ||
| 55 | + | The answer holds the `issue`, the `pull` request the agent opened (or | |
| 56 | + | `null`), and `agent`: its `status` (`started`, `queued` or `not_started`), | |
| 57 | + | and when it did not start, a `code`, a `message` and a `fix_url`. On the MCP | |
| 58 | + | server it is the `agent` tool's `delegate` action. See | |
| 59 | + | [put an agent on it](/reference/api/issues/delegate/). | |
| 60 | + | ||
| 21 | 61 | ## Assigning agents | |
| 22 | 62 | ||
| 23 | 63 | One issue: | |
| 40 | 80 | -H "Authorization: Bearer $G1T_TOKEN" | |
| 41 | 81 | ``` | |
| 42 | 82 | ||
| 43 | − | The same thing is the `assign_issue` tool on the MCP server, so an agent | |
| 83 | + | The same thing is the `agent` tool's `assign` action on the MCP server, so an agent | |
| 44 | 84 | planning work can hand issues to g1t agents itself. | |
| 45 | 85 | ||
| 46 | 86 | In a comment: write `@g1t-agent take this` on the issue. See | |
| 174 | 214 | | Review by a second agent | On | Off leaves review to people. | | |
| 175 | 215 | | Revisions before asking you | 2 | How often an agent is sent back before g1t stops. | | |
| 176 | 216 | | Merge automatically when ready | Off | Lands a g1t agent's pull request once every rule is met. | | |
| 217 | + | | Ask a person before merging low-confidence changes | On | A g1t agent's change [rated low](#how-sure-the-agent-is) waits for a person's approval instead of merging by itself or joining the queue. | | |
| 177 | 218 | | 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 [merge queue](/guides/merge-queue/). | | |
| 178 | 219 | ||
| 179 | 220 | A g1t agent's pull request follows the same rules as anyone's. If the | |
| 206 | 247 | agent opened is yours to drive; the same checks run on it, and you can ask | |
| 207 | 248 | for a review or a catch-up from its page. | |
| 208 | 249 | ||
| 250 | + | ### How sure the agent is | |
| 251 | + | ||
| 252 | + | Once a g1t agent has finished a change, g1t records how sure it is that the | |
| 253 | + | change is right: **high**, **medium** or **low**, with a few words saying | |
| 254 | + | why, such as "Low — tests not added, 3 revisions". It shows on the pull | |
| 255 | + | request, under the agent, and on Mission control. It is worked out again as | |
| 256 | + | the change moves through checks, review and revision, and kept with each | |
| 257 | + | run, so a run's page says how the change stood when that run left it. | |
| 258 | + | ||
| 259 | + | Confidence comes from what g1t can observe, not from how the agent sounds. | |
| 260 | + | Each signal below that tells against the change adds points, or makes it | |
| 261 | + | low on its own. No points is high, one or two is medium, and three or more | |
| 262 | + | is low. | |
| 263 | + | ||
| 264 | + | | Signal | Effect | | |
| 265 | + | | --- | --- | | |
| 266 | + | | The acceptance checks fail, or could not run | Low | | |
| 267 | + | | The reviewer agent asks for changes | Low | | |
| 268 | + | | A run was stopped at its cost or time cap | Low | | |
| 269 | + | | Sent back to revise | 1 point per revision, at most 3 | | |
| 270 | + | | The checks have not finished, or passed only on a retry | 1 point | | |
| 271 | + | | The issue has no acceptance checks | 1 point | | |
| 272 | + | | No review yet, or the repository has no reviewer agent | 1 point | | |
| 273 | + | | The reviewer approved but left three or more comments on lines | 1 point | | |
| 274 | + | | Code changed and no test was added or changed | 1 point | | |
| 275 | + | | More than 400 lines changed; more than 1,000 | 1 point; 2 points | | |
| 276 | + | | More than 30 files changed | 1 point | | |
| 277 | + | | Files changed outside the area its [plan](/guides/outcomes/) expected; four or more | 1 point; 2 points | | |
| 278 | + | | Touches CI workflows, repository automation, secrets, infrastructure or `CODEOWNERS` | 2 points | | |
| 279 | + | | Its latest run used 80% or more of its cost or time cap | 1 point each | | |
| 280 | + | | Steps refused by [guardrails](/guides/guardrails/); three or more | 1 point; 2 points | | |
| 281 | + | | A question or handoff it sent another agent is unanswered | 2 points | | |
| 282 | + | | The agent said it was unsure about something | 1 point | | |
| 283 | + | ||
| 284 | + | At the end of every run that makes or revises a change, the agent is also | |
| 285 | + | asked how sure it is, and what it could not verify. g1t takes the lower of | |
| 286 | + | the two: what it observes can lower the agent's own word, never raise it. | |
| 287 | + | When the agent's word is lower, the reasons start with "agent says low", | |
| 288 | + | and the pull request lists what it was unsure about. | |
| 289 | + | ||
| 290 | + | For high confidence, the reasons say what it rests on: checks pass, | |
| 291 | + | approved on the first review, tests added, a small change. | |
| 292 | + | ||
| 293 | + | The pull request's `confidence` in the | |
| 294 | + | [API](/reference/api/pull-requests/get-pull-request/) has the `level`, | |
| 295 | + | `reasons`, `self_reported`, `uncertain_about`, the `run_id` it was worked out | |
| 296 | + | after, and `assessed_at`. [Webhooks](/guides/webhooks/) for pull requests | |
| 297 | + | carry it too. | |
| 298 | + | ||
| 299 | + | ### Low-confidence changes wait for a person | |
| 300 | + | ||
| 301 | + | With **Ask a person before merging low-confidence changes** on, which it is | |
| 302 | + | unless someone turns it off, a g1t agent's change rated low is not merged | |
| 303 | + | by itself and does not join the merge queue, even with **Merge | |
| 304 | + | automatically when ready** on. Once everything else the repository asks | |
| 305 | + | for is met, it stops at **Needs you**, saying why, and Mission control | |
| 306 | + | lists it under **Needs you** with a **Low confidence** chip, the reasons in | |
| 307 | + | **What the agent already knows**, and the reasons again in **Why this | |
| 308 | + | needs you**. | |
| 309 | + | ||
| 310 | + | To let it land, approve it: a person's approval since the agent last | |
| 311 | + | revised lifts the hold, and it merges as the repository's rules say. To | |
| 312 | + | send it back, request changes. Merging it yourself works as usual. The | |
| 313 | + | setting is under **Settings → Branches and merging**, in **g1t agents**, | |
| 314 | + | and is `hold_low_confidence` in | |
| 315 | + | [`update_repo_settings`](/reference/api/repositories/update-repo-settings/). | |
| 316 | + | ||
| 209 | 317 | ## Choosing between pull requests | |
| 210 | 318 | ||
| 211 | 319 | Each pull request on the issue's page shows whether its checks passed. Open |
| 149 | 149 | So a brief of "Accomplish TECH-1234" works: the planner reads the ticket. | |
| 150 | 150 | ||
| 151 | 151 | Agents, and your own agent through MCP, can also look a reference up with | |
| 152 | − | the `get_context` tool: | |
| 152 | + | the `search` tool's `ticket` action: | |
| 153 | 153 | ||
| 154 | 154 | ```sh | |
| 155 | 155 | curl "https://api.g1t.sh/repos/acme/web/context?reference=TECH-1234" \ | |
| 204 | 204 | Owners can manage integrations through the API and MCP, with a person's | |
| 205 | 205 | token (a workspace token or an agent cannot): | |
| 206 | 206 | ||
| 207 | − | | Tool | Route | | |
| 207 | + | | MCP tool and action | Route | | |
| 208 | 208 | | --- | --- | | |
| 209 | − | | `list_integrations` | `GET /workspaces/{workspace}/integrations` | | |
| 210 | − | | `connect_integration` | `POST /workspaces/{workspace}/integrations` | | |
| 211 | − | | `test_integration` | `POST /workspaces/{workspace}/integrations/{id}/test` | | |
| 212 | − | | `disconnect_integration` | `DELETE /workspaces/{workspace}/integrations/{id}` | | |
| 213 | − | | `get_context` | `GET /repos/{owner}/{name}/context?reference=` | | |
| 214 | − | | `import_issue` | `POST /repos/{owner}/{name}/issues/import` | | |
| 209 | + | | `workspace` `list_integrations` | `GET /workspaces/{workspace}/integrations` | | |
| 210 | + | | `workspace` `connect_integration` | `POST /workspaces/{workspace}/integrations` | | |
| 211 | + | | `workspace` `test_integration` | `POST /workspaces/{workspace}/integrations/{id}/test` | | |
| 212 | + | | `workspace` `disconnect_integration` | `DELETE /workspaces/{workspace}/integrations/{id}` | | |
| 213 | + | | `search` `ticket` | `GET /repos/{owner}/{name}/context?reference=` | | |
| 214 | + | | `issue` `import` | `POST /repos/{owner}/{name}/issues/import` | | |
| 215 | 215 | ||
| 216 | 216 | ```sh | |
| 217 | 217 | curl -X POST https://api.g1t.sh/workspaces/acme/integrations \ |
| 34 | 34 | ## What merging does with the queue on | |
| 35 | 35 | ||
| 36 | 36 | Merging a pull request, from its page (**Add to the merge queue**), with | |
| 37 | − | `merge_pull_request`, or with `POST /repos/{owner}/{name}/pulls/{number}/merge`, | |
| 37 | + | the `pull_request` tool's `merge` action, or with | |
| 38 | + | `POST /repos/{owner}/{name}/pulls/{number}/merge`, | |
| 38 | 39 | adds it to the queue instead of changing `main`. Everything a merge needs | |
| 39 | 40 | is still checked first: the pull request must be ready for review, its | |
| 40 | 41 | checks must have passed and it must have the approvals the repository asks | |
| 161 | 162 | ||
| 162 | 163 | ## From the API or an agent | |
| 163 | 164 | ||
| 164 | − | `get_merge_queue`, or `GET /repos/{owner}/{name}/queue`, returns the queue. | |
| 165 | + | The `pull_request` tool's `merge_queue` action, or | |
| 166 | + | `GET /repos/{owner}/{name}/queue`, returns the queue. | |
| 165 | 167 | It is public for a public repository. | |
| 166 | 168 | ||
| 167 | 169 | ```sh |
| 129 | 129 | ||
| 130 | 130 | ## From the API or an agent | |
| 131 | 131 | ||
| 132 | − | The same flow is three operations. `plan_work` and `apply_plan` need the | |
| 133 | − | Write role or higher; `get_plan` needs Read. | |
| 132 | + | The same flow is three operations, the actions of the MCP `plan` tool. | |
| 133 | + | `create` and `apply` need the Write role or higher; `get` needs Read. | |
| 134 | 134 | ||
| 135 | − | | Tool | Route | | | |
| 135 | + | | `plan` action | Route | | | |
| 136 | 136 | | --- | --- | --- | | |
| 137 | − | | `plan_work` | `POST /repos/{owner}/{name}/plans` | Start a plan. Body: `brief`. Returns `plan_id` at once. | | |
| 138 | − | | `get_plan` | `GET /repos/{owner}/{name}/plans/{plan}` | The plan, its `status` and the issues it proposes. | | |
| 139 | − | | `apply_plan` | `POST /repos/{owner}/{name}/plans/{plan}/apply` | Open its issues. Body: `assign`, `keep`. | | |
| 137 | + | | `create` | `POST /repos/{owner}/{name}/plans` | Start a plan. Body: `brief`. Returns `plan_id` at once. | | |
| 138 | + | | `get` | `GET /repos/{owner}/{name}/plans/{plan}` | The plan, its `status` and the issues it proposes. | | |
| 139 | + | | `apply` | `POST /repos/{owner}/{name}/plans/{plan}/apply` | Open its issues. Body: `assign`, `keep`. | | |
| 140 | 140 | ||
| 141 | 141 | ```sh | |
| 142 | 142 | # 1. Start a plan. | |
| 162 | 162 | `number`. `keep` takes positions counting from 1; leave it out to open | |
| 163 | 163 | every issue. | |
| 164 | 164 | ||
| 165 | − | Once a plan is applied, `get_plan` also returns: | |
| 165 | + | Once a plan is applied, `get` also returns: | |
| 166 | 166 | ||
| 167 | 167 | | Field | | | |
| 168 | 168 | | --- | --- | |
| 157 | 157 | | `conflicts` | When conflicting, the files that conflict. | | |
| 158 | 158 | | `behind` | Whether its target has moved on without it. | | |
| 159 | 159 | ||
| 160 | − | An agent sees the same through the `get_pull_request` tool. | |
| 160 | + | An agent sees the same through the `pull_request` tool's `get` action. |
| 156 | 156 | ## From the API and agents | |
| 157 | 157 | ||
| 158 | 158 | The same search is `GET /search` in the API and the `search` tool over | |
| 159 | − | MCP. It takes `q` (the query), `type` and `page`, and needs no token for | |
| 159 | + | MCP, whose default action is `code`. It takes `q` (the query), `type` and `page`, and needs no token for | |
| 160 | 160 | public results: | |
| 161 | 161 | ||
| 162 | 162 | ```sh | |
| 169 | 169 | [MCP tools](/reference/mcp/#search). | |
| 170 | 170 | ||
| 171 | 171 | `search` looks across all of g1t. To ask about one workspace's catalog, | |
| 172 | − | docs and memory, use the [context hub](/guides/context-hub/) and its | |
| 173 | − | `search_context` tool. | |
| 172 | + | docs and memory, use the [context hub](/guides/context-hub/) and the | |
| 173 | + | `search` tool's `context` action. |
| 41 | 41 | a draft or open. A | |
| 42 | 42 | message is up to 4,000 characters. | |
| 43 | 43 | ||
| 44 | − | From the API or your own agent, use `message_agent` or | |
| 45 | − | `POST /repos/{owner}/{name}/pulls/{number}/messages`: | |
| 44 | + | From the API or your own agent, use the `agent` tool's `message` action, | |
| 45 | + | or `POST /repos/{owner}/{name}/pulls/{number}/messages`: | |
| 46 | 46 | ||
| 47 | 47 | ```sh | |
| 48 | 48 | curl -X POST https://api.g1t.sh/repos/acme/web/pulls/44/messages \ | |
| 71 | 71 | **Revisions before asking you** in the repository's settings; past that, | |
| 72 | 72 | g1t stops and the pull request says **Needs you**. | |
| 73 | 73 | ||
| 74 | − | From the API, give the verdict with `review_pull_request`, or | |
| 75 | − | `POST /repos/{owner}/{name}/pulls/{number}/reviews` with | |
| 76 | − | `"verdict": "request_changes"` and a `body`. Comments on lines are | |
| 77 | − | `add_comment` with `path` and `line`. | |
| 74 | + | From the API, give the verdict with the `pull_request` tool's `review` | |
| 75 | + | action, or `POST /repos/{owner}/{name}/pulls/{number}/reviews`, with | |
| 76 | + | `"verdict": "request_changes"` and a `body`. Comments on lines are the | |
| 77 | + | `issue` tool's `comment` action with `path` and `line`. | |
| 78 | 78 | ||
| 79 | 79 | ### People outrank an agent's review | |
| 80 | 80 | ||
| 93 | 93 | ||
| 94 | 94 | | An agent wants to | It uses | | |
| 95 | 95 | | --- | --- | | |
| 96 | − | | Ask the agent on another pull request something | `message_agent` with `kind: "question"` | | |
| 97 | − | | Hand over work that belongs in another pull request | `message_agent` with `kind: "handoff"` | | |
| 98 | − | | Answer a question, or take on or decline a handoff | `answer_message` with the message's `id`, and `decline: true` to decline | | |
| 99 | − | | Report work outside its task | `create_issue`, naming the pull request it is working on | | |
| 100 | − | | Warn another pull request's author, such as of a coming conflict | `add_comment` on that pull request | | |
| 96 | + | | Ask the agent on another pull request something | `agent` `message` with `kind: "question"` | | |
| 97 | + | | Hand over work that belongs in another pull request | `agent` `message` with `kind: "handoff"` | | |
| 98 | + | | Answer a question, or take on or decline a handoff | `agent` `answer` with the message's `id`, and `decline: true` to decline | | |
| 99 | + | | Report work outside its task | `issue` `create`, naming the pull request it is working on | | |
| 100 | + | | Warn another pull request's author, such as of a coming conflict | `issue` `comment` on that pull request | | |
| 101 | 101 | ||
| 102 | 102 | How an exchange goes: | |
| 103 | 103 | ||
| 104 | − | 1. The asking agent calls `message_agent` on the other pull request, with | |
| 104 | + | 1. The asking agent calls `agent` `message` on the other pull request, with | |
| 105 | 105 | `kind` and its own pull request as `from_number`, and keeps working. | |
| 106 | 106 | 2. The agent asked receives it at its next step, with the message's id and | |
| 107 | 107 | how to reply. It is recorded in that agent's session as "Question from | |
| 108 | 108 | the agent on #41" or "Work handed over by the agent on #41". | |
| 109 | − | 3. It replies with `answer_message`. The reply reaches the asking agent at | |
| 109 | + | 3. It replies with `agent` `answer`. The reply reaches the asking agent at | |
| 110 | 110 | its next step in turn, recorded in its session as "Answer from the agent | |
| 111 | 111 | on #44". | |
| 112 | 112 | ||
| 117 | 117 | If the agent asked is not at work, because its change is done and waiting | |
| 118 | 118 | for review or a merge, g1t wakes it to answer. It starts a short run in that | |
| 119 | 119 | pull request's sandbox with the agent's own change in front of it and what | |
| 120 | − | it was asked; the agent reads its code, answers with `answer_message`, and, | |
| 120 | + | it was asked; the agent reads its code, answers with `agent` `answer`, and, | |
| 121 | 121 | for a handoff it takes on, commits the work. Its pull request is noted "g1t | |
| 122 | 122 | woke g1t-agent to answer the agent on #41", and nothing else starts on it | |
| 123 | 123 | until everything it was asked is answered, or 20 minutes pass. The response | |
| 124 | − | to `message_agent` says so in `hint`, and points the asking agent at the | |
| 125 | − | other pull request's change to read meanwhile with `get_pull_request` and | |
| 126 | − | `get_pull_request_changes`. | |
| 124 | + | to `agent` `message` says so in `hint`, and points the asking agent at the | |
| 125 | + | other pull request's change to read meanwhile with `pull_request` `get` | |
| 126 | + | and `changes`. | |
| 127 | 127 | ||
| 128 | 128 | An agent g1t has stopped on (its pull request needs a person) is not woken; | |
| 129 | 129 | the hint then says it will not answer soon. | |
| 149 | 149 | ||
| 150 | 150 | A g1t agent picks up messages between its steps. An agent you run yourself | |
| 151 | 151 | is not reached this way: steer it in your own client. It can still send | |
| 152 | − | messages to a g1t agent's pull request with `message_agent`, as above, and | |
| 153 | − | comment on any pull request with `add_comment`. See | |
| 152 | + | messages to a g1t agent's pull request with `agent` `message`, as above, | |
| 153 | + | and comment on any pull request with `issue` `comment`. See | |
| 154 | 154 | [connect an agent](/guides/bring-your-own-agent/). |
| 77 | 77 | | `repo.deleted`, `repo.restored`, `repo.purged` | It was deleted, restored within its 30 days, or removed for good. | | |
| 78 | 78 | | `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number`; on close, `data.reason` and `data.resolved_by`. | | |
| 79 | 79 | | `comment.created` | A comment or review on an issue or pull request. | | |
| 80 | − | | `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. `data.number`, `data.issue`; on merge, `data.commit`. | | |
| 80 | + | | `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. `data.number`, `data.issue`; on merge, `data.commit`. On a g1t agent's change, once g1t has worked it out, `data.confidence`: `level` (`high`, `medium` or `low`), `reasons`, `self_reported`, `uncertain_about`, `run_id` and `assessed_at`. See [how sure the agent is](/guides/g1t-agents/#how-sure-the-agent-is). | | |
| 81 | 81 | | `checks.completed` | An issue's acceptance checks finished on a pull request. `data.status` is `passed`, `failed` or `errored`. | | |
| 82 | 82 | | `review.completed` | A g1t agent reviewed a pull request. `data.verdict`. | | |
| 83 | 83 | | `workflow.completed` | A GitHub Actions run finished. `data.workflow`, `data.conclusion`, `data.run_id`, `data.sha`, `data.pull`. | |
| 25 | 25 | it was recorded. That link is what lets g1t show the reasoning behind a | |
| 26 | 26 | commit rather than only the commit. | |
| 27 | 27 | ||
| 28 | − | Read a session on the pull request's **Session** tab, with `read_session`, | |
| 28 | + | Read a session on the pull request's **Session** tab, with the | |
| 29 | + | `pull_request` tool's `read_session` action, | |
| 29 | 30 | or with `GET /repos/{owner}/{name}/pulls/{number}/session?after=`. A session | |
| 30 | 31 | is as visible as the repository, so do not put secrets in one. | |
| 31 | 32 | ||
| 56 | 57 | See [recording sessions automatically](/guides/bring-your-own-agent/#recording-sessions-automatically) | |
| 57 | 58 | for what it installs and how to remove it. | |
| 58 | 59 | ||
| 59 | − | **With `record_session`**, from any agent. It takes a list of entries, each | |
| 60 | + | **With the `pull_request` tool's `record_session` action**, from any agent. It takes a list of entries, each | |
| 60 | 61 | with a `kind` from the table above and `text`, and `tool` for tool entries. | |
| 61 | 62 | The same is `POST /repos/{owner}/{name}/pulls/{number}/session`, with up to | |
| 62 | 63 | 200 entries per request: |
| 29 | 29 | -d '{"slug": "acme", "name": "Acme"}' | |
| 30 | 30 | ``` | |
| 31 | 31 | ||
| 32 | − | You can belong to up to ten workspaces. `GET /user`, or `whoami`, lists the | |
| 32 | + | You can belong to up to ten workspaces. `GET /user`, or the `account` tool's `whoami` action, lists the | |
| 33 | 33 | ones you belong to. | |
| 34 | 34 | ||
| 35 | 35 | Usernames and workspaces share one set of names, so a name means the same | |
| 334 | 334 | | Belongs to | You | The workspace | | |
| 335 | 335 | | Acts as | You | The workspace: its name is the author of what it does | | |
| 336 | 336 | | Can reach | Every workspace you belong to | That workspace only | | |
| 337 | − | | Can do | Everything you can | Admin on the workspace's repositories; it cannot manage people, tokens or workspaces | | |
| 337 | + | | Can do | What its [scopes](/guides/authentication/#scopes) allow, never more than you can | What its scopes allow, on the workspace's repositories; it cannot manage people, tokens or workspaces | | |
| 338 | + | | Expires | 7, 30 or 90 days (the default), 1 year, or never | The same choices | | |
| 338 | 339 | | When its creator leaves | Stops working | Keeps working | | |
| 339 | 340 | | Created by | You, in [Settings → Access tokens](https://g1t.sh/settings/tokens) | An owner, under the workspace's **Settings → Access tokens** | | |
| 340 | 341 | ||
| 343 | 344 | username works; the token is the password. `GET /user` answers with | |
| 344 | 345 | `"kind": "workspace"` for one, and `"kind": "user"` for a personal token. | |
| 345 | 346 | ||
| 346 | − | Every member can see a workspace's tokens: the name, who created each and | |
| 347 | − | when it was last used. Only owners can create or delete them. | |
| 347 | + | Every member can see a workspace's tokens: the name, who created each, | |
| 348 | + | when it was last used and when it expires. Only owners can create or | |
| 349 | + | delete them. An owner creates one with a name, an expiry (No expiry shows | |
| 350 | + | a warning) and the same scope checklist as a personal token, starting on | |
| 351 | + | the CI preset. | |
| 348 | 352 | ||
| 349 | 353 | ## Profiles | |
| 350 | 354 |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | The REST API lives at `https://api.g1t.sh`. It exposes the same operations | |
| 7 | − | as the [MCP server](/reference/mcp/): every endpoint names the MCP tool that | |
| 8 | − | does the same thing, with the same inputs. | |
| 7 | + | as the [MCP server](/reference/mcp/): every endpoint names the MCP tool and | |
| 8 | + | action that do the same thing, with the same inputs. | |
| 9 | 9 | ||
| 10 | 10 | This page covers what every endpoint shares. The pages under each resource | |
| 11 | 11 | in the sidebar document one endpoint each: its parameters, an example | |
| 55 | 55 | as the workspace. The token a g1t agent works with can use only the | |
| 56 | 56 | operations its task needs, in its own repository. | |
| 57 | 57 | ||
| 58 | + | ### Scopes | |
| 59 | + | ||
| 60 | + | Each endpoint needs one [scope](/guides/authentication/#scopes), such as | |
| 61 | + | `issues:read` to read an issue or `issues:write` to open one. Its page | |
| 62 | + | says which, and the [OpenAPI document](https://api.g1t.sh/openapi.json) | |
| 63 | + | gives it as `x-scope` on each operation, beside `x-mcp-tool` and | |
| 64 | + | `x-mcp-action`, the MCP tool and action that do the same: | |
| 65 | + | ||
| 66 | + | ```json | |
| 67 | + | { | |
| 68 | + | "operationId": "get_issue", | |
| 69 | + | "x-operation": "get_issue", | |
| 70 | + | "x-mcp-tool": "issue", | |
| 71 | + | "x-mcp-action": "get", | |
| 72 | + | "x-scope": "issues:read" | |
| 73 | + | } | |
| 74 | + | ``` | |
| 75 | + | ||
| 76 | + | `x-scope` is `null` for `GET /user`, which any token may use. A token | |
| 77 | + | needs the scope, and whoever it acts as needs a role that allows the call. | |
| 78 | + | A token reaches every workspace and repository whoever it acts as can. | |
| 79 | + | ||
| 58 | 80 | ## Signing in from a tool | |
| 59 | 81 | ||
| 60 | 82 | A tool gets a token by having a person approve a short code in their | |
| 116 | 138 | | --- | --- | --- | | |
| 117 | 139 | | 401 | `unauthenticated` | A token is required, or the one sent is not valid. | | |
| 118 | 140 | | 402 | `payment_required` | The workspace cannot start this work: it needs the g1t plan or a card check, or it is at a limit. Only endpoints that start an agent answer this. See [usage and billing](/guides/usage-and-billing/#when-work-is-stopped). | | |
| 119 | − | | 403 | `forbidden` | You are signed in but not allowed to do this. | | |
| 141 | + | | 403 | `forbidden` | You are signed in but not allowed to do this: your role is not enough, or the token lacks a scope, which `needed_scope` names. | | |
| 120 | 142 | | 404 | `not_found` | It does not exist, or you cannot see it. A path that is not an endpoint answers this too. | | |
| 121 | 143 | | 409 | `conflict` | The request conflicts with the current state. | | |
| 122 | 144 | | 422 | `invalid` | The input is not valid. | | |
| 124 | 146 | Branch on `code`, not on `message`: messages are written for people and | |
| 125 | 147 | may change. | |
| 126 | 148 | ||
| 149 | + | When an access token lacks the scope a call needs, the `403` also names | |
| 150 | + | that scope in `needed_scope`: | |
| 151 | + | ||
| 152 | + | ```json | |
| 153 | + | { | |
| 154 | + | "error": { | |
| 155 | + | "code": "forbidden", | |
| 156 | + | "message": "This access token needs the issues:write scope to use create_issue.", | |
| 157 | + | "needed_scope": "issues:write" | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ``` | |
| 161 | + | ||
| 162 | + | Give the token that scope in | |
| 163 | + | [Settings → Access tokens](https://g1t.sh/settings/tokens), or use another | |
| 164 | + | token. A `403` for any other reason has no `needed_scope`. | |
| 165 | + | ||
| 127 | 166 | ## Lists | |
| 128 | 167 | ||
| 129 | 168 | Lists come newest first, unless an endpoint says otherwise. Most return |
| 1 | 1 | --- | |
| 2 | 2 | title: MCP tools | |
| 3 | − | description: Every tool the g1t MCP server exposes, with its required inputs and the matching REST route. | |
| 3 | + | description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | The MCP server at `https://mcp.g1t.sh` exposes the tools below. Each is the | |
| 7 | − | same operation as a route of the [REST API](/reference/api/), so the two | |
| 8 | − | always agree. To connect a client, see | |
| 9 | − | [connect an agent](/guides/bring-your-own-agent/). | |
| 6 | + | The MCP server at `https://mcp.g1t.sh` exposes 13 tools, one per kind of | |
| 7 | + | thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`, | |
| 8 | + | `plan`, `memory`, `workflow`, `secret`, `webhook`, `access`, `workspace` | |
| 9 | + | and `account`. Each tool takes an `action` that says what to do. Every | |
| 10 | + | action is the same operation as a route of the [REST API](/reference/api/), | |
| 11 | + | with the same inputs, permissions and results, so the two always agree. | |
| 12 | + | ||
| 13 | + | ## Connect | |
| 14 | + | ||
| 15 | + | To connect Claude Code, Codex, OpenCode, Cursor or another client, see | |
| 16 | + | [connect an agent](/guides/bring-your-own-agent/). With Claude Code: | |
| 17 | + | ||
| 18 | + | ```sh | |
| 19 | + | claude mcp add --transport http g1t https://mcp.g1t.sh | |
| 20 | + | ``` | |
| 21 | + | ||
| 22 | + | The server speaks MCP over streamable HTTP, and answers every request with | |
| 23 | + | JSON. Every call needs to be signed in, in one of two ways: | |
| 24 | + | ||
| 25 | + | - **OAuth.** A client that supports MCP authorization needs only the URL. | |
| 26 | + | An unauthenticated request is answered with `401` and a pointer to | |
| 27 | + | `https://mcp.g1t.sh/.well-known/oauth-protected-resource`; the client | |
| 28 | + | registers itself and sends you to your browser to approve it. See | |
| 29 | + | [signing in with OAuth](/guides/authentication/#signing-in-with-oauth). | |
| 30 | + | - **An access token.** Send `Authorization: Bearer g1t_…` with an | |
| 31 | + | [access token](/guides/authentication/#access-tokens). | |
| 32 | + | ||
| 33 | + | Opening [mcp.g1t.sh](https://mcp.g1t.sh) in a browser shows the server's | |
| 34 | + | card: what it is, how to connect, and every tool with its actions, the | |
| 35 | + | operation and scope of each, and its input schema. | |
| 36 | + | ||
| 37 | + | ## How tools and actions work | |
| 38 | + | ||
| 39 | + | Call a tool with `tools/call`, its name, and `arguments` that hold the | |
| 40 | + | `action` and that action's inputs: | |
| 41 | + | ||
| 42 | + | ```json | |
| 43 | + | { | |
| 44 | + | "jsonrpc": "2.0", | |
| 45 | + | "id": 1, | |
| 46 | + | "method": "tools/call", | |
| 47 | + | "params": { | |
| 48 | + | "name": "issue", | |
| 49 | + | "arguments": { "action": "get", "repo": "flagon-io/hello", "number": 42 } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ``` | |
| 53 | + | ||
| 54 | + | - `action` is required, except on two tools that have a default: | |
| 55 | + | `search` runs `code`, and `account` runs `whoami`, when it is left out. | |
| 56 | + | - The input schema that `tools/list` returns is one flat object: `action`, | |
| 57 | + | then every field any of the tool's actions takes. The `action` field's | |
| 58 | + | description lists each action with the fields it needs, such as | |
| 59 | + | `get (repo, number): One issue with comments, checks and its pull requests.` | |
| 60 | + | - The server card at `https://mcp.g1t.sh` has each tool's schema keyed by | |
| 61 | + | action: a `oneOf` with one branch per action and its required fields. | |
| 62 | + | `tools/list` does not use `oneOf`, because many clients refuse a tool | |
| 63 | + | whose schema has one at its top level. | |
| 64 | + | - A call without one of its action's required fields is not run. It | |
| 65 | + | returns an error result naming them, such as `issue.get needs number.` | |
| 66 | + | A call without an action on a tool that has no default, or with an | |
| 67 | + | action the tool does not have, returns an error result that lists the | |
| 68 | + | tool's actions. | |
| 69 | + | - A tool name the server does not know is a JSON-RPC error, `-32602`. | |
| 70 | + | ||
| 71 | + | ### Results | |
| 72 | + | ||
| 73 | + | A result is the operation's answer as JSON text, with `snake_case` fields, | |
| 74 | + | as the REST API returns it: | |
| 75 | + | ||
| 76 | + | ```json | |
| 77 | + | { | |
| 78 | + | "jsonrpc": "2.0", | |
| 79 | + | "id": 1, | |
| 80 | + | "result": { | |
| 81 | + | "content": [{ "type": "text", "text": "{\n \"number\": 42,\n \"title\": \"Retry failed webhook deliveries\",\n …\n}" }], | |
| 82 | + | "isError": false | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ``` | |
| 86 | + | ||
| 87 | + | An operation that fails returns its message as the result, with `isError` | |
| 88 | + | set to `true`, so the agent can read it and act on it. | |
| 89 | + | ||
| 90 | + | ### Examples | |
| 91 | + | ||
| 92 | + | Start a draft pull request for issue 42. The answer holds the git remote of | |
| 93 | + | the pull request's own fork to push to: | |
| 94 | + | ||
| 95 | + | ```json | |
| 96 | + | { | |
| 97 | + | "jsonrpc": "2.0", | |
| 98 | + | "id": 2, | |
| 99 | + | "method": "tools/call", | |
| 100 | + | "params": { | |
| 101 | + | "name": "pull_request", | |
| 102 | + | "arguments": { "action": "create", "repo": "flagon-io/hello", "issue": 42, "agent": "claude-code" } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ``` | |
| 106 | + | ||
| 107 | + | Search code across g1t, with the default action: | |
| 108 | + | ||
| 109 | + | ```json | |
| 110 | + | { | |
| 111 | + | "jsonrpc": "2.0", | |
| 112 | + | "id": 3, | |
| 113 | + | "method": "tools/call", | |
| 114 | + | "params": { | |
| 115 | + | "name": "search", | |
| 116 | + | "arguments": { "query": "parse_query language:rust repo:flagon-io/hello" } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ``` | |
| 120 | + | ||
| 121 | + | The same call with `curl` and an access token: | |
| 122 | + | ||
| 123 | + | ```sh | |
| 124 | + | curl https://mcp.g1t.sh \ | |
| 125 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 126 | + | -H "Content-Type: application/json" \ | |
| 127 | + | -d '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search", "arguments": {"query": "parse_query language:rust repo:flagon-io/hello"}}}' | |
| 128 | + | ``` | |
| 129 | + | ||
| 130 | + | ## What you see depends on your token | |
| 131 | + | ||
| 132 | + | Each action needs one [scope](/guides/authentication/#scopes), shown in the | |
| 133 | + | tables below; `whoami` needs none. `tools/list` shows a token only what its | |
| 134 | + | scopes allow: | |
| 135 | + | ||
| 136 | + | - The `action` field lists only the actions the token may use, and the | |
| 137 | + | schema has only their fields. | |
| 138 | + | - A tool with none of its actions allowed is left out. | |
| 139 | + | - A call to an action the token's scopes do not allow is refused with an | |
| 140 | + | error result such as | |
| 141 | + | `This access token needs the issues:write scope to use create_issue.` | |
| 142 | + | ||
| 143 | + | For example, a token with only `issues:write` sees `issue` (its `list` and | |
| 144 | + | `get` too, since `write` includes `read`), `plan` with `get` and `apply`, | |
| 145 | + | and `account` with `whoami`. A token with the | |
| 146 | + | [Read only preset](/guides/authentication/#presets) sees only the reading | |
| 147 | + | actions of each tool, and no `agent` tool at all. | |
| 10 | 148 | ||
| 149 | + | What a token may do is also bounded by the role of whoever it acts as: it | |
| 150 | + | reaches what they can reach, and no more. See | |
| 151 | + | [scopes](/guides/authentication/#scopes). | |
| 152 | + | ||
| 153 | + | A token or OAuth sign-in made before tokens had scopes, a token from | |
| 154 | + | signing in from a tool, and a token made with full access see every tool. | |
| 155 | + | ||
| 156 | + | ### Annotations | |
| 157 | + | ||
| 158 | + | Each listed tool carries MCP annotations, worked out from the actions the | |
| 159 | + | token can see. Clients use them to decide when to ask you before a call. | |
| 160 | + | ||
| 161 | + | | Annotation | Value | | |
| 162 | + | | --- | --- | | |
| 163 | + | | `title` | The tool's name for people, such as `Pull requests`. | | |
| 164 | + | | `readOnlyHint` | `true` when every action shown only reads. | | |
| 165 | + | | `destructiveHint` | `true` when the tool is not read-only and an action shown cannot be undone or reaches beyond g1t's own records: deleting a workspace, deleting, purging or transferring a repository, changing its visibility, removing an email address or a collaborator, disconnecting an integration, deleting a webhook, setting or deleting secrets and variables, replacing model routes, setting a workspace's base permission, and merging a pull request. | | |
| 166 | + | | `idempotentHint` | The same as `readOnlyHint`. | | |
| 167 | + | | `openWorldHint` | Always `false`. | | |
| 168 | + | ||
| 169 | + | So for a read-only token every tool is read-only, and for a token that can | |
| 170 | + | merge, `pull_request` is destructive. | |
| 171 | + | ||
| 172 | + | ## Earlier tool names | |
| 173 | + | ||
| 174 | + | Before resource tools, the server had one tool per operation, named after | |
| 175 | + | the operation: `get_issue`, `create_pull_request`, `record_session`, | |
| 176 | + | `mark_pull_request_ready`, `remember`, `recall` and so on. `tools/list` no | |
| 177 | + | longer lists them, but `tools/call` still answers them for a deprecation | |
| 178 | + | period, so clients set up with them keep working. Move to the resource | |
| 179 | + | tool and its action: the tables below give each, and each page of the | |
| 180 | + | [API reference](/reference/api/) names the tool and action for its | |
| 181 | + | operation. | |
| 182 | + | ||
| 183 | + | | Earlier name | Now | | |
| 184 | + | | --- | --- | | |
| 185 | + | | `get_issue` | `issue` with `"action": "get"` | | |
| 186 | + | | `create_pull_request` | `pull_request` with `"action": "create"` | | |
| 187 | + | | `record_session` | `pull_request` with `"action": "record_session"` | | |
| 188 | + | | `mark_pull_request_ready` | `pull_request` with `"action": "ready"` | | |
| 189 | + | | `get_pull_request` | `pull_request` with `"action": "get"` | | |
| 190 | + | | `recall`, `remember` | `memory` with `"action": "recall"` or `"remember"` | | |
| 191 | + | | `search` | `search`, with `"action": "code"` or none | | |
| 192 | + | | `search_context`, `get_entity`, `get_context` | `search` with `"action": "context"`, `"entity"` or `"ticket"` | | |
| 193 | + | | `assign_issue`, `delegate` | `agent` with `"action": "assign"` or `"delegate"` | | |
| 194 | + | | `whoami` | `account`, with `"action": "whoami"` or none | | |
| 195 | + | ||
| 11 | 196 | ## Conventions | |
| 12 | 197 | ||
| 13 | 198 | - `repo` is always `owner/name`, such as `"flagon-io/hello"`. | |
| 15 | 200 | repository, so a number names exactly one of them. | |
| 16 | 201 | - Inputs are `snake_case`. Results are JSON, with `snake_case` fields, as | |
| 17 | 202 | the REST API returns them. | |
| 18 | − | - A tool that fails returns its error as the result, with `isError` set, so | |
| 19 | − | the agent can read it and act on it. | |
| 20 | 203 | - Reading a public repository needs no sign-in through the API. Through MCP, | |
| 21 | 204 | every call needs to be signed in. | |
| 22 | 205 | ||
| 23 | − | Required inputs are listed in each table. Optional inputs are described in | |
| 24 | − | the tool's schema, which `tools/list` returns, and in the | |
| 25 | − | [API reference](/reference/api/). | |
| 206 | + | The tables below list each action's required inputs. Optional inputs are | |
| 207 | + | in the tool's schema, which `tools/list` returns, and on the action's page | |
| 208 | + | in the [API reference](/reference/api/), which each action links to. | |
| 26 | 209 | ||
| 27 | − | ## Account and workspaces | |
| 210 | + | ## `search` | |
| 28 | 211 | ||
| 29 | − | | Tool | Required | What it does | Route | | |
| 212 | + | Find things. `code`, the default, searches all of g1t you can see: | |
| 213 | + | repositories, code on default branches, issues, pull requests and people. | |
| 214 | + | `context` asks one workspace's context hub by meaning. See | |
| 215 | + | [search and Explore](/guides/search/) for the query syntax, and the | |
| 216 | + | [context hub](/guides/context-hub/). | |
| 217 | + | ||
| 218 | + | | Action | What it does | Required | Scope | | |
| 30 | 219 | | --- | --- | --- | --- | | |
| 31 | − | | `whoami` | | Who the access token acts as, and the workspaces it can work in. `kind` is `user`, `workspace` or `agent`. | [`GET /user`](/reference/api/accounts/whoami/) | | |
| 32 | − | | `create_workspace` | `slug` | Create a workspace. | [`POST /workspaces`](/reference/api/workspaces/create-workspace/) | | |
| 33 | − | | `list_emails` | | Your email addresses and email settings. People only. | [`GET /user/emails`](/reference/api/accounts/list-emails/) | | |
| 34 | − | | `add_email` | `email`, `password` | Add an address; g1t emails it a link to confirm it. | [`POST /user/emails`](/reference/api/accounts/add-email/) | | |
| 35 | − | | `remove_email` | `email`, `password` | Remove an address; never the primary or the last confirmed one. | [`DELETE /user/emails/{email}`](/reference/api/accounts/remove-email/) | | |
| 36 | − | | `update_email_settings` | | Change `primary` or `backup` (with `password`), `private_email` or `block_private_pushes`. See [email addresses](/guides/authentication/#email-addresses). | [`PATCH /user/email-settings`](/reference/api/accounts/update-email-settings/) | | |
| 37 | − | | `delete_workspace` | `workspace`, `confirm` | Delete an empty workspace whose billing is settled; `confirm` is its slug. Owners only. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | [`DELETE /workspaces/{workspace}`](/reference/api/workspaces/delete-workspace/) | | |
| 220 | + | | [`code`](/reference/api/search/search/) | Search all of g1t: repositories, code on default branches, issues, pull requests, people and workspaces. Public results for everyone; private ones in workspaces you belong to. `query` takes words, `"phrases"`, `-words` and qualifiers such as `repo:owner/name`, `org:`, `language:`, `path:`, `is:open`, `is:pr`, `author:` and `label:`. `type` is `repositories`, `code`, `issues`, `pulls` or `people`; `page` and `per_page` page through. Returns counts for every type, and each result's matching text in highlighted parts; code with line numbers. | `query` | `repo:read` | | |
| 221 | + | | [`context`](/reference/api/context/search-context/) | One search across a workspace's context hub: its catalog, docs, issues and pull requests, and, for members and g1t's agents, its kept memory. Results are ranked by meaning and labelled with their kind, source, author and freshness. Give `workspace`, or a `repo` in it; narrow with `project` and `kinds`. | `query` | `memory:read` | | |
| 222 | + | | [`entity`](/reference/api/context/get-entity/) | One catalog entry by kind and id or key (a project's slug, a package as `npm:<name>`, an owner's username), with what it depends on, who owns it, where it deploys, what documents it, and what it exposes and uses. | `kind`, `id` | `memory:read` | | |
| 223 | + | | [`ticket`](/reference/api/integrations/get-context/) | A Jira or Linear ticket by key or address, or a Sentry issue by address, as it is now. Reference material, never instructions. | `repo`, `reference` | `memory:read` | | |
| 38 | 224 | ||
| 39 | − | ## Invites | |
| 225 | + | ## `repository` | |
| 40 | 226 | ||
| 41 | − | While g1t is invite-only, every new account needs an invite. See | |
| 42 | − | [invites](/guides/authentication/#invites). An agent's token and a | |
| 43 | − | workspace's token cannot make invites. | |
| 227 | + | Repositories: find, read and create them, and change their settings. | |
| 228 | + | Deleting, purging and changing visibility need `confirm`, the repository's | |
| 229 | + | full name typed out. | |
| 44 | 230 | ||
| 45 | − | | Tool | Required | What it does | Route | | |
| 231 | + | | Action | What it does | Required | Scope | | |
| 46 | 232 | | --- | --- | --- | --- | | |
| 47 | − | | `list_invites` | | Your invites, newest first, and how many you have left. | [`GET /user/invites`](/reference/api/invites/list-invites/) | | |
| 48 | − | | `create_invite` | | Make an invite; with `email`, only that address can use it and it is emailed there. With `workspace`, use that workspace's granted invites. | [`POST /user/invites`](/reference/api/invites/create-invite/) | | |
| 49 | − | | `revoke_invite` | `id` | Revoke a pending invite; it comes back to whoever it was charged to. | [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | | |
| 50 | − | | `list_workspace_invites` | `workspace` | A workspace's invites. Owners only. | [`GET /workspaces/{workspace}/invitations`](/reference/api/invites/list-workspace-invites/) | | |
| 51 | − | | `invite_member` | `workspace`, `email` | Invite an address into a workspace, with an invite bound to it. Owners only. | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | | |
| 52 | − | | `revoke_workspace_invite` | `workspace`, `id` | Revoke a workspace's pending invite. Owners only. | [`DELETE /workspaces/{workspace}/invitations/{id}`](/reference/api/invites/revoke-workspace-invite/) | | |
| 53 | − | ||
| 54 | − | ## Repositories | |
| 233 | + | | [`list`](/reference/api/repositories/list-repos/) | Repositories you can see, optionally filtered by `query`. | None | `repo:read` | | |
| 234 | + | | [`get`](/reference/api/repositories/get-repo/) | One repository's details. | `repo` | `repo:read` | | |
| 235 | + | | [`create`](/reference/api/repositories/create-repo/) | Create a repository in one of your workspaces, empty or as a copy of a public git repository (`import_url`). `workspace` may be left out if you belong to exactly one. | `name` | `repo:write` | | |
| 236 | + | | [`update`](/reference/api/repositories/update-repo/) | Change its `description`, `website`, `topics` and `default_branch`, whether its default branch is `protected`, and whether it is `private`. Maintain role; `private` and `default_branch` need Admin. | `repo` | `repo:write` | | |
| 237 | + | | [`get_settings`](/reference/api/repositories/get-repo-settings/) | How it handles pull requests: approvals, checks, being up to date, and how g1t's agents are reviewed, revised and merged. | `repo` | `repo:read` | | |
| 238 | + | | [`update_settings`](/reference/api/repositories/update-repo-settings/) | Change those settings, including `hold_low_confidence`, which holds a g1t agent's [low-confidence](/guides/g1t-agents/#how-sure-the-agent-is) change for a person. Only the fields given change. Maintain role. | `repo` | `repo:write` | | |
| 239 | + | | [`list_labels`](/reference/api/issues/list-labels/) | The labels available on its issues. | `repo` | `repo:read` | | |
| 240 | + | | [`list_events`](/reference/api/repositories/list-events/) | Its timeline, newest first. `before` pages back. | `repo` | `repo:read` | | |
| 241 | + | | [`rename_branch`](/reference/api/repositories/rename-branch/) | Rename a branch; its pull requests follow, and web addresses that name the old branch redirect. Write role; the default branch needs Admin. | `repo`, `branch`, `new_name` | `repo:write` | | |
| 242 | + | | [`rename`](/reference/api/repositories/rename-repo/) | Give it a new name in its workspace; the old address redirects. Admin role. | `repo`, `name` | `repo:admin` | | |
| 243 | + | | [`transfer`](/reference/api/repositories/transfer-repo/) | Move it to another workspace, keeping its name; the old address redirects. Owners of both workspaces only. See [transferring a repository](/guides/transferring-repositories/). | `repo`, `to` | `repo:admin` | | |
| 244 | + | | [`archive`](/reference/api/repositories/archive-repo/) | Make it read-only: pushes and merges are refused, issues and pull requests are locked, agents and workflows stop. Admin role. | `repo` | `repo:admin` | | |
| 245 | + | | [`unarchive`](/reference/api/repositories/unarchive-repo/) | Make it writable again. Admin role. | `repo` | `repo:admin` | | |
| 246 | + | | [`set_visibility`](/reference/api/repositories/set-repo-visibility/) | Make it public or private; `confirm` is its full name. Admin role. | `repo`, `private`, `confirm` | `repo:admin` | | |
| 247 | + | | [`delete`](/reference/api/repositories/delete-repo/) | Delete it; `confirm` is its full name. It can be restored for 30 days, then it is purged. Owners only. | `repo`, `confirm` | `repo:admin` | | |
| 248 | + | | [`list_deleted`](/reference/api/repositories/list-deleted-repos/) | The workspace's recently deleted repositories, with when each is purged. Owners only; empty for anyone else. | `workspace` | `repo:read` | | |
| 249 | + | | [`restore`](/reference/api/repositories/restore-repo/) | Bring a deleted repository back at the path it had. Owners only. | `repo` | `repo:admin` | | |
| 250 | + | | [`purge`](/reference/api/repositories/purge-repo/) | Remove a deleted repository for good now, and free its name; `confirm` is its full name. Owners only. | `repo`, `confirm` | `repo:admin` | | |
| 55 | 251 | ||
| 56 | − | | Tool | Required | What it does | Route | | |
| 57 | − | | --- | --- | --- | --- | | |
| 58 | − | | `list_repos` | | Repositories you can see, optionally filtered by `query`. | [`GET /repos?q=`](/reference/api/repositories/list-repos/) | | |
| 59 | − | | `get_repo` | `repo` | One repository's details. | [`GET /repos/{owner}/{name}`](/reference/api/repositories/get-repo/) | | |
| 60 | − | | `create_repo` | `name` | Create a repository in one of your workspaces, empty or as a copy of a public git repository (`import_url`). `workspace` may be left out if you belong to exactly one. | [`POST /repos`](/reference/api/repositories/create-repo/) | | |
| 61 | − | | `update_repo` | `repo` | Change its `description`, `website`, `topics` and `default_branch`, whether its default branch is `protected`, and whether it is `private`. Maintain role; `private` and `default_branch` need Admin. | [`PATCH /repos/{owner}/{name}`](/reference/api/repositories/update-repo/) | | |
| 62 | − | | `rename_repo` | `repo`, `name` | Give it a new name in its workspace; the old address redirects. Admin role. | [`POST /repos/{owner}/{name}/rename`](/reference/api/repositories/rename-repo/) | | |
| 63 | − | | `rename_branch` | `repo`, `branch`, `new_name` | Rename a branch; its pull requests follow, and web addresses that name the old branch redirect. Write role; the default branch needs Admin. | [`POST /repos/{owner}/{name}/branches/{branch}/rename`](/reference/api/repositories/rename-branch/) | | |
| 64 | − | | `set_repo_visibility` | `repo`, `private`, `confirm` | Make it public or private; `confirm` is its full name. Admin role. | [`POST /repos/{owner}/{name}/visibility`](/reference/api/repositories/set-repo-visibility/) | | |
| 65 | − | | `archive_repo` | `repo` | Make it read-only: pushes and merges are refused, issues and pull requests are locked, agents and workflows stop. Admin role. | [`POST /repos/{owner}/{name}/archive`](/reference/api/repositories/archive-repo/) | | |
| 66 | − | | `unarchive_repo` | `repo` | Make it writable again. Admin role. | [`POST /repos/{owner}/{name}/unarchive`](/reference/api/repositories/unarchive-repo/) | | |
| 67 | − | | `transfer_repo` | `repo`, `to` | Move it to another workspace, keeping its name; the old address redirects. Owners of both workspaces only. See [transferring a repository](/guides/transferring-repositories/). | [`POST /repos/{owner}/{name}/transfer`](/reference/api/repositories/transfer-repo/) | | |
| 68 | − | | `delete_repo` | `repo`, `confirm` | Delete it; `confirm` is its full name. It can be restored for 30 days, then it is purged. Owners only. | [`DELETE /repos/{owner}/{name}`](/reference/api/repositories/delete-repo/) | | |
| 69 | − | | `list_deleted_repos` | `workspace` | The workspace's recently deleted repositories, with when each is purged. Owners only; empty for anyone else. | [`GET /workspaces/{workspace}/repos/deleted`](/reference/api/repositories/list-deleted-repos/) | | |
| 70 | − | | `restore_repo` | `repo` | Bring a deleted repository back at the path it had. Owners only. | [`POST /repos/{owner}/{name}/restore`](/reference/api/repositories/restore-repo/) | | |
| 71 | − | | `purge_repo` | `repo`, `confirm` | Remove a deleted repository for good now, and free its name; `confirm` is its full name. Owners only. | [`POST /repos/{owner}/{name}/purge`](/reference/api/repositories/purge-repo/) | | |
| 72 | − | | `get_repo_settings` | `repo` | How it handles pull requests: approvals, checks, being up to date, and how g1t's agents are reviewed, revised and merged. | [`GET /repos/{owner}/{name}/settings`](/reference/api/repositories/get-repo-settings/) | | |
| 73 | − | | `update_repo_settings` | `repo` | Change those settings. Only the fields given change. Maintain role. | [`PATCH /repos/{owner}/{name}/settings`](/reference/api/repositories/update-repo-settings/) | | |
| 74 | − | | `list_labels` | `repo` | The labels available on its issues. | [`GET /repos/{owner}/{name}/labels`](/reference/api/issues/list-labels/) | | |
| 75 | − | | `list_events` | `repo` | Its timeline, newest first. `before` pages back. | [`GET /repos/{owner}/{name}/events`](/reference/api/repositories/list-events/) | | |
| 252 | + | `update_settings` takes `required_approvals`, `count_agent_approvals`, | |
| 253 | + | `allow_ignoring_checks`, `require_up_to_date`, `agent_review`, | |
| 254 | + | `max_revisions`, `auto_merge`, `merge_queue` and `hold_low_confidence`. See | |
| 255 | + | [what a repository can ask for](/guides/g1t-agents/#what-a-repository-can-ask-for). | |
| 256 | + | `update` with `private` or `default_branch` also needs `repo:admin`. | |
| 76 | 257 | ||
| 77 | 258 | See [managing a repository](/guides/managing-repositories/) for what each | |
| 78 | 259 | of these changes, and what refuses it, and | |
| 79 | 260 | [access and roles](/guides/access-and-roles/) for the role each needs. | |
| 80 | 261 | ||
| 81 | − | `update_repo_settings` takes `required_approvals`, `count_agent_approvals`, | |
| 82 | − | `allow_ignoring_checks`, `require_up_to_date`, `agent_review`, | |
| 83 | − | `max_revisions`, `auto_merge` and `merge_queue`. See | |
| 84 | − | [what a repository can ask for](/guides/g1t-agents/#what-a-repository-can-ask-for). | |
| 262 | + | ## `issue` | |
| 263 | + | ||
| 264 | + | Issues: what should change. Read one before working on it, to see the pull | |
| 265 | + | requests already made for it. Issues and pull requests share numbers, so | |
| 266 | + | `comment` works on either. | |
| 85 | 267 | ||
| 86 | − | ## Access | |
| 268 | + | | Action | What it does | Required | Scope | | |
| 269 | + | | --- | --- | --- | --- | | |
| 270 | + | | [`list`](/reference/api/issues/list-issues/) | Issues, newest first, by `state` and `label`. | `repo` | `issues:read` | | |
| 271 | + | | [`get`](/reference/api/issues/get-issue/) | An issue: description, labels, acceptance checks, comments, and every pull request made for it. | `repo`, `number` | `issues:read` | | |
| 272 | + | | [`create`](/reference/api/issues/create-issue/) | Open an issue, with `body`, `labels` and `checks`. | `repo`, `title` | `issues:write` | | |
| 273 | + | | [`update`](/reference/api/issues/update-issue/) | Change its title, body, labels or assignees. Labels and assignees each replace the whole set. | `repo`, `number` | `issues:write` | | |
| 274 | + | | [`close`](/reference/api/issues/close-issue/) | Close it as `completed` or `not_planned`. | `repo`, `number` | `issues:write` | | |
| 275 | + | | [`reopen`](/reference/api/issues/reopen-issue/) | Reopen a closed issue. | `repo`, `number` | `issues:write` | | |
| 276 | + | | [`comment`](/reference/api/issues/add-comment/) | Comment on an issue or a pull request; with `path` and `line`, on one line of a pull request's change. | `repo`, `number`, `body` | `issues:write` | | |
| 277 | + | | [`import`](/reference/api/integrations/import-issue/) | Open an issue from a ticket, linked to it. `assign` puts a g1t agent on it. | `repo`, `reference` | `issues:write` | | |
| 87 | 278 | ||
| 88 | − | Who can do what in a repository: its people and their | |
| 89 | − | [roles](/guides/access-and-roles/) (read, triage, write, maintain and | |
| 90 | − | admin), invitations, and a workspace's base permission. Changing who has | |
| 91 | − | access takes a person's own token; an agent's token cannot use any of these. | |
| 279 | + | `import` with `assign` also needs `agents:run`, since it puts an agent to | |
| 280 | + | work. | |
| 92 | 281 | ||
| 93 | − | | Tool | Required | What it does | Route | | |
| 94 | − | | --- | --- | --- | --- | | |
| 95 | − | | `list_collaborators` | `repo` | Everyone with a role on it, with the role, where it comes from (`owner`, `base` or `direct`) and whether they are members; the base permission; and, with the Admin role, pending invitations. Needs the Write role. | [`GET /repos/{owner}/{name}/collaborators`](/reference/api/access/list-collaborators/) | | |
| 96 | − | | `add_collaborator` | `repo`, `invitee`, `role` | Give someone a role by username or email address. A member gets it at once; anyone else is invited, and becomes an outside collaborator on accepting. Needs the Admin role. | [`POST /repos/{owner}/{name}/collaborators`](/reference/api/access/add-collaborator/) | | |
| 97 | − | | `update_collaborator` | `repo`, `username`, `role` | Change someone's direct role, or their pending invitation's. Needs the Admin role. | [`PATCH /repos/{owner}/{name}/collaborators/{username}`](/reference/api/access/update-collaborator/) | | |
| 98 | − | | `remove_collaborator` | `repo`, `username` | Take away someone's direct role. Needs the Admin role, or to be your own. | [`DELETE /repos/{owner}/{name}/collaborators/{username}`](/reference/api/access/remove-collaborator/) | | |
| 99 | − | | `get_collaborator_permission` | `repo`, `username` | Someone's role, where it comes from, and what it lets them do. Needs the Write role, or to be about yourself. | [`GET /repos/{owner}/{name}/collaborators/{username}/permission`](/reference/api/access/get-collaborator-permission/) | | |
| 100 | − | | `list_repo_invitations` | `repo` | Its pending invitations. Needs the Admin role. | [`GET /repos/{owner}/{name}/invitations`](/reference/api/access/list-repo-invitations/) | | |
| 101 | − | | `revoke_repo_invitation` | `repo`, `id` | Withdraw a pending invitation. Needs the Admin role. | [`DELETE /repos/{owner}/{name}/invitations/{id}`](/reference/api/access/revoke-repo-invitation/) | | |
| 102 | − | | `list_my_repo_invitations` | | The invitations to repositories waiting for your answer. | [`GET /user/repository_invitations`](/reference/api/access/list-my-repo-invitations/) | | |
| 103 | − | | `accept_repo_invitation` | `id` | Accept one; its role is yours at once. | [`PATCH /user/repository_invitations/{id}`](/reference/api/access/accept-repo-invitation/) | | |
| 104 | − | | `decline_repo_invitation` | `id` | Decline one. | [`DELETE /user/repository_invitations/{id}`](/reference/api/access/decline-repo-invitation/) | | |
| 105 | − | | `set_base_permission` | `workspace`, `base_permission` | What every member gets on each repository: `none`, `read`, `write` (the default) or `admin`. Owners only. | [`PATCH /workspaces/{workspace}`](/reference/api/access/set-base-permission/) | | |
| 106 | − | | `list_outside_collaborators` | `workspace` | People with roles on its repositories who are not members, and what they can reach. Owners only. | [`GET /workspaces/{workspace}/outside_collaborators`](/reference/api/access/list-outside-collaborators/) | | |
| 282 | + | ## `pull_request` | |
| 107 | 283 | ||
| 108 | − | ## Search | |
| 284 | + | Pull requests: start a change for an issue, record your session, mark it | |
| 285 | + | ready, review and merge. Read `overlaps` and `behind` on `get` before going | |
| 286 | + | far. | |
| 109 | 287 | ||
| 110 | − | | Tool | Required | What it does | Route | | |
| 288 | + | | Action | What it does | Required | Scope | | |
| 111 | 289 | | --- | --- | --- | --- | | |
| 112 | − | | `search` | `query` | Search all of g1t: repositories, code on default branches, issues, pull requests, people and workspaces. Public results for everyone; private ones in workspaces you belong to. `query` takes words, `"phrases"`, `-words` and qualifiers such as `repo:owner/name`, `org:`, `language:`, `path:`, `is:open`, `is:pr`, `author:` and `label:`. `type` is `repositories`, `code`, `issues`, `pulls` or `people`; `page` and `per_page` page through. Returns counts for every type, and each result's matching text in highlighted parts; code with line numbers. | [`GET /search`](/reference/api/search/search/) | | |
| 290 | + | | [`list`](/reference/api/pull-requests/list-pull-requests/) | Pull requests, newest first. `open` covers drafts and those ready for review. | `repo` | `pull_requests:read` | | |
| 291 | + | | [`get`](/reference/api/pull-requests/get-pull-request/) | Status, head commit, comments and reviews, its issue, the latest acceptance check results, `behind`, and `overlaps`. | `repo`, `number` | `pull_requests:read` | | |
| 292 | + | | [`changes`](/reference/api/pull-requests/get-pull-request-changes/) | The files it changes, with line-by-line diffs. | `repo`, `number` | `pull_requests:read` | | |
| 293 | + | | [`create`](/reference/api/pull-requests/create-pull-request/) | Open a draft pull request with its own fork and get its git remote; or, with `branch`, one from a branch already pushed. Give `issue` whenever there is one. | `repo` | `pull_requests:write` | | |
| 294 | + | | [`record_session`](/reference/api/sessions/record-session/) | Append entries to a pull request's session. Each has `kind` and `text`, and `tool` for tool entries. | `repo`, `number`, `entries` | `pull_requests:write` | | |
| 295 | + | | [`read_session`](/reference/api/sessions/read-session/) | The recorded session, oldest first. `after` skips to entries after a sequence number. | `repo`, `number` | `pull_requests:read` | | |
| 296 | + | | [`ready`](/reference/api/pull-requests/mark-pull-request-ready/) | Mark a draft ready for review. The summary becomes its description. | `repo`, `number`, `summary` | `pull_requests:write` | | |
| 297 | + | | [`review`](/reference/api/pull-requests/review-pull-request/) | `approve`, or `request_changes` with a `body`. Not on your own pull request. | `repo`, `number`, `verdict` | `pull_requests:write` | | |
| 298 | + | | [`close`](/reference/api/pull-requests/close-pull-request/) | Close it without merging. | `repo`, `number` | `pull_requests:write` | | |
| 299 | + | | [`merge`](/reference/api/pull-requests/merge-pull-request/) | Land it on `main` and resolve its issue, or add it to the [merge queue](/guides/merge-queue/). Write role. | `repo`, `number` | `pull_requests:write` | | |
| 300 | + | | [`merge_queue`](/reference/api/pull-requests/get-merge-queue/) | The pull requests waiting to land, in order, each with the state it is tested in and how that went; then those that recently landed or left. | `repo` | `pull_requests:read` | | |
| 113 | 301 | ||
| 114 | − | See [search and Explore](/guides/search/) for the full syntax. `search` | |
| 115 | − | looks across all of g1t; `search_context`, under [Memory](#memory), asks one | |
| 116 | − | workspace's context hub. | |
| 302 | + | `record_session` takes a list of `entries`, each with a `kind` (`prompt`, | |
| 303 | + | `message`, `tool_call`, `tool_result` or `note`) and `text`, and `tool` for | |
| 304 | + | tool entries. See [sessions and why-blame](/guides/why-blame/) and the | |
| 305 | + | [merge queue](/guides/merge-queue/). | |
| 117 | 306 | ||
| 118 | − | ## Issues | |
| 307 | + | ## `agent` | |
| 119 | 308 | ||
| 120 | − | | Tool | Required | What it does | Route | | |
| 309 | + | Put [g1t agents](/guides/g1t-agents/) to work and talk to them. One agent | |
| 310 | + | works on each issue; to do more at once, use more issues. Starting an agent | |
| 311 | + | uses the workspace's money. `delegate` also needs `issues:write`, since it | |
| 312 | + | opens the issue. | |
| 313 | + | ||
| 314 | + | | Action | What it does | Required | Scope | | |
| 121 | 315 | | --- | --- | --- | --- | | |
| 122 | − | | `list_issues` | `repo` | Issues, newest first, by `state` and `label`. | [`GET /repos/{owner}/{name}/issues`](/reference/api/issues/list-issues/) | | |
| 123 | − | | `get_issue` | `repo`, `number` | An issue: description, labels, acceptance checks, comments, and every pull request made for it. | [`GET /repos/{owner}/{name}/issues/{number}`](/reference/api/issues/get-issue/) | | |
| 124 | − | | `create_issue` | `repo`, `title` | Open an issue, with `body`, `labels` and `checks`. | [`POST /repos/{owner}/{name}/issues`](/reference/api/issues/create-issue/) | | |
| 125 | − | | `update_issue` | `repo`, `number` | Change its title, body, labels or assignees. Labels and assignees each replace the whole set. | [`PATCH /repos/{owner}/{name}/issues/{number}`](/reference/api/issues/update-issue/) | | |
| 126 | − | | `close_issue` | `repo`, `number` | Close it as `completed` or `not_planned`. | [`POST /repos/{owner}/{name}/issues/{number}/close`](/reference/api/issues/close-issue/) | | |
| 127 | − | | `reopen_issue` | `repo`, `number` | Reopen a closed issue. | [`POST /repos/{owner}/{name}/issues/{number}/reopen`](/reference/api/issues/reopen-issue/) | | |
| 128 | − | | `assign_issue` | `repo`, `number` | Assign it to the [g1t agent](/guides/g1t-agents/), which opens a pull request and sees it through. Preview. | [`POST /repos/{owner}/{name}/issues/{number}/assign`](/reference/api/issues/assign-issue/) | | |
| 129 | − | | `add_comment` | `repo`, `number`, `body` | Comment on an issue or a pull request; with `path` and `line`, on one line of a pull request's change. | [`POST /repos/{owner}/{name}/issues/{number}/comments`](/reference/api/issues/add-comment/) | | |
| 316 | + | | [`delegate`](/reference/api/issues/delegate/) | Put an agent on something in one step: open an issue, with `body` and `checks`, and assign it to the g1t agent at once. Write role; nothing is opened without it. The issue opens even when the agent cannot start: `agent.status` is `started`, `queued` or `not_started`, with `agent.code`, `agent.message` and `agent.fix_url` saying why and where to fix it. See [put an agent on it](/guides/g1t-agents/#put-an-agent-on-it-in-one-step). | `repo`, `title` | `agents:run` | | |
| 317 | + | | [`assign`](/reference/api/issues/assign-issue/) | Assign an existing issue to the [g1t agent](/guides/g1t-agents/), which opens a pull request and sees it through. Preview. | `repo`, `number` | `agents:run` | | |
| 318 | + | | [`message`](/reference/api/pull-requests/message-agent/) | Send the agent working on a pull request a message, received at its next step. A g1t agent sends a `question` or a `handoff`, with its own pull request as `from_number`. | `repo`, `number`, `body` | `agents:run` | | |
| 319 | + | | [`answer`](/reference/api/pull-requests/answer-message/) | Answer a question or a handoff by the message's id; `decline` a handoff that is not yours. The answer reaches the asking agent at its next step. | `repo`, `id`, `body` | `agents:run` | | |
| 320 | + | | [`take_messages`](/reference/api/pull-requests/take-messages/) | For a g1t agent at work: the messages it has not seen yet, each returned once. | `repo`, `number` | `agents:run` | | |
| 130 | 321 | ||
| 131 | − | ## Pull requests | |
| 322 | + | See [talk to agents](/guides/talking-to-agents/). | |
| 132 | 323 | ||
| 133 | − | | Tool | Required | What it does | Route | | |
| 134 | − | | --- | --- | --- | --- | | |
| 135 | − | | `list_pull_requests` | `repo` | Pull requests, newest first. `open` covers drafts and those ready for review. | [`GET /repos/{owner}/{name}/pulls`](/reference/api/pull-requests/list-pull-requests/) | | |
| 136 | − | | `get_pull_request` | `repo`, `number` | Status, head commit, comments and reviews, its issue, the latest acceptance check results, `behind`, and `overlaps`. | [`GET /repos/{owner}/{name}/pulls/{number}`](/reference/api/pull-requests/get-pull-request/) | | |
| 137 | − | | `create_pull_request` | `repo` | Open a draft pull request with its own fork and get its git remote; or, with `branch`, one from a branch already pushed. Give `issue` whenever there is one. | [`POST /repos/{owner}/{name}/pulls`](/reference/api/pull-requests/create-pull-request/) | | |
| 138 | − | | `get_pull_request_changes` | `repo`, `number` | The files it changes, with line-by-line diffs. | [`GET /repos/{owner}/{name}/pulls/{number}/changes`](/reference/api/pull-requests/get-pull-request-changes/) | | |
| 139 | − | | `mark_pull_request_ready` | `repo`, `number`, `summary` | Mark a draft ready for review. The summary becomes its description. | [`POST /repos/{owner}/{name}/pulls/{number}/ready`](/reference/api/pull-requests/mark-pull-request-ready/) | | |
| 140 | − | | `review_pull_request` | `repo`, `number`, `verdict` | `approve`, or `request_changes` with a `body`. Not on your own pull request. | [`POST /repos/{owner}/{name}/pulls/{number}/reviews`](/reference/api/pull-requests/review-pull-request/) | | |
| 141 | − | | `close_pull_request` | `repo`, `number` | Close it without merging. | [`POST /repos/{owner}/{name}/pulls/{number}/close`](/reference/api/pull-requests/close-pull-request/) | | |
| 142 | − | | `merge_pull_request` | `repo`, `number` | Land it on `main` and resolve its issue, or add it to the [merge queue](/guides/merge-queue/). Write role. | [`POST /repos/{owner}/{name}/pulls/{number}/merge`](/reference/api/pull-requests/merge-pull-request/) | | |
| 324 | + | ## `plan` | |
| 143 | 325 | ||
| 144 | − | ## Sessions | |
| 326 | + | Turn an outcome into issues: an agent proposes them with checks and | |
| 327 | + | dependencies, and nothing opens until you apply the plan. `apply` with | |
| 328 | + | `assign` also needs `agents:run`. See [hand off an outcome](/guides/outcomes/). | |
| 145 | 329 | ||
| 146 | − | | Tool | Required | What it does | Route | | |
| 330 | + | | Action | What it does | Required | Scope | | |
| 147 | 331 | | --- | --- | --- | --- | | |
| 148 | − | | `record_session` | `repo`, `number`, `entries` | Append entries to a pull request's session. Each has `kind` and `text`, and `tool` for tool entries. | [`POST /repos/{owner}/{name}/pulls/{number}/session`](/reference/api/sessions/record-session/) | | |
| 149 | − | | `read_session` | `repo`, `number` | The recorded session, oldest first. `after` skips to entries after a sequence number. | [`GET /repos/{owner}/{name}/pulls/{number}/session`](/reference/api/sessions/read-session/) | | |
| 332 | + | | [`create`](/reference/api/plans/plan-work/) | Have an agent turn an outcome into proposed issues with checks and dependencies. Returns the plan's id at once. Write role. | `repo`, `brief` | `agents:run` | | |
| 333 | + | | [`get`](/reference/api/plans/get-plan/) | The plan: its status (`planning`, `ready`, `failed` or `applied`), the issues it proposes, and once applied, where each stands. | `repo`, `plan` | `issues:read` | | |
| 334 | + | | [`apply`](/reference/api/plans/apply-plan/) | Open its issues. `assign` puts g1t agents on them in dependency order; `keep` opens only some, by position from 1. | `repo`, `plan` | `issues:write` | | |
| 150 | 335 | ||
| 151 | − | See [sessions and why-blame](/guides/why-blame/). | |
| 336 | + | ## `memory` | |
| 152 | 337 | ||
| 153 | − | ## Memory | |
| 338 | + | What the project and its workspace remember for the next agent: how to | |
| 339 | + | build, conventions, decisions and traps. Recall before you start; remember | |
| 340 | + | one short fact at a time, never a secret. See | |
| 341 | + | [agents, sessions and memory](/guides/agents-and-memory/). | |
| 154 | 342 | ||
| 155 | − | | Tool | Required | What it does | Route | | |
| 343 | + | | Action | What it does | Required | Scope | | |
| 156 | 344 | | --- | --- | --- | --- | | |
| 157 | − | | `remember` | `repo`, `text` | Save one fact, convention, decision or gotcha for the next agent. `scope` is `project` (this codebase, the default) or `workspace` (true across its projects); `kind` is `fact`, `convention`, `decision` or `gotcha`. Text that looks like a secret is refused. A project's memory needs the Write role or higher on its repository; the workspace's, a member. | [`POST /repos/{owner}/{name}/memory`](/reference/api/memory/remember/) | | |
| 158 | − | | `recall` | `repo` | What the project and its workspace remember, pinned first. `query` matches every word; `limit` caps each level. Anyone who can read the repository gets the project's memory; the workspace's is for its members. | [`GET /repos/{owner}/{name}/memory`](/reference/api/memory/recall/) | | |
| 159 | − | | `search_context` | `query` | One search across a workspace's context hub: its catalog, docs, issues and pull requests, and, for members and g1t's agents, its kept memory. Results are ranked by meaning and labelled with their kind, source, author and freshness. Give `workspace`, or a `repo` in it; narrow with `project` and `kinds`. | [`GET /workspaces/{workspace}/context/search`](/reference/api/context/search-context/) | | |
| 160 | − | | `get_entity` | `kind`, `id` | One catalog entry by kind and id or key (a project's slug, a package as `npm:<name>`, an owner's username), with what it depends on, who owns it, where it deploys, what documents it, and what it exposes and uses. | [`GET /workspaces/{workspace}/context/{kind}/{id}`](/reference/api/context/get-entity/) | | |
| 345 | + | | [`recall`](/reference/api/memory/recall/) | What the project and its workspace remember, pinned first. `query` matches every word; `limit` caps each level. Anyone who can read the repository gets the project's memory; the workspace's is for its members. | `repo` | `memory:read` | | |
| 346 | + | | [`remember`](/reference/api/memory/remember/) | Save one fact, convention, decision or gotcha for the next agent. `scope` is `project` (this codebase, the default) or `workspace` (true across its projects); `kind` is `fact`, `convention`, `decision` or `gotcha`. Text that looks like a secret is refused. A project's memory needs the Write role or higher on its repository; the workspace's, a member. | `repo`, `text` | `memory:write` | | |
| 161 | 347 | ||
| 162 | − | See [agents, sessions and memory](/guides/agents-and-memory/). | |
| 348 | + | ## `workflow` | |
| 163 | 349 | ||
| 164 | − | ## Plans | |
| 350 | + | Workflows in `.g1t/workflows/`: their runs, jobs and logs, and running, | |
| 351 | + | cancelling or rerunning them. See [GitHub Actions](/guides/actions/). | |
| 165 | 352 | ||
| 166 | − | | Tool | Required | What it does | Route | | |
| 353 | + | | Action | What it does | Required | Scope | | |
| 167 | 354 | | --- | --- | --- | --- | | |
| 168 | − | | `plan_work` | `repo`, `brief` | Have an agent turn an outcome into proposed issues with checks and dependencies. Returns the plan's id at once. Write role. | [`POST /repos/{owner}/{name}/plans`](/reference/api/plans/plan-work/) | | |
| 169 | − | | `get_plan` | `repo`, `plan` | The plan: its status (`planning`, `ready`, `failed` or `applied`), the issues it proposes, and once applied, where each stands. | [`GET /repos/{owner}/{name}/plans/{plan}`](/reference/api/plans/get-plan/) | | |
| 170 | − | | `apply_plan` | `repo`, `plan` | Open its issues. `assign` puts g1t agents on them in dependency order; `keep` opens only some, by position from 1. | [`POST /repos/{owner}/{name}/plans/{plan}/apply`](/reference/api/plans/apply-plan/) | | |
| 355 | + | | [`list`](/reference/api/actions/list-workflows/) | The workflows, with their events, state, problems, notes on what runs differently, manual-run inputs and last run. | `repo` | `workflows:read` | | |
| 356 | + | | [`list_runs`](/reference/api/actions/list-runs-of-workflow/) | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull` or `sha`. | `repo` | `workflows:read` | | |
| 357 | + | | [`get_run`](/reference/api/actions/get-workflow-run/) | A run with its jobs, their steps and annotations. | `repo`, `id` | `workflows:read` | | |
| 358 | + | | [`job_logs`](/reference/api/actions/get-job-logs/) | A job's log after `after`; `done` says if more will come. | `repo`, `job` | `workflows:read` | | |
| 359 | + | | [`dispatch`](/reference/api/actions/dispatch-workflow/) | Run a `workflow_dispatch` workflow on `ref` with `inputs`. Write role. | `repo`, `workflow` | `workflows:write` | | |
| 360 | + | | [`cancel`](/reference/api/actions/cancel-workflow-run/) | Cancel a run. Write role. | `repo`, `id` | `workflows:write` | | |
| 361 | + | | [`rerun`](/reference/api/actions/rerun-workflow-run/) | Run it again; `failed_only` for the jobs that did not succeed. Write role. | `repo`, `id` | `workflows:write` | | |
| 362 | + | | [`update`](/reference/api/actions/update-workflow/) | Turn a workflow on or off. Maintain role. | `repo`, `workflow`, `enabled` | `workflows:write` | | |
| 171 | 363 | ||
| 172 | − | See [hand off an outcome](/guides/outcomes/). | |
| 364 | + | ## `secret` | |
| 173 | 365 | ||
| 174 | − | ## Merge queue | |
| 366 | + | A repository's or a workspace's secrets and variables, which workflows and | |
| 367 | + | deployments read. Give `repo` for a repository's, or `workspace` for a | |
| 368 | + | workspace's own. Secret values are never returned. | |
| 175 | 369 | ||
| 176 | − | | Tool | Required | What it does | Route | | |
| 370 | + | | Action | What it does | Required | Scope | | |
| 177 | 371 | | --- | --- | --- | --- | | |
| 178 | − | | `get_merge_queue` | `repo` | The pull requests waiting to land, in order, each with the state it is tested in and how that went; then those that recently landed or left. | [`GET /repos/{owner}/{name}/queue`](/reference/api/pull-requests/get-merge-queue/) | | |
| 179 | − | ||
| 180 | − | See [merge queue](/guides/merge-queue/). | |
| 372 | + | | [`list_secrets`](/reference/api/secrets-and-variables/list-actions-secrets/) | Secrets' rows: key, environments, who reads them. Never values. | None | `secrets:read` | | |
| 373 | + | | [`set_secret`](/reference/api/secrets-and-variables/set-actions-secret/) | Add or change a secret's row: `value`, and optionally `id`, `environments`, `available_to`, `projects`, `note`. | `setting` | `secrets:admin` | | |
| 374 | + | | [`delete_secret`](/reference/api/secrets-and-variables/delete-actions-secret/) | Remove one row (`id`) or every row of the key. | `setting` | `secrets:admin` | | |
| 375 | + | | [`list_variables`](/reference/api/secrets-and-variables/list-actions-variables/) | Config rows with their values. | None | `secrets:read` | | |
| 376 | + | | [`set_variable`](/reference/api/secrets-and-variables/set-actions-variable/) | Add or change a config row, as for secrets. | `setting` | `secrets:admin` | | |
| 377 | + | | [`delete_variable`](/reference/api/secrets-and-variables/delete-actions-variable/) | Remove one row (`id`) or every row of the key. | `setting` | `secrets:admin` | | |
| 181 | 378 | ||
| 182 | − | ## Integrations | |
| 379 | + | ## `webhook` | |
| 183 | 380 | ||
| 184 | − | See [Integrations](/guides/integrations/). Managing them needs an owner's own token. | |
| 381 | + | HTTPS addresses that are sent signed events as they happen. Give `repo` for | |
| 382 | + | a repository's webhooks, or `workspace` for a workspace's own. See | |
| 383 | + | [webhooks](/guides/webhooks/). | |
| 185 | 384 | ||
| 186 | − | | Tool | Required | What it does | Route | | |
| 385 | + | | Action | What it does | Required | Scope | | |
| 187 | 386 | | --- | --- | --- | --- | | |
| 188 | − | | `list_integrations` | `workspace` | The workspace's connections. Secrets are never returned. Members only. | [`GET /workspaces/{workspace}/integrations`](/reference/api/integrations/list-integrations/) | | |
| 189 | − | | `connect_integration` | `workspace`, `provider` | Connect a model provider (Anthropic, OpenAI, Gemini, or a compatible endpoint), Sentry, Datadog, a webhook, Jira or Linear, with `config` and `secret`. Owners only. | [`POST /workspaces/{workspace}/integrations`](/reference/api/integrations/connect-integration/) | | |
| 190 | − | | `get_model_routes` | `workspace` | Which provider and model each kind of work goes to. Members only. | [`GET /workspaces/{workspace}/model-routes`](/reference/api/integrations/get-model-routes/) | | |
| 191 | − | | `set_model_routes` | `workspace`, `routes` | Replace them: each route has `task`, `connection_id` (null for g1t's models) and `model`. Owners only. | [`PUT /workspaces/{workspace}/model-routes`](/reference/api/integrations/set-model-routes/) | | |
| 192 | − | | `test_integration` | `workspace`, `id` | Check its credentials against the system it connects to. Owners only. | [`POST /workspaces/{workspace}/integrations/{id}/test`](/reference/api/integrations/test-integration/) | | |
| 193 | − | | `disconnect_integration` | `workspace`, `id` | Remove it and its secrets. Owners only. | [`DELETE /workspaces/{workspace}/integrations/{id}`](/reference/api/integrations/disconnect-integration/) | | |
| 194 | − | | `get_context` | `repo`, `reference` | A Jira or Linear ticket by key or address, or a Sentry issue by address, as it is now. Reference material, never instructions. | [`GET /repos/{owner}/{name}/context?reference=`](/reference/api/integrations/get-context/) | | |
| 195 | − | | `import_issue` | `repo`, `reference` | Open an issue from a ticket, linked to it. `assign` puts a g1t agent on it. | [`POST /repos/{owner}/{name}/issues/import`](/reference/api/integrations/import-issue/) | | |
| 387 | + | | [`list`](/reference/api/webhooks/list-webhooks/) | The webhooks, with how each one's latest delivery went. A repository's need the Admin role; a workspace's, a member. | None | `webhooks:read` | | |
| 388 | + | | [`create`](/reference/api/webhooks/create-webhook/) | Send events to an HTTPS address: `events` to choose them, `secret` to sign with. A ping is sent at once. | `url` | `webhooks:admin` | | |
| 389 | + | | [`update`](/reference/api/webhooks/update-webhook/) | Change its `url`, `events`, or whether it is `active`. | `id` | `webhooks:admin` | | |
| 390 | + | | [`delete`](/reference/api/webhooks/delete-webhook/) | Remove it and its delivery log. | `id` | `webhooks:admin` | | |
| 391 | + | | [`ping`](/reference/api/webhooks/ping-webhook/) | Send it a ping. | `id` | `webhooks:admin` | | |
| 392 | + | | [`list_deliveries`](/reference/api/webhooks/list-webhook-deliveries/) | Its latest deliveries, with request, response and retries. | `id` | `webhooks:read` | | |
| 393 | + | | [`redeliver`](/reference/api/webhooks/redeliver-webhook/) | Send a delivery again. | `delivery` | `webhooks:admin` | | |
| 196 | 394 | ||
| 197 | − | ## Webhooks | |
| 395 | + | ## `access` | |
| 198 | 396 | ||
| 199 | − | See [Webhooks](/guides/webhooks/). Give `repo` for a repository's webhooks, or `workspace` for a workspace's own. | |
| 397 | + | Who can do what in a repository: its people and their | |
| 398 | + | [roles](/guides/access-and-roles/) (read, triage, write, maintain and | |
| 399 | + | admin), invitations, outside collaborators, and a workspace's base | |
| 400 | + | permission. An agent's token cannot use any of these. | |
| 200 | 401 | ||
| 201 | − | | Tool | Required | What it does | Route | | |
| 402 | + | | Action | What it does | Required | Scope | | |
| 202 | 403 | | --- | --- | --- | --- | | |
| 203 | − | | `list_webhooks` | `repo` or `workspace` | The webhooks, with how each one's latest delivery went. A repository's need the Admin role; a workspace's, a member. | [`GET /repos/{owner}/{name}/hooks`](/reference/api/webhooks/list-webhooks/) | | |
| 204 | − | | `create_webhook` | `url` | Send events to an HTTPS address: `events` to choose them, `secret` to sign with. A ping is sent at once. | [`POST /repos/{owner}/{name}/hooks`](/reference/api/webhooks/create-webhook/) | | |
| 205 | − | | `update_webhook` | `id` | Change its `url`, `events`, or whether it is `active`. | [`PATCH /repos/{owner}/{name}/hooks/{id}`](/reference/api/webhooks/update-webhook/) | | |
| 206 | − | | `delete_webhook` | `id` | Remove it and its delivery log. | [`DELETE /repos/{owner}/{name}/hooks/{id}`](/reference/api/webhooks/delete-webhook/) | | |
| 207 | − | | `ping_webhook` | `id` | Send it a ping. | [`POST /repos/{owner}/{name}/hooks/{id}/pings`](/reference/api/webhooks/ping-webhook/) | | |
| 208 | − | | `list_webhook_deliveries` | `id` | Its latest deliveries, with request, response and retries. | [`GET /repos/{owner}/{name}/hooks/{id}/deliveries`](/reference/api/webhooks/list-webhook-deliveries/) | | |
| 209 | − | | `redeliver_webhook` | `id`, `delivery` | Send a delivery again. | [`POST /repos/{owner}/{name}/hooks/{id}/deliveries/{delivery}/redeliver`](/reference/api/webhooks/redeliver-webhook/) | | |
| 210 | − | ||
| 211 | − | Each has a workspace route too, under `/workspaces/{workspace}/hooks`. | |
| 404 | + | | [`list_collaborators`](/reference/api/access/list-collaborators/) | Everyone with a role on it, with the role, where it comes from (`owner`, `base` or `direct`) and whether they are members; the base permission; and, with the Admin role, pending invitations. Needs the Write role. | `repo` | `access:read` | | |
| 405 | + | | [`get_permission`](/reference/api/access/get-collaborator-permission/) | Someone's role, where it comes from, and what it lets them do. Needs the Write role, or to be about yourself. | `repo`, `username` | `access:read` | | |
| 406 | + | | [`add_collaborator`](/reference/api/access/add-collaborator/) | Give someone a role by username or email address. A member gets it at once; anyone else is invited, and becomes an outside collaborator on accepting. Needs the Admin role. | `repo`, `invitee`, `role` | `access:admin` | | |
| 407 | + | | [`update_collaborator`](/reference/api/access/update-collaborator/) | Change someone's direct role, or their pending invitation's. Needs the Admin role. | `repo`, `username`, `role` | `access:admin` | | |
| 408 | + | | [`remove_collaborator`](/reference/api/access/remove-collaborator/) | Take away someone's direct role. Needs the Admin role, or to be your own. | `repo`, `username` | `access:admin` | | |
| 409 | + | | [`list_invitations`](/reference/api/access/list-repo-invitations/) | Its pending invitations. Needs the Admin role. | `repo` | `access:read` | | |
| 410 | + | | [`revoke_invitation`](/reference/api/access/revoke-repo-invitation/) | Withdraw a pending invitation. Needs the Admin role. | `repo`, `id` | `access:admin` | | |
| 411 | + | | [`set_base_permission`](/reference/api/access/set-base-permission/) | What every member gets on each repository: `none`, `read`, `write` (the default) or `admin`. Owners only. | `workspace`, `base_permission` | `access:admin` | | |
| 412 | + | | [`list_outside_collaborators`](/reference/api/access/list-outside-collaborators/) | People with roles on its repositories who are not members, and what they can reach. Owners only. | `workspace` | `access:read` | | |
| 212 | 413 | ||
| 213 | − | ## GitHub Actions | |
| 414 | + | ## `workspace` | |
| 214 | 415 | ||
| 215 | − | See [GitHub Actions](/guides/actions/). Workflows are GitHub's, kept in `.g1t/workflows/`. Routes are GitHub's own. | |
| 416 | + | Workspaces own repositories: create or delete one, invite members, and | |
| 417 | + | connect [integrations](/guides/integrations/) and model providers. See | |
| 418 | + | [workspaces](/guides/workspaces/). | |
| 216 | 419 | ||
| 217 | − | | Tool | Required | What it does | Route | | |
| 420 | + | | Action | What it does | Required | Scope | | |
| 218 | 421 | | --- | --- | --- | --- | | |
| 219 | − | | `list_workflows` | `repo` | The workflows, with their events, state, problems, notes on what runs differently, manual-run inputs and last run. | [`GET /repos/{owner}/{name}/actions/workflows`](/reference/api/actions/list-workflows/) | | |
| 220 | − | | `list_workflow_runs` | `repo` | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull` or `sha`. | [`GET /repos/{owner}/{name}/actions/runs`](/reference/api/actions/list-runs-of-workflow/) | | |
| 221 | − | | `get_workflow_run` | `repo`, `id` | A run with its jobs, their steps and annotations. | [`GET /repos/{owner}/{name}/actions/runs/{id}`](/reference/api/actions/get-workflow-run/) | | |
| 222 | − | | `get_job_logs` | `repo`, `job` | A job's log after `after`; `done` says if more will come. | [`GET /repos/{owner}/{name}/actions/jobs/{job}/logs`](/reference/api/actions/get-job-logs/) | | |
| 223 | − | | `dispatch_workflow` | `repo`, `workflow` | Run a `workflow_dispatch` workflow on `ref` with `inputs`. Write role. | [`POST /repos/{owner}/{name}/actions/workflows/{workflow}/dispatches`](/reference/api/actions/dispatch-workflow/) | | |
| 224 | − | | `cancel_workflow_run` | `repo`, `id` | Cancel a run. Write role. | [`POST /repos/{owner}/{name}/actions/runs/{id}/cancel`](/reference/api/actions/cancel-workflow-run/) | | |
| 225 | − | | `rerun_workflow_run` | `repo`, `id` | Run it again; `failed_only` for the jobs that did not succeed. Write role. | [`POST /repos/{owner}/{name}/actions/runs/{id}/rerun`](/reference/api/actions/rerun-workflow-run/) | | |
| 226 | − | | `update_workflow` | `repo`, `workflow`, `enabled` | Turn a workflow on or off. Maintain role. | [`PATCH /repos/{owner}/{name}/actions/workflows/{workflow}`](/reference/api/actions/update-workflow/) | | |
| 227 | − | | `list_actions_secrets` | `repo` or `workspace` | Secrets' rows: key, environments, who reads them. Never values. | [`GET /repos/{owner}/{name}/actions/secrets`, `GET /workspaces/{workspace}/actions/secrets`](/reference/api/secrets-and-variables/list-actions-secrets/) | | |
| 228 | − | | `set_actions_secret` | `setting` | Add or change a secret's row: `value`, and optionally `id`, `environments`, `available_to`, `repositories`, `note`. | [`PUT …/actions/secrets/{name}`](/reference/api/secrets-and-variables/set-actions-secret/) | | |
| 229 | − | | `delete_actions_secret` | `setting` | Remove one row (`id`) or every row of the key. | [`DELETE …/actions/secrets/{name}`](/reference/api/secrets-and-variables/delete-actions-secret/) | | |
| 230 | − | | `list_actions_variables` | `repo` or `workspace` | Config rows with their values. | [`GET …/actions/variables`](/reference/api/secrets-and-variables/list-actions-variables/) | | |
| 231 | − | | `set_actions_variable` | `setting` | Add or change a config row, as for secrets. | [`POST …/actions/variables`, `PATCH …/variables/{name}`](/reference/api/secrets-and-variables/set-actions-variable/) | | |
| 232 | − | | `delete_actions_variable` | `setting` | Remove one row (`id`) or every row of the key. | [`DELETE …/actions/variables/{name}`](/reference/api/secrets-and-variables/delete-actions-variable/) | | |
| 422 | + | | [`create`](/reference/api/workspaces/create-workspace/) | Create a workspace. | `slug` | `workspace:admin` | | |
| 423 | + | | [`delete`](/reference/api/workspaces/delete-workspace/) | Delete an empty workspace whose billing is settled; `confirm` is its slug. Owners only. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | `workspace`, `confirm` | `workspace:admin` | | |
| 424 | + | | [`list_invites`](/reference/api/invites/list-workspace-invites/) | A workspace's invites. Owners only. | `workspace` | `workspace:read` | | |
| 425 | + | | [`invite_member`](/reference/api/invites/invite-member/) | Invite an address into a workspace, with an invite bound to it. Owners only. | `workspace`, `email` | `workspace:admin` | | |
| 426 | + | | [`revoke_invite`](/reference/api/invites/revoke-workspace-invite/) | Revoke a workspace's pending invite. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 427 | + | | [`list_integrations`](/reference/api/integrations/list-integrations/) | The workspace's connections. Secrets are never returned. Members only. | `workspace` | `workspace:read` | | |
| 428 | + | | [`connect_integration`](/reference/api/integrations/connect-integration/) | Connect a model provider (Anthropic, OpenAI, Gemini, or a compatible endpoint), Sentry, Datadog, a webhook, Jira or Linear, with `config` and `secret`. Owners only. | `workspace`, `provider` | `workspace:admin` | | |
| 429 | + | | [`disconnect_integration`](/reference/api/integrations/disconnect-integration/) | Remove it and its secrets. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 430 | + | | [`test_integration`](/reference/api/integrations/test-integration/) | Check its credentials against the system it connects to. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 431 | + | | [`get_model_routes`](/reference/api/integrations/get-model-routes/) | Which provider and model each kind of work goes to. Members only. | `workspace` | `workspace:read` | | |
| 432 | + | | [`set_model_routes`](/reference/api/integrations/set-model-routes/) | Replace them: each route has `task`, `connection_id` (null for g1t's models) and `model`. Owners only. | `workspace`, `routes` | `workspace:admin` | | |
| 233 | 433 | ||
| 234 | − | ## Messages | |
| 434 | + | ## `account` | |
| 235 | 435 | ||
| 236 | − | | Tool | Required | What it does | Route | | |
| 436 | + | Who the token acts as and its workspaces, your email addresses, your | |
| 437 | + | invites while g1t is [invite-only](/guides/authentication/#invites), and | |
| 438 | + | invitations to repositories waiting for you. `whoami` is the default | |
| 439 | + | action, and needs no scope. An agent's token and a workspace's token cannot | |
| 440 | + | use the email and invite actions. | |
| 441 | + | ||
| 442 | + | | Action | What it does | Required | Scope | | |
| 237 | 443 | | --- | --- | --- | --- | | |
| 238 | − | | `message_agent` | `repo`, `number`, `body` | Send the agent working on a pull request a message, received at its next step. A g1t agent sends a `question` or a `handoff`, with its own pull request as `from_number`. | [`POST /repos/{owner}/{name}/pulls/{number}/messages`](/reference/api/pull-requests/message-agent/) | | |
| 239 | − | | `answer_message` | `repo`, `id`, `body` | Answer a question or a handoff by the message's id; `decline` a handoff that is not yours. The answer reaches the asking agent at its next step. | [`POST /repos/{owner}/{name}/messages/{id}/answer`](/reference/api/pull-requests/answer-message/) | | |
| 240 | − | | `take_messages` | `repo`, `number` | For a g1t agent at work: the messages it has not seen yet, each returned once. | [`POST /repos/{owner}/{name}/pulls/{number}/messages/take`](/reference/api/pull-requests/take-messages/) | | |
| 444 | + | | [`whoami`](/reference/api/accounts/whoami/) | Who the access token acts as, and the workspaces it can work in. `kind` is `user`, `workspace` or `agent`. | None | None | | |
| 445 | + | | [`list_emails`](/reference/api/accounts/list-emails/) | Your email addresses and email settings. People only. | None | `account:read` | | |
| 446 | + | | [`add_email`](/reference/api/accounts/add-email/) | Add an address; g1t emails it a link to confirm it. | `email`, `password` | `account:write` | | |
| 447 | + | | [`remove_email`](/reference/api/accounts/remove-email/) | Remove an address; never the primary or the last confirmed one. | `email`, `password` | `account:write` | | |
| 448 | + | | [`update_email_settings`](/reference/api/accounts/update-email-settings/) | Change `primary` or `backup` (with `password`), `private_email` or `block_private_pushes`. See [email addresses](/guides/authentication/#email-addresses). | None | `account:write` | | |
| 449 | + | | [`list_invites`](/reference/api/invites/list-invites/) | Your invites, newest first, and how many you have left. | None | `account:read` | | |
| 450 | + | | [`create_invite`](/reference/api/invites/create-invite/) | Make an invite; with `email`, only that address can use it and it is emailed there. With `workspace`, use that workspace's granted invites. | None | `account:write` | | |
| 451 | + | | [`revoke_invite`](/reference/api/invites/revoke-invite/) | Revoke a pending invite; it comes back to whoever it was charged to. | `id` | `account:write` | | |
| 452 | + | | [`list_repository_invitations`](/reference/api/access/list-my-repo-invitations/) | The invitations to repositories waiting for your answer. | None | `account:read` | | |
| 453 | + | | [`accept_repository_invitation`](/reference/api/access/accept-repo-invitation/) | Accept one; its role is yours at once. | `id` | `account:write` | | |
| 454 | + | | [`decline_repository_invitation`](/reference/api/access/decline-repo-invitation/) | Decline one. | `id` | `account:write` | | |
| 241 | 455 | ||
| 242 | − | See [talk to agents](/guides/talking-to-agents/). | |
| 243 | 456 | ||
| 244 | 457 | ## What a g1t agent can use | |
| 245 | 458 | ||
| 247 | 460 | a token bound to its run and its own repository, acting as `g1t-agent` on | |
| 248 | 461 | behalf of the person who started the work, and only while that person is | |
| 249 | 462 | still a member of the workspace or has a role on one of its repositories. It | |
| 250 | − | has that person's role on its repository, but never more than Write. Which tools it may use depends on the kind | |
| 251 | − | of run. | |
| 463 | + | has that person's role on its repository, but never more than Write. Which | |
| 464 | + | actions it may use depends on the kind of run. | |
| 252 | 465 | ||
| 253 | − | | Run | Tools | | |
| 466 | + | | Run | Actions | | |
| 254 | 467 | | --- | --- | | |
| 255 | − | | Implement, revise, answer | `get_repo`, `list_issues`, `get_issue`, `list_labels`, `list_pull_requests`, `get_pull_request`, `get_pull_request_changes`, `read_session`, `get_merge_queue`, `list_events`, `recall`, `search_context`, `get_entity`, `search`, `list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, and `create_issue`, `add_comment`, `take_messages`, `remember`, `message_agent`, `answer_message`, `get_context` | | |
| 256 | − | | Review | The same reading tools, and `add_comment`, `review_pull_request`, `get_context` | | |
| 257 | − | | Plan | The same reading tools, and `create_issue`, `get_context` | | |
| 258 | − | | Catch up | The reading tools only | | |
| 468 | + | | Implement, revise, answer | Reading: `repository` `get`, `list_labels` and `list_events`; `issue` `list` and `get`; `pull_request` `list`, `get`, `changes`, `read_session` and `merge_queue`; `memory` `recall`; `search` `code`, `context` and `entity`; `workflow` `list`, `list_runs`, `get_run` and `job_logs`. Then `issue` `create` and `comment`, `memory` `remember`, `agent` `message`, `answer` and `take_messages`, and `search` `ticket`. | | |
| 469 | + | | Review | The same reading actions, and `issue` `comment`, `pull_request` `review` and `search` `ticket`. | | |
| 470 | + | | Plan | The same reading actions, and `issue` `create` and `search` `ticket`. | | |
| 471 | + | | Catch up | The reading actions only. | | |
| 259 | 472 | ||
| 260 | − | No agent's token can use the tools for settings, members, tokens, billing, | |
| 261 | − | integrations, webhooks, secrets and variables, or workflows' controls, nor | |
| 262 | − | `merge_pull_request`, `assign_issue`, `plan_work`, `apply_plan`, | |
| 263 | − | `import_issue`, `create_repo`, `update_repo`, `rename_repo`, | |
| 264 | − | `rename_branch`, `set_repo_visibility`, `archive_repo`, `unarchive_repo`, | |
| 265 | − | `transfer_repo`, `delete_repo`, `list_deleted_repos`, `restore_repo`, | |
| 266 | − | `purge_repo`, `create_workspace` or `delete_workspace`, nor any of the | |
| 267 | − | [access](#access) tools. Every repository it | |
| 268 | − | names must be its own. `tools/list` shows such a token only the tools it | |
| 269 | − | may use; a call to any other is refused with the rule that refused it, and | |
| 270 | − | recorded in the workspace's [audit log](/guides/audit-log/), as is every | |
| 271 | − | call it makes. | |
| 473 | + | No agent's token can use the `workspace`, `access`, `secret` or `webhook` | |
| 474 | + | tools, the controls of `workflow`, or `pull_request` `merge`, `agent` | |
| 475 | + | `assign` and `delegate`, `plan` `create` and `apply`, `issue` `import`, or | |
| 476 | + | any `repository` action that creates, changes, renames, archives, | |
| 477 | + | transfers, deletes, restores or purges a repository. Every repository it | |
| 478 | + | names must be its own. `tools/list` shows such a token only the tools and | |
| 479 | + | actions it may use; a call to any other is refused with the rule that | |
| 480 | + | refused it, and recorded in the workspace's [audit log](/guides/audit-log/), | |
| 481 | + | as is every call it makes. |
Binary or large file; its contents are not shown.
| 1 | + | import { ArrowUpRight, ChevronDown, LoaderCircle, Sparkles } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 3 | + | import { Link, useFetcher } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { RepoPath } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { cn } from "../lib/cn"; | |
| 8 | + | import type { NotStarted } from "../lib/delegate"; | |
| 9 | + | import { Avatar } from "./ui"; | |
| 10 | + | import { CONTROL } from "./ui/input"; | |
| 11 | + | ||
| 12 | + | /** What the composer's action said back: why nothing opened, or the issue that opened without its agent. */ | |
| 13 | + | export type ComposerResult = { error: string | null; notStarted: NotStarted | null } | null; | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * "Put an agent on it": a compact composer that opens an issue in one of | |
| 17 | + | * the viewer's projects and puts g1t-agent on it, in one step. Posts to | |
| 18 | + | * Mission control's action, which lands on the issue with the agent | |
| 19 | + | * running. A native `<details>`, so it opens and posts without script. | |
| 20 | + | */ | |
| 21 | + | export function AgentComposer({ | |
| 22 | + | repos, | |
| 23 | + | open: openAtFirst, | |
| 24 | + | result, | |
| 25 | + | note, | |
| 26 | + | action = "/?index", | |
| 27 | + | children, | |
| 28 | + | }: { | |
| 29 | + | repos: RepoPath[]; | |
| 30 | + | /** Open from the start: asked for by the address, or a post came back. */ | |
| 31 | + | open: boolean; | |
| 32 | + | /** What a post without script came back with. */ | |
| 33 | + | result: ComposerResult; | |
| 34 | + | /** Said under the form, such as that agents need a model first. */ | |
| 35 | + | note?: ReactNode; | |
| 36 | + | action?: string; | |
| 37 | + | /** The button that opens it. */ | |
| 38 | + | children: ReactNode; | |
| 39 | + | }) { | |
| 40 | + | const fetcher = useFetcher<ComposerResult>(); | |
| 41 | + | const ref = useRef<HTMLDetailsElement>(null); | |
| 42 | + | const [open, setOpen] = useState(openAtFirst); | |
| 43 | + | const busy = fetcher.state !== "idle"; | |
| 44 | + | const said = fetcher.data ?? result; | |
| 45 | + | ||
| 46 | + | // Closed by Escape or by a click outside it, as a menu is. | |
| 47 | + | useEffect(() => { | |
| 48 | + | if (!open) return; | |
| 49 | + | const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false); | |
| 50 | + | const onClick = (event: MouseEvent) => { | |
| 51 | + | if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); | |
| 52 | + | }; | |
| 53 | + | document.addEventListener("keydown", onKey); | |
| 54 | + | document.addEventListener("mousedown", onClick); | |
| 55 | + | return () => { | |
| 56 | + | document.removeEventListener("keydown", onKey); | |
| 57 | + | document.removeEventListener("mousedown", onClick); | |
| 58 | + | }; | |
| 59 | + | }, [open]); | |
| 60 | + | ||
| 61 | + | return ( | |
| 62 | + | <details | |
| 63 | + | ref={ref} | |
| 64 | + | open={open} | |
| 65 | + | onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)} | |
| 66 | + | className="group/composer relative" | |
| 67 | + | > | |
| 68 | + | <summary className="list-none [&::-webkit-details-marker]:hidden">{children}</summary> | |
| 69 | + | <div className="fixed inset-x-4 top-20 z-40 sm:absolute sm:inset-x-auto sm:top-full sm:left-0 sm:mt-2 sm:w-[28rem]"> | |
| 70 | + | <fetcher.Form | |
| 71 | + | method="post" | |
| 72 | + | action={action} | |
| 73 | + | className="rounded-xl border border-line-strong bg-raised p-4 shadow-2xl shadow-black/50" | |
| 74 | + | aria-label="Put an agent on it" | |
| 75 | + | > | |
| 76 | + | <input type="hidden" name="intent" value="delegate" /> | |
| 77 | + | <p className="flex items-center gap-2 text-sm font-semibold"> | |
| 78 | + | <Sparkles size={15} className="text-merged" /> | |
| 79 | + | Put an agent on it | |
| 80 | + | </p> | |
| 81 | + | <p className="mt-1 text-xs leading-5 text-muted"> | |
| 82 | + | Opens an issue and assigns g1t-agent at once. It makes the change in a sandbox and sees it through checks | |
| 83 | + | and review. | |
| 84 | + | </p> | |
| 85 | + | ||
| 86 | + | <div className="mt-4 space-y-3"> | |
| 87 | + | <label className="block"> | |
| 88 | + | <span className="mb-1.5 block text-xs font-medium text-muted">Project</span> | |
| 89 | + | <span className="relative block"> | |
| 90 | + | <select name="repo" required className={cn(CONTROL, "h-9 appearance-none pr-8")} defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : ""}> | |
| 91 | + | {repos.map((repo) => ( | |
| 92 | + | <option key={`${repo.namespace}/${repo.name}`} value={`${repo.namespace}/${repo.name}`}> | |
| 93 | + | {repo.name} | |
| 94 | + | </option> | |
| 95 | + | ))} | |
| 96 | + | </select> | |
| 97 | + | <ChevronDown size={14} className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-faint" /> | |
| 98 | + | </span> | |
| 99 | + | </label> | |
| 100 | + | <label className="block"> | |
| 101 | + | <span className="mb-1.5 block text-xs font-medium text-muted">Title</span> | |
| 102 | + | <input | |
| 103 | + | name="title" | |
| 104 | + | required | |
| 105 | + | maxLength={200} | |
| 106 | + | autoComplete="off" | |
| 107 | + | data-1p-ignore | |
| 108 | + | placeholder="Retry failed webhook deliveries" | |
| 109 | + | className={cn(CONTROL, "h-9")} | |
| 110 | + | /> | |
| 111 | + | </label> | |
| 112 | + | <label className="block"> | |
| 113 | + | <span className="mb-1.5 block text-xs font-medium text-muted">What you want done</span> | |
| 114 | + | <textarea | |
| 115 | + | name="body" | |
| 116 | + | rows={4} | |
| 117 | + | placeholder="In plain words: what is wrong or wanted, and anything the agent cannot see for itself." | |
| 118 | + | className={cn(CONTROL, "min-h-24 resize-y leading-relaxed")} | |
| 119 | + | /> | |
| 120 | + | </label> | |
| 121 | + | <details className="group/checks"> | |
| 122 | + | <summary className="cursor-pointer list-none text-xs font-medium text-muted hover:text-fg [&::-webkit-details-marker]:hidden"> | |
| 123 | + | <ChevronDown size={13} className="mr-1 inline -rotate-90 transition-transform group-open/checks:rotate-0" /> | |
| 124 | + | Acceptance checks <span className="font-normal text-faint">(optional)</span> | |
| 125 | + | </summary> | |
| 126 | + | <textarea | |
| 127 | + | name="checks" | |
| 128 | + | rows={2} | |
| 129 | + | placeholder="npm test" | |
| 130 | + | aria-label="Acceptance checks, one command per line" | |
| 131 | + | className={cn(CONTROL, "mt-2 min-h-16 resize-y font-mono text-xs")} | |
| 132 | + | /> | |
| 133 | + | <span className="mt-1 block text-xs text-faint">One command per line. Its pull request must make them all pass.</span> | |
| 134 | + | </details> | |
| 135 | + | </div> | |
| 136 | + | ||
| 137 | + | {said?.error && <p className="mt-3 text-sm text-danger">{said.error}</p>} | |
| 138 | + | {said?.notStarted && ( | |
| 139 | + | <div className="mt-3 rounded-lg border border-warn/30 bg-warn/[0.06] p-3 text-sm"> | |
| 140 | + | <p className="text-fg-soft"> | |
| 141 | + | Opened <Link to={said.notStarted.to} className="font-medium text-fg hover:underline">#{said.notStarted.number}</Link>, but | |
| 142 | + | g1t-agent did not start. {said.notStarted.message} | |
| 143 | + | </p> | |
| 144 | + | <p className="mt-2 flex flex-wrap gap-2"> | |
| 145 | + | {said.notStarted.fix && ( | |
| 146 | + | <Link | |
| 147 | + | to={said.notStarted.fix.to} | |
| 148 | + | className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-white" | |
| 149 | + | > | |
| 150 | + | {said.notStarted.fix.label} <ArrowUpRight size={12} /> | |
| 151 | + | </Link> | |
| 152 | + | )} | |
| 153 | + | <Link to={said.notStarted.to} className="inline-flex items-center rounded-md border border-line-strong px-2.5 py-1 text-xs font-medium text-fg/90 hover:bg-bg"> | |
| 154 | + | Open the issue | |
| 155 | + | </Link> | |
| 156 | + | </p> | |
| 157 | + | </div> | |
| 158 | + | )} | |
| 159 | + | {note && <p className="mt-3 text-xs leading-5 text-muted">{note}</p>} | |
| 160 | + | ||
| 161 | + | <div className="mt-4 flex items-center justify-between gap-3"> | |
| 162 | + | <span className="flex min-w-0 items-center gap-1.5 text-xs text-faint"> | |
| 163 | + | <Avatar name="g1t-agent" size={16} /> | |
| 164 | + | <span className="truncate">g1t-agent · no model to choose</span> | |
| 165 | + | </span> | |
| 166 | + | <button | |
| 167 | + | type="submit" | |
| 168 | + | disabled={busy || repos.length === 0} | |
| 169 | + | className="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-merged px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-[#c9bfff] disabled:opacity-60" | |
| 170 | + | > | |
| 171 | + | {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />} | |
| 172 | + | {busy ? "Starting…" : "Put an agent on it"} | |
| 173 | + | </button> | |
| 174 | + | </div> | |
| 175 | + | </fetcher.Form> | |
| 176 | + | </div> | |
| 177 | + | </details> | |
| 178 | + | ); | |
| 179 | + | } |
| 4 | 4 | * list. Stopping and messaging a run go through the project's | |
| 5 | 5 | * `agents.json` resource route, so every place behaves the same. | |
| 6 | 6 | */ | |
| 7 | − | import { Bot, CircleSlash, Clock, Coins, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react"; | |
| 7 | + | import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react"; | |
| 8 | 8 | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 9 | 9 | import { Link, useFetcher, useRevalidator } from "react-router"; | |
| 10 | 10 | ||
| 11 | 11 | import { | |
| 12 | 12 | type AgentRun, | |
| 13 | 13 | type AgentRunStatus, | |
| 14 | + | type Confidence, | |
| 14 | 15 | type RunKind, | |
| 15 | 16 | RUN_KIND_LABEL, | |
| 16 | 17 | type Stage, | |
| 328 | 329 | return fetcher.data ?? null; | |
| 329 | 330 | } | |
| 330 | 331 | ||
| 332 | + | /** "Agent confidence: Low — tests not added, 3 revisions", and what the agent said it was unsure of. */ | |
| 333 | + | export function ConfidenceLine({ confidence }: { confidence: Confidence }) { | |
| 334 | + | const tone = confidence.level === "low" ? "text-danger" : confidence.level === "medium" ? "text-warn" : "text-accent"; | |
| 335 | + | const level = { low: "Low", medium: "Medium", high: "High" }[confidence.level]; | |
| 336 | + | return ( | |
| 337 | + | <div className="mt-3 text-xs leading-5"> | |
| 338 | + | <p className="flex items-start gap-2"> | |
| 339 | + | <Gauge size={13} className={`mt-1 shrink-0 ${tone}`} /> | |
| 340 | + | <span className="min-w-0"> | |
| 341 | + | <span className="text-muted">Agent confidence: </span> | |
| 342 | + | <span className={`font-medium ${tone}`}>{level}</span> | |
| 343 | + | {confidence.reasons.length > 0 && <span className="text-fg-soft"> — {confidence.reasons.join(", ")}</span>} | |
| 344 | + | </span> | |
| 345 | + | </p> | |
| 346 | + | {confidence.uncertainAbout.length > 0 && ( | |
| 347 | + | <p className="mt-0.5 pl-[1.3125rem] text-muted">Unsure about: {confidence.uncertainAbout.join("; ")}</p> | |
| 348 | + | )} | |
| 349 | + | </div> | |
| 350 | + | ); | |
| 351 | + | } | |
| 352 | + | ||
| 331 | 353 | /** | |
| 332 | 354 | * The agent on a pull request, near the top of its page: who is working | |
| 333 | 355 | * on it, at what stage, what it is doing this minute, for how long and at | |
| 338 | 360 | repo, | |
| 339 | 361 | number, | |
| 340 | 362 | stage, | |
| 363 | + | confidence, | |
| 341 | 364 | }: { | |
| 342 | 365 | owner: string; | |
| 343 | 366 | repo: string; | |
| 344 | 367 | number: number; | |
| 345 | 368 | stage?: Stage | null; | |
| 369 | + | /** How sure g1t is of the change, once the agent has finished it. */ | |
| 370 | + | confidence?: Confidence | null; | |
| 346 | 371 | }) { | |
| 347 | 372 | const data = useRuns(owner, repo, { number: String(number), limit: "5" }); | |
| 348 | 373 | const runs = data?.runs ?? []; | |
| 388 | 413 | {current.step} | |
| 389 | 414 | </p> | |
| 390 | 415 | )} | |
| 416 | + | {confidence && <ConfidenceLine confidence={confidence} />} | |
| 391 | 417 | <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted"> | |
| 392 | 418 | <span className="flex items-center gap-1"> | |
| 393 | 419 | <Clock size={12} /> |
| 1 | − | import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, LoaderCircle, Plus } from "lucide-react"; | |
| 1 | + | import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, LoaderCircle, Plus, Sparkles } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 27 | 27 | whyFor, | |
| 28 | 28 | } from "../lib/mission-control"; | |
| 29 | 29 | import { cn } from "../lib/cn"; | |
| 30 | + | import { AgentComposer, type ComposerResult } from "./agent-composer"; | |
| 30 | 31 | import { AgentSetup } from "./agent-setup"; | |
| 31 | 32 | import type { ShellData } from "./shell"; | |
| 32 | 33 | import { useLiveRefresh } from "./agents"; | |
| 57 | 58 | blocking: "border-danger/35 bg-danger/10 text-danger", | |
| 58 | 59 | checks_failing: "border-danger/35 bg-danger/10 text-danger", | |
| 59 | 60 | outside_guardrails: "border-warn/35 bg-warn/10 text-warn", | |
| 61 | + | low_confidence: "border-warn/35 bg-warn/10 text-warn", | |
| 60 | 62 | stalled: "border-warn/35 bg-warn/10 text-warn", | |
| 61 | 63 | asked_for_you: "border-merged/35 bg-merged/10 text-merged", | |
| 62 | 64 | needs_review: "border-info/35 bg-info/10 text-info", | |
| 97 | 99 | return ( | |
| 98 | 100 | <dl className="grid grid-cols-2 gap-x-4 gap-y-2.5"> | |
| 99 | 101 | {facts.map((fact) => ( | |
| 100 | − | <div key={fact.label} className="min-w-0"> | |
| 102 | + | <div key={fact.label} className={cn("min-w-0", fact.wide && "col-span-2")}> | |
| 101 | 103 | <dt className="text-xs text-faint">{fact.label}</dt> | |
| 102 | − | <dd className={cn("mt-0.5 truncate text-sm font-medium tabular-nums", fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft")}> | |
| 104 | + | <dd | |
| 105 | + | className={cn( | |
| 106 | + | "mt-0.5 text-sm font-medium tabular-nums", | |
| 107 | + | fact.wide ? "leading-5" : "truncate", | |
| 108 | + | fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft", | |
| 109 | + | )} | |
| 110 | + | > | |
| 103 | 111 | {fact.value} | |
| 104 | 112 | </dd> | |
| 105 | 113 | </div> | |
| 640 | 648 | const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting", landed: "Today" }; | |
| 641 | 649 | const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" }; | |
| 642 | 650 | ||
| 643 | − | export default function MissionControl({ loaderData }: { loaderData: Loaded }) { | |
| 651 | + | export default function MissionControl({ loaderData, delegated = null }: { loaderData: Loaded; delegated?: ComposerResult }) { | |
| 644 | 652 | const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined; | |
| 645 | 653 | const loaded: Loaded = loaderData; | |
| 646 | 654 | const [params] = useSearchParams(); | |
| 774 | 782 | const delta = change(week.total, week.previous); | |
| 775 | 783 | const feed = everyActivity ? groups : groups.slice(0, 8); | |
| 776 | 784 | ||
| 785 | + | // "Put an agent on it" first, with "New issue" beside it as before: one | |
| 786 | + | // split control, the agent the main way in. | |
| 777 | 787 | const newIssue = | |
| 778 | 788 | repos.length > 0 ? ( | |
| 789 | + | <div className="flex items-stretch"> | |
| 790 | + | <AgentComposer | |
| 791 | + | repos={repos} | |
| 792 | + | open={params.get("agent") === "new" || delegated != null} | |
| 793 | + | result={delegated} | |
| 794 | + | note={ | |
| 795 | + | canRunAgents ? null : ( | |
| 796 | + | <> | |
| 797 | + | Agents need a model first.{" "} | |
| 798 | + | {workspace && ( | |
| 799 | + | <Link to={`/${workspace}/-/integrations`} className="font-medium text-fg hover:underline"> | |
| 800 | + | Connect one | |
| 801 | + | </Link> | |
| 802 | + | )} | |
| 803 | + | . The issue still opens. | |
| 804 | + | </> | |
| 805 | + | ) | |
| 806 | + | } | |
| 807 | + | > | |
| 808 | + | <span className="inline-flex cursor-pointer items-center gap-1.5 rounded-l-md border border-line-strong bg-raised px-3 py-2 text-sm font-medium text-fg transition-colors hover:bg-line/60 group-open/composer:bg-line/60"> | |
| 809 | + | <Sparkles size={14} className="text-merged" /> Put an agent on it | |
| 810 | + | </span> | |
| 811 | + | </AgentComposer> | |
| 779 | 812 | <DropdownMenu> | |
| 780 | − | <DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg"> | |
| 813 | + | <DropdownMenuTrigger className="-ml-px inline-flex items-center gap-1.5 rounded-r-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg"> | |
| 781 | 814 | <Plus size={14} /> New issue | |
| 782 | 815 | </DropdownMenuTrigger> | |
| 783 | 816 | <DropdownMenuContent align="end" className="max-h-80 overflow-y-auto"> | |
| 792 | 825 | ))} | |
| 793 | 826 | </DropdownMenuContent> | |
| 794 | 827 | </DropdownMenu> | |
| 828 | + | </div> | |
| 795 | 829 | ) : ( | |
| 796 | 830 | <Link | |
| 797 | 831 | to={workspace ? `/new?workspace=${workspace}` : "/new"} |
| 1324 | 1324 | if (!user) return visitorCommands(shell, here, signUpLabel); | |
| 1325 | 1325 | const commands: Command[] = [ | |
| 1326 | 1326 | { label: "Mission control", to: "/", icon: <House size={15} /> }, | |
| 1327 | + | ...(shell.repos.length > 0 | |
| 1328 | + | ? [{ label: "Put an agent on it", hint: "Open an issue and assign g1t-agent", to: "/?agent=new", icon: <Sparkles size={15} /> }] | |
| 1329 | + | : []), | |
| 1327 | 1330 | { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> }, | |
| 1328 | 1331 | { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> }, | |
| 1329 | 1332 | { label: "New project", to: "/new", icon: <Plus size={15} /> }, | |
| 1342 | 1345 | { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> }, | |
| 1343 | 1346 | { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> }, | |
| 1344 | 1347 | { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> }, | |
| 1348 | + | ...(repo.member | |
| 1349 | + | ? [{ label: `Put an agent on ${repo.name}`, hint: name, to: `${base}/issues/new?agent=1`, icon: <Sparkles size={15} /> }] | |
| 1350 | + | : []), | |
| 1345 | 1351 | { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> }, | |
| 1346 | 1352 | { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> }, | |
| 1347 | 1353 | ...(repo.member | |
| 1372 | 1378 | icon: <Box size={15} />, | |
| 1373 | 1379 | }); | |
| 1374 | 1380 | } | |
| 1381 | + | // "Put an agent on …": one per project, after the projects themselves. | |
| 1382 | + | for (const listed of shell.repos) { | |
| 1383 | + | if (repo && listed.namespace === repo.namespace && listed.name === repo.name) continue; | |
| 1384 | + | commands.push({ | |
| 1385 | + | label: `Put an agent on ${listed.title ?? listed.name}`, | |
| 1386 | + | hint: `${listed.namespace}/${listed.name}`, | |
| 1387 | + | to: `/${listed.namespace}/${listed.name}/issues/new?agent=1`, | |
| 1388 | + | icon: <Sparkles size={15} />, | |
| 1389 | + | }); | |
| 1390 | + | } | |
| 1375 | 1391 | return commands; | |
| 1376 | 1392 | } | |
| 1377 | 1393 |
| 1 | + | import { ShieldAlert, TriangleAlert } from "lucide-react"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | DANGEROUS_SCOPES, | |
| 6 | + | PRESETS, | |
| 7 | + | SCOPE_GROUPS, | |
| 8 | + | describeScope, | |
| 9 | + | isDangerous, | |
| 10 | + | levelsOf, | |
| 11 | + | presetScopes, | |
| 12 | + | scopeLevel, | |
| 13 | + | scopeResource, | |
| 14 | + | type PresetId, | |
| 15 | + | type Scope, | |
| 16 | + | } from "@g1t/contracts"; | |
| 17 | + | ||
| 18 | + | import { cn } from "../lib/cn"; | |
| 19 | + | import { | |
| 20 | + | DEFAULT_EXPIRY, | |
| 21 | + | EXPIRY_CHOICES, | |
| 22 | + | accessSummary, | |
| 23 | + | everyScope, | |
| 24 | + | impliedBy, | |
| 25 | + | matchingPreset, | |
| 26 | + | normalizeScopes, | |
| 27 | + | } from "../lib/token-scopes"; | |
| 28 | + | import { Badge } from "./ui/badge"; | |
| 29 | + | import { CONTROL } from "./ui/input"; | |
| 30 | + | ||
| 31 | + | // Choosing what a token or an application may do: a classic checklist. | |
| 32 | + | // A token reaches whatever its owner can; the boxes say what it may do | |
| 33 | + | // there. Every box is a plain form field (`scope`), so the form posts the | |
| 34 | + | // same with or without JavaScript; the script applies presets and ticks the | |
| 35 | + | // lower levels a higher one includes. `lib/token-scopes.ts` reads it back. | |
| 36 | + | ||
| 37 | + | /** One box. Greyed out and ticked when a higher level of its resource is ticked. */ | |
| 38 | + | function ScopeBox({ | |
| 39 | + | scope, | |
| 40 | + | ticked, | |
| 41 | + | onToggle, | |
| 42 | + | }: { | |
| 43 | + | scope: Scope; | |
| 44 | + | ticked: readonly Scope[]; | |
| 45 | + | onToggle: (scope: Scope, on: boolean) => void; | |
| 46 | + | }) { | |
| 47 | + | const by = impliedBy(ticked, scope); | |
| 48 | + | const checked = by !== null || ticked.includes(scope); | |
| 49 | + | return ( | |
| 50 | + | <label | |
| 51 | + | className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")} | |
| 52 | + | title={by ? `Included in ${by}` : undefined} | |
| 53 | + | > | |
| 54 | + | <input | |
| 55 | + | type="checkbox" | |
| 56 | + | name="scope" | |
| 57 | + | value={scope} | |
| 58 | + | checked={checked} | |
| 59 | + | disabled={by !== null} | |
| 60 | + | onChange={(event) => onToggle(scope, event.target.checked)} | |
| 61 | + | className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")} | |
| 62 | + | /> | |
| 63 | + | <span className="min-w-0"> | |
| 64 | + | <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}> | |
| 65 | + | {scope} | |
| 66 | + | </span> | |
| 67 | + | <span className="block text-xs leading-snug text-faint">{describeScope(scope)}</span> | |
| 68 | + | </span> | |
| 69 | + | </label> | |
| 70 | + | ); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** | |
| 74 | + | * The scope checklist: presets as quick buttons, then a box per scope, | |
| 75 | + | * grouped by area, with admin scopes under "Dangerous". Posts `scope` for | |
| 76 | + | * each ticked box and `preset` = `full` for full access. | |
| 77 | + | * | |
| 78 | + | * With `only`, it is the consent page: just the scopes an application | |
| 79 | + | * asked for, all ticked, to untick; nothing can be added. | |
| 80 | + | */ | |
| 81 | + | export function ScopeChecklist({ | |
| 82 | + | initial, | |
| 83 | + | allowFull = true, | |
| 84 | + | only, | |
| 85 | + | }: { | |
| 86 | + | /** Null: full access. */ | |
| 87 | + | initial: readonly string[] | null; | |
| 88 | + | allowFull?: boolean; | |
| 89 | + | only?: readonly Scope[]; | |
| 90 | + | }) { | |
| 91 | + | const [full, setFull] = useState(allowFull && !only && initial === null); | |
| 92 | + | const [ticked, setTicked] = useState<Scope[]>(() => (initial === null ? everyScope() : normalizeScopes(initial))); | |
| 93 | + | const shown = (scope: Scope) => !only || only.includes(scope); | |
| 94 | + | const groups = SCOPE_GROUPS.map((group) => ({ ...group, scopes: group.scopes.filter(shown) })).filter( | |
| 95 | + | (group) => group.scopes.length > 0, | |
| 96 | + | ); | |
| 97 | + | const dangerous = DANGEROUS_SCOPES.filter(shown); | |
| 98 | + | const preset: PresetId | null = full ? "full" : matchingPreset(ticked); | |
| 99 | + | const count = full ? null : normalizeScopes(ticked).length; | |
| 100 | + | ||
| 101 | + | const choosePreset = (id: PresetId) => { | |
| 102 | + | const scopes = presetScopes(id); | |
| 103 | + | setFull(scopes === null); | |
| 104 | + | setTicked(scopes === null ? everyScope() : normalizeScopes(scopes)); | |
| 105 | + | }; | |
| 106 | + | // Ticking a level includes the lower ones; unticking one leaves the | |
| 107 | + | // level below it ticked, so only the box you touched changes. | |
| 108 | + | const toggle = (scope: Scope, on: boolean) => { | |
| 109 | + | setFull(false); | |
| 110 | + | setTicked((current) => { | |
| 111 | + | const resource = scopeResource(scope); | |
| 112 | + | if (on) return normalizeScopes([...current, scope]); | |
| 113 | + | const levels = levelsOf(resource); | |
| 114 | + | const below = levels[levels.indexOf(scopeLevel(scope)) - 1]; | |
| 115 | + | const rest = current.filter((held) => held !== scope); | |
| 116 | + | return normalizeScopes(below && shown(`${resource}:${below}` as Scope) ? [...rest, `${resource}:${below}`] : rest); | |
| 117 | + | }); | |
| 118 | + | }; | |
| 119 | + | ||
| 120 | + | return ( | |
| 121 | + | <fieldset className="min-w-0 space-y-3"> | |
| 122 | + | <legend className="sr-only">Scopes</legend> | |
| 123 | + | {full && <input type="hidden" name="preset" value="full" />} | |
| 124 | + | ||
| 125 | + | {!only && ( | |
| 126 | + | <div className="flex flex-wrap items-center gap-1.5"> | |
| 127 | + | <span className="mr-1 text-sm font-medium text-muted">Scopes</span> | |
| 128 | + | {PRESETS.filter((option) => allowFull || option.id !== "full").map((option) => ( | |
| 129 | + | <button | |
| 130 | + | key={option.id} | |
| 131 | + | type="button" | |
| 132 | + | aria-pressed={preset === option.id} | |
| 133 | + | title={option.description} | |
| 134 | + | onClick={() => choosePreset(option.id)} | |
| 135 | + | className={cn( | |
| 136 | + | "rounded-full border px-2.5 py-0.5 text-xs transition-colors", | |
| 137 | + | preset === option.id | |
| 138 | + | ? option.id === "full" | |
| 139 | + | ? "border-danger/50 bg-danger/10 text-danger" | |
| 140 | + | : "border-accent/50 bg-accent/10 text-accent" | |
| 141 | + | : "border-line text-muted hover:border-line-strong hover:text-fg", | |
| 142 | + | )} | |
| 143 | + | > | |
| 144 | + | {option.label} | |
| 145 | + | </button> | |
| 146 | + | ))} | |
| 147 | + | <span className="ml-auto text-xs text-faint"> | |
| 148 | + | {count === null ? "Everything you can do" : count === 1 ? "1 scope" : `${count} scopes`} | |
| 149 | + | </span> | |
| 150 | + | </div> | |
| 151 | + | )} | |
| 152 | + | ||
| 153 | + | {full && ( | |
| 154 | + | <p className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger"> | |
| 155 | + | <TriangleAlert size={14} className="mt-px shrink-0" /> | |
| 156 | + | Full access can do everything you can, including scopes added later. Untick anything to | |
| 157 | + | narrow it. | |
| 158 | + | </p> | |
| 159 | + | )} | |
| 160 | + | ||
| 161 | + | <div className="divide-y divide-line rounded-md border border-line"> | |
| 162 | + | {groups.map((group) => ( | |
| 163 | + | <div key={group.id} role="group" aria-labelledby={`scopes-${group.id}`} className="px-3 py-2.5 sm:px-4"> | |
| 164 | + | <p id={`scopes-${group.id}`} className="mb-1 text-xs font-medium text-muted"> | |
| 165 | + | {group.label} | |
| 166 | + | </p> | |
| 167 | + | <div className="grid gap-x-6 sm:grid-cols-2"> | |
| 168 | + | {group.scopes.map((scope) => ( | |
| 169 | + | <ScopeBox key={scope} scope={scope} ticked={ticked} onToggle={toggle} /> | |
| 170 | + | ))} | |
| 171 | + | </div> | |
| 172 | + | </div> | |
| 173 | + | ))} | |
| 174 | + | </div> | |
| 175 | + | ||
| 176 | + | {dangerous.length > 0 && ( | |
| 177 | + | <div role="group" aria-labelledby="scopes-dangerous" className="rounded-md border border-danger/30 px-3 py-2.5 sm:px-4"> | |
| 178 | + | <p id="scopes-dangerous" className="flex items-center gap-1.5 text-xs font-medium text-danger"> | |
| 179 | + | <ShieldAlert size={14} className="shrink-0" /> | |
| 180 | + | Dangerous | |
| 181 | + | </p> | |
| 182 | + | <p className="mt-0.5 mb-1 text-xs text-faint"> | |
| 183 | + | Hard to undo, or decides who can reach what. Tick these only for something you trust as | |
| 184 | + | much as yourself. | |
| 185 | + | </p> | |
| 186 | + | <div className="grid gap-x-6 sm:grid-cols-2"> | |
| 187 | + | {dangerous.map((scope) => ( | |
| 188 | + | <ScopeBox key={scope} scope={scope} ticked={ticked} onToggle={toggle} /> | |
| 189 | + | ))} | |
| 190 | + | </div> | |
| 191 | + | </div> | |
| 192 | + | )} | |
| 193 | + | </fieldset> | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /** When a new token stops working: 90 days unless chosen otherwise. Posts `expires`. */ | |
| 198 | + | export function ExpiryField({ id = "token-expires" }: { id?: string }) { | |
| 199 | + | const [value, setValue] = useState<string>(DEFAULT_EXPIRY); | |
| 200 | + | return ( | |
| 201 | + | <div className="flex flex-col gap-1.5"> | |
| 202 | + | <label htmlFor={id} className="text-sm font-medium text-muted"> | |
| 203 | + | Expires | |
| 204 | + | </label> | |
| 205 | + | <select | |
| 206 | + | id={id} | |
| 207 | + | name="expires" | |
| 208 | + | value={value} | |
| 209 | + | onChange={(event) => setValue(event.target.value)} | |
| 210 | + | className={CONTROL} | |
| 211 | + | > | |
| 212 | + | {EXPIRY_CHOICES.map((choice) => ( | |
| 213 | + | <option key={choice.value} value={choice.value}> | |
| 214 | + | {choice.label} | |
| 215 | + | </option> | |
| 216 | + | ))} | |
| 217 | + | </select> | |
| 218 | + | {value === "never" && ( | |
| 219 | + | <p className="flex items-start gap-1.5 text-xs text-warn"> | |
| 220 | + | <TriangleAlert size={13} className="mt-px shrink-0" /> | |
| 221 | + | It works until someone deletes it. Prefer an expiry. | |
| 222 | + | </p> | |
| 223 | + | )} | |
| 224 | + | </div> | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /** A token's or an application's access in a list: what it may do. */ | |
| 229 | + | export function AccessSummary({ | |
| 230 | + | holder, | |
| 231 | + | className, | |
| 232 | + | }: { | |
| 233 | + | holder: { scopes: readonly string[] | null; legacy: boolean }; | |
| 234 | + | className?: string; | |
| 235 | + | }) { | |
| 236 | + | const summary = accessSummary(holder); | |
| 237 | + | const preset = matchingPreset(holder.scopes); | |
| 238 | + | const scopes = holder.scopes && !preset ? normalizeScopes(holder.scopes) : []; | |
| 239 | + | const tone = holder.scopes === null ? (holder.legacy ? "warn" : "danger") : scopes.length === 0 && !preset ? "neutral" : "accent"; | |
| 240 | + | return ( | |
| 241 | + | <div className={cn("mt-1.5 flex flex-wrap items-center gap-1.5", className)}> | |
| 242 | + | <Badge tone={tone}>{summary}</Badge> | |
| 243 | + | {scopes.map((scope) => ( | |
| 244 | + | <span | |
| 245 | + | key={scope} | |
| 246 | + | title={describeScope(scope)} | |
| 247 | + | className={cn( | |
| 248 | + | "rounded border px-1.5 py-px font-mono text-[0.6875rem]", | |
| 249 | + | isDangerous(scope) ? "border-danger/40 text-danger" : "border-line text-muted", | |
| 250 | + | )} | |
| 251 | + | > | |
| 252 | + | {scope} | |
| 253 | + | </span> | |
| 254 | + | ))} | |
| 255 | + | </div> | |
| 256 | + | ); | |
| 257 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { chosenRepo, delegateForm, issuePath, notStarted } from "./delegate.ts"; | |
| 5 | + | ||
| 6 | + | const repos = [ | |
| 7 | + | { namespace: "acme", name: "web" }, | |
| 8 | + | { namespace: "acme", name: "api" }, | |
| 9 | + | ]; | |
| 10 | + | ||
| 11 | + | test("a form becomes what to put the agent on", () => { | |
| 12 | + | const form = new FormData(); | |
| 13 | + | form.set("title", " Retry webhooks with backoff "); | |
| 14 | + | form.set("body", " Failed deliveries are dropped. "); | |
| 15 | + | form.set("checks", "npm test\n\n npm run lint "); | |
| 16 | + | form.append("label", "bug"); | |
| 17 | + | form.set("labels", "webhooks, "); | |
| 18 | + | assert.deepEqual(delegateForm(form), { | |
| 19 | + | title: "Retry webhooks with backoff", | |
| 20 | + | body: "Failed deliveries are dropped.", | |
| 21 | + | labels: ["bug", "webhooks"], | |
| 22 | + | checks: ["npm test", "npm run lint"], | |
| 23 | + | }); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("only one of the viewer's projects can be chosen", () => { | |
| 27 | + | assert.deepEqual(chosenRepo("acme/API", repos), { namespace: "acme", name: "api" }); | |
| 28 | + | assert.equal(chosenRepo("acme/billing", repos), null); | |
| 29 | + | assert.equal(chosenRepo("acme", repos), null); | |
| 30 | + | assert.equal(chosenRepo("acme/web/x", repos), null); | |
| 31 | + | assert.equal(chosenRepo(null, repos), null); | |
| 32 | + | }); | |
| 33 | + | ||
| 34 | + | test("a started or queued agent goes on to the issue; one that did not start says why and where", () => { | |
| 35 | + | const repo = repos[0]; | |
| 36 | + | assert.equal(issuePath(repo, 41), "/acme/web/issues/41"); | |
| 37 | + | assert.equal(notStarted({ status: "started", code: null, message: null, fixUrl: null }, repo, 41), null); | |
| 38 | + | assert.equal(notStarted({ status: "queued", code: "waiting", message: "Busy.", fixUrl: null }, repo, 41), null); | |
| 39 | + | assert.deepEqual( | |
| 40 | + | notStarted( | |
| 41 | + | { status: "not_started", code: "not_paid", message: "Agents need a paid workspace.", fixUrl: "https://g1t.sh/acme/-/billing" }, | |
| 42 | + | repo, | |
| 43 | + | 41, | |
| 44 | + | ), | |
| 45 | + | { | |
| 46 | + | to: "/acme/web/issues/41", | |
| 47 | + | number: 41, | |
| 48 | + | message: "Agents need a paid workspace.", | |
| 49 | + | fix: { label: "Start the plan or the trial", to: "/acme/-/billing" }, | |
| 50 | + | }, | |
| 51 | + | ); | |
| 52 | + | assert.equal(notStarted({ status: "not_started", code: "paused", message: "Paused.", fixUrl: null }, repo, 41)?.fix, null); | |
| 53 | + | // The address a message ends with is the fix link's, so it is said once. | |
| 54 | + | assert.equal( | |
| 55 | + | notStarted( | |
| 56 | + | { status: "not_started", code: "trial_used", message: "Start the $20 plan to keep going: /acme/-/billing", fixUrl: "https://g1t.sh/acme/-/billing" }, | |
| 57 | + | repo, | |
| 58 | + | 41, | |
| 59 | + | )?.message, | |
| 60 | + | "Start the $20 plan to keep going.", | |
| 61 | + | ); | |
| 62 | + | }); |
| 1 | + | /** | |
| 2 | + | * Putting an agent on something in one step, from a form: the issue new | |
| 3 | + | * page with "Assign g1t-agent now", and Mission control's composer. Pure, | |
| 4 | + | * so it is tested on its own; the routes call `env.RUNNER.delegate`. | |
| 5 | + | */ | |
| 6 | + | import type { AgentStart, DelegateInput, RepoPath } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | /** What a form asks for, read the way both forms name their fields. */ | |
| 9 | + | export function delegateForm(form: FormData): DelegateInput { | |
| 10 | + | const text = (name: string) => String(form.get(name) ?? ""); | |
| 11 | + | return { | |
| 12 | + | title: text("title").trim(), | |
| 13 | + | body: text("body").trim(), | |
| 14 | + | labels: [...form.getAll("label").map(String), ...text("labels").split(",")].map((label) => label.trim()).filter(Boolean), | |
| 15 | + | checks: text("checks") | |
| 16 | + | .split("\n") | |
| 17 | + | .map((check) => check.trim()) | |
| 18 | + | .filter(Boolean), | |
| 19 | + | }; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** The project a composer chose, written `owner/name`, if it is one of `repos`. */ | |
| 23 | + | export function chosenRepo(value: FormDataEntryValue | null, repos: RepoPath[]): RepoPath | null { | |
| 24 | + | const [namespace, name, extra] = String(value ?? "").split("/"); | |
| 25 | + | if (!namespace || !name || extra != null) return null; | |
| 26 | + | return repos.find((repo) => repo.namespace.toLowerCase() === namespace.toLowerCase() && repo.name.toLowerCase() === name.toLowerCase()) ?? null; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** Where to land once the issue is open: on it, while the agent works or waits. */ | |
| 30 | + | export function issuePath(repo: RepoPath, number: number): string { | |
| 31 | + | return `/${repo.namespace}/${repo.name}/issues/${number}`; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /** What a form says when the issue was opened but the agent did not start. */ | |
| 35 | + | export type NotStarted = { | |
| 36 | + | /** The issue that was opened. */ | |
| 37 | + | to: string; | |
| 38 | + | number: number; | |
| 39 | + | message: string; | |
| 40 | + | fix: { label: string; to: string } | null; | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | const FIX_LABEL: Record<string, string> = { | |
| 44 | + | not_paid: "Start the plan or the trial", | |
| 45 | + | trial_used: "Start the plan", | |
| 46 | + | limit: "Raise the limit", | |
| 47 | + | issue_cap: "Raise the cap per issue", | |
| 48 | + | no_model: "Connect a model", | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * The agent's start, for a form: null when it started or is queued, so the | |
| 53 | + | * form goes on to the issue; otherwise what to say and where to fix it. | |
| 54 | + | */ | |
| 55 | + | export function notStarted(agent: AgentStart, repo: RepoPath, number: number): NotStarted | null { | |
| 56 | + | if (agent.status !== "not_started") return null; | |
| 57 | + | // The fix is on g1t.sh itself: followed within the site. | |
| 58 | + | const fixTo = agent.fixUrl?.replace(/^https:\/\/g1t\.sh(?=\/)/, "") ?? null; | |
| 59 | + | return { | |
| 60 | + | to: issuePath(repo, number), | |
| 61 | + | number, | |
| 62 | + | // The fix is offered as a link of its own, so the address the message ends with is left off. | |
| 63 | + | message: fixTo | |
| 64 | + | ? (agent.message ?? "g1t-agent did not start.").replace(/:\s*(?:https:\/\/g1t\.sh)?\/[\w./#-]+\s*$/, ".") | |
| 65 | + | : (agent.message ?? "g1t-agent did not start."), | |
| 66 | + | fix: fixTo ? { label: FIX_LABEL[agent.code ?? ""] ?? "Fix it", to: fixTo } : null, | |
| 67 | + | }; | |
| 68 | + | } |
| 4 | 4 | import { | |
| 5 | 5 | type Merged, | |
| 6 | 6 | change, | |
| 7 | + | confidenceAsk, | |
| 8 | + | confidenceLine, | |
| 7 | 9 | dayKey, | |
| 8 | 10 | isTestFile, | |
| 9 | 11 | landedByAgents, | |
| 20 | 22 | waitingRows, | |
| 21 | 23 | weekOf, | |
| 22 | 24 | whyFor, | |
| 25 | + | withConfidence, | |
| 23 | 26 | } from "./mission-control.ts"; | |
| 24 | 27 | ||
| 25 | 28 | const HOUR = 3_600_000; | |
| 91 | 94 | assert.deepEqual(bare, [{ label: "Checks", value: "Not run", tone: null }]); | |
| 92 | 95 | }); | |
| 93 | 96 | ||
| 97 | + | test("a change held for low confidence is its own reason, ahead of what it would read as", () => { | |
| 98 | + | const held = | |
| 99 | + | "The agent's confidence in this change is low (checks failing, tests not added). This repository asks a person before merging it: approve it to let it land, or ask for changes."; | |
| 100 | + | // Its reasons name checks and approval, but it is held for its confidence. | |
| 101 | + | assert.equal(stallReason(held), "low_confidence"); | |
| 102 | + | assert.equal(reasonFor({ kind: "stalled", detail: held }), "low_confidence"); | |
| 103 | + | ||
| 104 | + | const low = { level: "low" as const, reasons: ["tests not added", "3 revisions"], uncertainAbout: ["the retry limit"] }; | |
| 105 | + | assert.equal(withConfidence("ready_to_merge", low), "low_confidence"); | |
| 106 | + | assert.equal(withConfidence("needs_review", low), "low_confidence"); | |
| 107 | + | // A failure that needs someone anyway keeps its own chip. | |
| 108 | + | assert.equal(withConfidence("checks_failing", low), "checks_failing"); | |
| 109 | + | assert.equal(withConfidence("ready_to_merge", { level: "medium" }), "ready_to_merge"); | |
| 110 | + | assert.equal(withConfidence("ready_to_merge", null), "ready_to_merge"); | |
| 111 | + | ||
| 112 | + | assert.equal(confidenceLine(low), "Low — tests not added, 3 revisions"); | |
| 113 | + | assert.equal(confidenceLine({ level: "high", reasons: [] }), "High"); | |
| 114 | + | assert.match(confidenceAsk(low), /not sure of the change: tests not added, 3 revisions\. Approve it/); | |
| 115 | + | ||
| 116 | + | const why = whyFor("low_confidence", { kind: "stalled", detail: held }, low); | |
| 117 | + | assert.match(why, /from tests not added, 3 revisions\./); | |
| 118 | + | assert.match(why, /unsure about the retry limit/); | |
| 119 | + | assert.match(why, /nothing merges it until you approve it/); | |
| 120 | + | // Ready, not held: it says to look before merging. | |
| 121 | + | assert.match(whyFor("low_confidence", { kind: "ready", detail: "" }, low), /Look at it before you merge it/); | |
| 122 | + | }); | |
| 123 | + | ||
| 124 | + | test("what the agent knows gains its confidence, across the row", () => { | |
| 125 | + | const facts = pullFacts({ | |
| 126 | + | checkStatus: "passed", | |
| 127 | + | files: [{ path: "src/retry.ts", additions: 40, deletions: 2 }], | |
| 128 | + | lifecycle: { revisions: 3 }, | |
| 129 | + | confidence: { level: "low", reasons: ["tests not added", "3 revisions"] }, | |
| 130 | + | }); | |
| 131 | + | const fact = facts.find((f) => f.label === "Confidence"); | |
| 132 | + | assert.deepEqual(fact, { label: "Confidence", value: "Low — tests not added, 3 revisions", tone: "bad", wide: true }); | |
| 133 | + | assert.equal(facts.at(-1)?.label, "Confidence"); | |
| 134 | + | }); | |
| 135 | + | ||
| 94 | 136 | test("test files are recognised by the names test runners use", () => { | |
| 95 | 137 | assert.ok(isTestFile("src/a.test.ts")); | |
| 96 | 138 | assert.ok(isTestFile("src/__tests__/a.ts")); |
| 5 | 5 | * one sentence under the greeting. Pure, so it is tested on its own; it | |
| 6 | 6 | * imports only types. | |
| 7 | 7 | */ | |
| 8 | − | import type { AgentRun, CheckStatus, ChangedFile, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts"; | |
| 8 | + | import type { AgentRun, CheckStatus, ChangedFile, Confidence, ConfidenceLevel, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts"; | |
| 9 | 9 | ||
| 10 | 10 | import type { Need } from "./mission"; | |
| 11 | 11 | ||
| 19 | 19 | // --- Reasons ---------------------------------------------------------------- | |
| 20 | 20 | ||
| 21 | 21 | /** Why something is waiting on a person, as the chip beside it says. */ | |
| 22 | − | export type Reason = "blocking" | "asked_for_you" | "checks_failing" | "outside_guardrails" | "needs_review" | "stalled" | "ready_to_merge"; | |
| 22 | + | export type Reason = | |
| 23 | + | | "blocking" | |
| 24 | + | | "asked_for_you" | |
| 25 | + | | "checks_failing" | |
| 26 | + | | "outside_guardrails" | |
| 27 | + | | "low_confidence" | |
| 28 | + | | "needs_review" | |
| 29 | + | | "stalled" | |
| 30 | + | | "ready_to_merge"; | |
| 23 | 31 | ||
| 24 | 32 | export const REASON_LABEL: Record<Reason, string> = { | |
| 25 | 33 | blocking: "Blocking", | |
| 26 | 34 | asked_for_you: "Asked for you", | |
| 27 | 35 | checks_failing: "Checks failing", | |
| 28 | 36 | outside_guardrails: "Outside guardrails", | |
| 37 | + | low_confidence: "Low confidence", | |
| 29 | 38 | needs_review: "Needs review", | |
| 30 | 39 | stalled: "Stalled", | |
| 31 | 40 | ready_to_merge: "Ready to merge", | |
| 57 | 66 | ||
| 58 | 67 | /** What a pull request's `needs_you` sentence says it is waiting for. */ | |
| 59 | 68 | export function stallReason(detail: string): Reason { | |
| 69 | + | // First: its reasons can name checks, caps or approval in passing. | |
| 70 | + | if (/confidence in this change is low/i.test(detail)) return "low_confidence"; | |
| 60 | 71 | if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails"; | |
| 61 | 72 | if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking"; | |
| 62 | 73 | if (/checks? (?:still )?fail|still fails|could not be run/i.test(detail)) return "checks_failing"; | |
| 64 | 75 | return "stalled"; | |
| 65 | 76 | } | |
| 66 | 77 | ||
| 78 | + | /** | |
| 79 | + | * The reason a pull request's need is shown, given how sure g1t is of the | |
| 80 | + | * change: one that is ready, or held, with low confidence says so. | |
| 81 | + | */ | |
| 82 | + | export function withConfidence(reason: Reason, confidence: Pick<Confidence, "level"> | null | undefined): Reason { | |
| 83 | + | if (confidence?.level !== "low") return reason; | |
| 84 | + | return reason === "ready_to_merge" || reason === "stalled" || reason === "needs_review" ? "low_confidence" : reason; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | const LEVEL: Record<ConfidenceLevel, string> = { low: "Low", medium: "Medium", high: "High" }; | |
| 88 | + | ||
| 89 | + | /** "Low — tests not added, 3 revisions": a confidence in one line. */ | |
| 90 | + | export function confidenceLine(confidence: Pick<Confidence, "level" | "reasons">): string { | |
| 91 | + | return confidence.reasons.length > 0 ? `${LEVEL[confidence.level]} — ${confidence.reasons.join(", ")}` : LEVEL[confidence.level]; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** The ask, for a change held for its low confidence. */ | |
| 95 | + | export function confidenceAsk(confidence: Pick<Confidence, "reasons">): string { | |
| 96 | + | const why = confidence.reasons.length > 0 ? `: ${confidence.reasons.join(", ")}` : ""; | |
| 97 | + | return `The agent finished, but g1t is not sure of the change${why}. Approve it to let it land, or ask for changes.`; | |
| 98 | + | } | |
| 99 | + | ||
| 67 | 100 | /** Why only a person can move it: the callout beside what the agent knows. */ | |
| 68 | − | export function whyFor(reason: Reason, need: Pick<Need, "kind" | "detail">): string { | |
| 101 | + | export function whyFor( | |
| 102 | + | reason: Reason, | |
| 103 | + | need: Pick<Need, "kind" | "detail">, | |
| 104 | + | confidence?: Pick<Confidence, "reasons" | "uncertainAbout"> | null, | |
| 105 | + | ): string { | |
| 106 | + | if (reason === "low_confidence") { | |
| 107 | + | const reasons = confidence?.reasons ?? []; | |
| 108 | + | const what = reasons.length > 0 ? reasons.join(", ") : "what g1t observed of the change"; | |
| 109 | + | const unsure = | |
| 110 | + | confidence && confidence.uncertainAbout.length > 0 ? ` The agent said it was unsure about ${confidence.uncertainAbout.join("; ")}.` : ""; | |
| 111 | + | // Held: the repository asks a person first. Otherwise it is ready and waits for a merge anyway. | |
| 112 | + | const held = | |
| 113 | + | need.kind === "stalled" | |
| 114 | + | ? "This repository asks a person before a change like that lands, so nothing merges it until you approve it." | |
| 115 | + | : "Look at it before you merge it."; | |
| 116 | + | return `g1t rates its confidence in this change low, from ${what}.${unsure} ${held}`; | |
| 117 | + | } | |
| 69 | 118 | if (need.kind === "limit") return "Agents start nothing new past the workspace's usage limit. Only an owner can raise it or add a card."; | |
| 70 | 119 | if (need.kind === "deploy") | |
| 71 | 120 | return "Production still serves the build before this one. Every push to the default branch builds again, so this fails until what broke is fixed."; | |
| 97 | 146 | // --- What the agent knows --------------------------------------------------- | |
| 98 | 147 | ||
| 99 | 148 | export type FactTone = "good" | "warn" | "bad" | null; | |
| 100 | − | export type Fact = { label: string; value: string; tone: FactTone }; | |
| 149 | + | /** One thing known; a `wide` one takes the whole row and wraps. */ | |
| 150 | + | export type Fact = { label: string; value: string; tone: FactTone; wide?: boolean }; | |
| 151 | + | ||
| 152 | + | const CONFIDENCE_TONE: Record<ConfidenceLevel, FactTone> = { low: "bad", medium: "warn", high: "good" }; | |
| 153 | + | ||
| 154 | + | /** How sure g1t is of the change, with its reasons, as a fact. */ | |
| 155 | + | export function confidenceFact(confidence: Pick<Confidence, "level" | "reasons">): Fact { | |
| 156 | + | return { label: "Confidence", value: confidenceLine(confidence), tone: CONFIDENCE_TONE[confidence.level], wide: true }; | |
| 157 | + | } | |
| 101 | 158 | ||
| 102 | 159 | const CHECKS: Record<CheckStatus, { text: string; tone: FactTone }> = { | |
| 103 | 160 | passed: { text: "Passed", tone: "good" }, | |
| 130 | 187 | files: ChangedFile[]; | |
| 131 | 188 | lifecycle?: Pick<Lifecycle, "revisions"> | null; | |
| 132 | 189 | runs?: Pick<AgentRun, "costUsd" | "kind">[]; | |
| 190 | + | confidence?: Pick<Confidence, "level" | "reasons"> | null; | |
| 133 | 191 | }): Fact[] { | |
| 134 | 192 | const facts: Fact[] = []; | |
| 135 | 193 | const checks = input.checkStatus ? CHECKS[input.checkStatus] : null; | |
| 159 | 217 | tone: null, | |
| 160 | 218 | }); | |
| 161 | 219 | } | |
| 220 | + | if (input.confidence) facts.push(confidenceFact(input.confidence)); | |
| 162 | 221 | return facts; | |
| 163 | 222 | } | |
| 164 | 223 |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { DANGEROUS_SCOPES, OAUTH_DEFAULT_SCOPES, SCOPES, SCOPE_GROUPS } from "@g1t/contracts/scopes"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | accessSummary, | |
| 8 | + | consentedScopes, | |
| 9 | + | describeExpiry, | |
| 10 | + | everyScope, | |
| 11 | + | expiryTtl, | |
| 12 | + | grantFromForm, | |
| 13 | + | impliedBy, | |
| 14 | + | matchingPreset, | |
| 15 | + | normalizeScopes, | |
| 16 | + | requestedScopes, | |
| 17 | + | scopesFromForm, | |
| 18 | + | } from "./token-scopes.ts"; | |
| 19 | + | ||
| 20 | + | function form(fields: Record<string, string | string[]>) { | |
| 21 | + | return { | |
| 22 | + | get: (name: string) => { | |
| 23 | + | const value = fields[name]; | |
| 24 | + | return Array.isArray(value) ? (value[0] ?? null) : (value ?? null); | |
| 25 | + | }, | |
| 26 | + | getAll: (name: string) => { | |
| 27 | + | const value = fields[name]; | |
| 28 | + | return value === undefined ? [] : Array.isArray(value) ? value : [value]; | |
| 29 | + | }, | |
| 30 | + | }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | test("every scope is on the checklist exactly once, admin ones under Dangerous", () => { | |
| 34 | + | const listed = [...SCOPE_GROUPS.flatMap((group) => group.scopes), ...DANGEROUS_SCOPES]; | |
| 35 | + | assert.deepEqual([...listed].sort(), SCOPES.map((row) => row.scope).sort()); | |
| 36 | + | assert.equal(new Set(listed).size, listed.length); | |
| 37 | + | assert.ok(DANGEROUS_SCOPES.every((scope) => scope.endsWith(":admin"))); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("ticked boxes store the highest level of each resource", () => { | |
| 41 | + | const parsed = scopesFromForm( | |
| 42 | + | form({ scope: ["issues:read", "issues:write", "code:read", "agents:run", "bogus:read"] }), | |
| 43 | + | ); | |
| 44 | + | assert.deepEqual(parsed, { ok: true, value: ["code:read", "issues:write", "agents:run"] }); | |
| 45 | + | assert.deepEqual(normalizeScopes(["repo:read", "repo:admin", "repo:write"]), ["repo:admin"]); | |
| 46 | + | }); | |
| 47 | + | ||
| 48 | + | test("full access is null, and nothing ticked is refused", () => { | |
| 49 | + | assert.deepEqual(scopesFromForm(form({ preset: "full", scope: "issues:read" })), { ok: true, value: null }); | |
| 50 | + | assert.equal(scopesFromForm(form({ preset: "full" }), { allowFull: false }).ok, false); | |
| 51 | + | assert.equal(scopesFromForm(form({ preset: "custom" })).ok, false); | |
| 52 | + | assert.deepEqual(grantFromForm(form({ scope: "memory:read" })), { ok: true, value: { scopes: ["memory:read"] } }); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | test("a higher level ticks the lower ones of its resource only", () => { | |
| 56 | + | assert.equal(impliedBy(["issues:write"], "issues:read"), "issues:write"); | |
| 57 | + | assert.equal(impliedBy(["repo:admin"], "repo:write"), "repo:admin"); | |
| 58 | + | assert.equal(impliedBy(["issues:write"], "issues:write"), null); | |
| 59 | + | assert.equal(impliedBy(["issues:write"], "pull_requests:read"), null); | |
| 60 | + | assert.equal(impliedBy(["code:read"], "code:write"), null); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("full access ticks the top level of everything", () => { | |
| 64 | + | const all = everyScope(); | |
| 65 | + | assert.ok(all.includes("repo:admin")); | |
| 66 | + | assert.ok(all.includes("agents:run")); | |
| 67 | + | assert.ok(all.includes("code:write")); | |
| 68 | + | assert.ok(!all.includes("code:read")); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("presets are recognised however their scopes are written", () => { | |
| 72 | + | assert.equal(matchingPreset(null), "full"); | |
| 73 | + | assert.equal(matchingPreset(["repo:read", "code:write", "workflows:write"]), "ci"); | |
| 74 | + | assert.equal(matchingPreset([...OAUTH_DEFAULT_SCOPES]), "agent"); | |
| 75 | + | assert.equal(matchingPreset(["issues:read"]), null); | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test("a token's access reads plainly", () => { | |
| 79 | + | assert.equal(accessSummary({ scopes: null, legacy: true }), "Legacy · full access"); | |
| 80 | + | assert.equal(accessSummary({ scopes: null, legacy: false }), "Full access"); | |
| 81 | + | assert.equal(accessSummary({ scopes: ["code:read", "code:write", "workflows:write", "repo:read"], legacy: false }), "CI"); | |
| 82 | + | assert.equal(accessSummary({ scopes: ["issues:read", "issues:write", "memory:read"], legacy: false }), "2 scopes"); | |
| 83 | + | assert.equal(accessSummary({ scopes: [], legacy: false }), "No scopes"); | |
| 84 | + | }); | |
| 85 | + | ||
| 86 | + | test("an application asking for nothing usable gets the default set, never admin", () => { | |
| 87 | + | assert.deepEqual(requestedScopes(null), OAUTH_DEFAULT_SCOPES); | |
| 88 | + | assert.deepEqual(requestedScopes("openid profile *"), OAUTH_DEFAULT_SCOPES); | |
| 89 | + | assert.ok(!requestedScopes("").some((scope) => scope.endsWith(":admin"))); | |
| 90 | + | assert.deepEqual(requestedScopes("issues:write nonsense repo:read"), ["repo:read", "issues:write"]); | |
| 91 | + | }); | |
| 92 | + | ||
| 93 | + | test("consent keeps only what was asked for", () => { | |
| 94 | + | const requested = requestedScopes("repo:read issues:read issues:write"); | |
| 95 | + | assert.deepEqual(consentedScopes(form({ scope: ["issues:write", "repo:admin", "secrets:admin"] }), requested), ["issues:write"]); | |
| 96 | + | assert.deepEqual(consentedScopes(form({ scope: ["issues:read", "repo:read"] }), requested), ["repo:read", "issues:read"]); | |
| 97 | + | assert.deepEqual(consentedScopes(form({}), requested), []); | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | test("expiry choices and words", () => { | |
| 101 | + | assert.equal(expiryTtl("7"), 7 * 86_400); | |
| 102 | + | assert.equal(expiryTtl("never"), undefined); | |
| 103 | + | assert.equal(expiryTtl("13"), 90 * 86_400); | |
| 104 | + | assert.equal(expiryTtl(null), 90 * 86_400); | |
| 105 | + | const now = Date.parse("2026-10-05T00:00:00Z"); | |
| 106 | + | assert.equal(describeExpiry(null, now), "No expiry"); | |
| 107 | + | assert.equal(describeExpiry("2026-10-04T00:00:00Z", now), "Expired"); | |
| 108 | + | assert.equal(describeExpiry("2026-10-12T00:00:00Z", now), "Expires in 7 days"); | |
| 109 | + | assert.equal(describeExpiry("2026-10-05T01:00:00Z", now), "Expires in 1 hour"); | |
| 110 | + | assert.equal(describeExpiry("2027-01-03T00:00:00Z", now), "Expires in 3 months"); | |
| 111 | + | }); |
| 1 | + | /** | |
| 2 | + | * Choosing what a token, an application or a workspace's token may do: | |
| 3 | + | * the checklist's fields, read into the scopes identity stores, and the | |
| 4 | + | * words settings use to show them again. | |
| 5 | + | * | |
| 6 | + | * Tokens are classic: a token reaches whatever its owner can, and its | |
| 7 | + | * scopes say what it may do there. The form posts one `scope` field per | |
| 8 | + | * ticked box. A higher level of a resource includes the lower ones | |
| 9 | + | * (`issues:write` gives `issues:read`), so only the highest ticked level | |
| 10 | + | * of each resource is stored. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | import { | |
| 14 | + | PRESETS, | |
| 15 | + | SCOPES, | |
| 16 | + | SCOPE_RESOURCES, | |
| 17 | + | levelsOf, | |
| 18 | + | parseScopes, | |
| 19 | + | presetScopes, | |
| 20 | + | scopeIncludes, | |
| 21 | + | scopeLevel, | |
| 22 | + | scopeResource, | |
| 23 | + | OAUTH_DEFAULT_SCOPES, | |
| 24 | + | type PresetId, | |
| 25 | + | type Scope, | |
| 26 | + | type ScopeLevel, | |
| 27 | + | type ScopeResource, | |
| 28 | + | } from "@g1t/contracts/scopes"; | |
| 29 | + | ||
| 30 | + | const RANK: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, admin: 3 }; | |
| 31 | + | ||
| 32 | + | /** Anything with FormData's getters, so tests can pass a plain map. */ | |
| 33 | + | export type FormLike = { get(name: string): unknown; getAll(name: string): unknown[] }; | |
| 34 | + | ||
| 35 | + | /** The fewest scopes that give the same access: the highest level per resource, in table order. */ | |
| 36 | + | export function normalizeScopes(scopes: readonly string[]): Scope[] { | |
| 37 | + | const top = new Map<ScopeResource, Scope>(); | |
| 38 | + | for (const scope of parseScopes(scopes.join(" "))) { | |
| 39 | + | const held = top.get(scopeResource(scope)); | |
| 40 | + | if (!held || RANK[scopeLevel(scope)] > RANK[scopeLevel(held)]) top.set(scopeResource(scope), scope); | |
| 41 | + | } | |
| 42 | + | return SCOPES.map((row) => row.scope).filter((scope) => top.get(scopeResource(scope)) === scope); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /** Every scope's top level: what full access ticks. */ | |
| 46 | + | export function everyScope(): Scope[] { | |
| 47 | + | return SCOPE_RESOURCES.map(({ resource }) => { | |
| 48 | + | const levels = levelsOf(resource); | |
| 49 | + | return `${resource}:${levels[levels.length - 1]}` as Scope; | |
| 50 | + | }); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** | |
| 54 | + | * Whether `scope` is given by another ticked box of its resource at a | |
| 55 | + | * higher level. The checklist shows it ticked and greyed out. | |
| 56 | + | */ | |
| 57 | + | export function impliedBy(ticked: readonly Scope[], scope: Scope): Scope | null { | |
| 58 | + | return ticked.find((held) => held !== scope && scopeIncludes(held, scope)) ?? null; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** The preset a set of scopes is exactly, if any. Null scopes are full access. */ | |
| 62 | + | export function matchingPreset(scopes: readonly string[] | null): PresetId | null { | |
| 63 | + | if (scopes === null) return "full"; | |
| 64 | + | const mine = normalizeScopes(scopes).join(" "); | |
| 65 | + | for (const preset of PRESETS) { | |
| 66 | + | const theirs = presetScopes(preset.id); | |
| 67 | + | if (theirs && normalizeScopes(theirs).join(" ") === mine) return preset.id; | |
| 68 | + | } | |
| 69 | + | return null; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | export function presetLabel(id: PresetId): string { | |
| 73 | + | return PRESETS.find((preset) => preset.id === id)?.label ?? id; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** How a token or grant's access reads in a list. */ | |
| 77 | + | export function accessSummary(holder: { scopes: readonly string[] | null; legacy: boolean }): string { | |
| 78 | + | if (holder.scopes === null) return holder.legacy ? "Legacy · full access" : "Full access"; | |
| 79 | + | const preset = matchingPreset(holder.scopes); | |
| 80 | + | if (preset) return presetLabel(preset); | |
| 81 | + | const count = normalizeScopes(holder.scopes).length; | |
| 82 | + | if (count === 0) return "No scopes"; | |
| 83 | + | return count === 1 ? "1 scope" : `${count} scopes`; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /** Whether a set of scopes gives anything an admin level gives. */ | |
| 87 | + | export function hasDangerous(scopes: readonly string[] | null): boolean { | |
| 88 | + | return scopes === null || normalizeScopes(scopes).some((scope) => scopeLevel(scope) === "admin"); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | export type GrantInput = { scopes: string[] | null }; | |
| 92 | + | ||
| 93 | + | export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }; | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * The scopes ticked on the checklist: `preset` = `full` is full access | |
| 97 | + | * (null); otherwise every `scope` box, unknown names left out. | |
| 98 | + | */ | |
| 99 | + | export function scopesFromForm(form: FormLike, options: { allowFull?: boolean } = {}): Parsed<string[] | null> { | |
| 100 | + | if (options.allowFull !== false && form.get("preset") === "full") return { ok: true, value: null }; | |
| 101 | + | const scopes = normalizeScopes(form.getAll("scope").map(String)); | |
| 102 | + | if (scopes.length === 0) return { ok: false, error: "Tick at least one scope." }; | |
| 103 | + | return { ok: true, value: scopes }; | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** What the scope checklist posts, ready for identity. */ | |
| 107 | + | export function grantFromForm(form: FormLike, options: { allowFull?: boolean } = {}): Parsed<GrantInput> { | |
| 108 | + | const scopes = scopesFromForm(form, options); | |
| 109 | + | if (!scopes.ok) return scopes; | |
| 110 | + | return { ok: true, value: { scopes: scopes.value } }; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /** What an application asked for in `scope`; nothing usable means the default set, never admin. */ | |
| 114 | + | export function requestedScopes(scope: string | null | undefined): Scope[] { | |
| 115 | + | const asked = parseScopes(scope ?? ""); | |
| 116 | + | return asked.length > 0 ? asked : [...OAUTH_DEFAULT_SCOPES]; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /** | |
| 120 | + | * The scopes kept on the consent page: only what the application asked | |
| 121 | + | * for, whatever the form says. A box greyed out because a higher one of | |
| 122 | + | * its resource is ticked is not posted, and needs not be: the higher one | |
| 123 | + | * gives it. | |
| 124 | + | */ | |
| 125 | + | export function consentedScopes(form: FormLike, requested: readonly Scope[]): Scope[] { | |
| 126 | + | const ticked = new Set(form.getAll("scope").map(String)); | |
| 127 | + | return normalizeScopes(requested.filter((scope) => ticked.has(scope))); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /** Expiry choices for a new token, in days; `never` does not expire. */ | |
| 131 | + | export const EXPIRY_CHOICES = [ | |
| 132 | + | { value: "7", label: "7 days" }, | |
| 133 | + | { value: "30", label: "30 days" }, | |
| 134 | + | { value: "90", label: "90 days" }, | |
| 135 | + | { value: "365", label: "1 year" }, | |
| 136 | + | { value: "never", label: "No expiry" }, | |
| 137 | + | ] as const; | |
| 138 | + | ||
| 139 | + | export const DEFAULT_EXPIRY = "90"; | |
| 140 | + | ||
| 141 | + | /** Seconds a new token lives, or undefined for no expiry. Anything unknown is the default. */ | |
| 142 | + | export function expiryTtl(value: unknown): number | undefined { | |
| 143 | + | const text = String(value ?? DEFAULT_EXPIRY); | |
| 144 | + | if (text === "never") return undefined; | |
| 145 | + | const days = EXPIRY_CHOICES.some((choice) => choice.value === text) ? Number(text) : Number(DEFAULT_EXPIRY); | |
| 146 | + | return days * 86_400; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /** "Expires in 3 days", "Expired", "No expiry". */ | |
| 150 | + | export function describeExpiry(expiresAt: string | null, now = Date.now()): string { | |
| 151 | + | if (!expiresAt) return "No expiry"; | |
| 152 | + | const left = new Date(expiresAt).getTime() - now; | |
| 153 | + | if (left <= 0) return "Expired"; | |
| 154 | + | const hours = left / 3_600_000; | |
| 155 | + | if (hours < 1) return "Expires in under an hour"; | |
| 156 | + | const plural = (count: number, unit: string) => `Expires in ${count} ${unit}${count === 1 ? "" : "s"}`; | |
| 157 | + | if (hours < 48) return plural(Math.round(hours), "hour"); | |
| 158 | + | const days = Math.round(hours / 24); | |
| 159 | + | if (days < 60) return plural(days, "day"); | |
| 160 | + | const months = Math.round(days / 30); | |
| 161 | + | if (months < 24) return plural(months, "month"); | |
| 162 | + | return plural(Math.round(days / 365), "year"); | |
| 163 | + | } |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { Suspense, lazy } from "react"; | |
| 3 | − | import { type ShouldRevalidateFunctionArgs, data } from "react-router"; | |
| 3 | + | import { type ShouldRevalidateFunctionArgs, data, redirect } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { | |
| 6 | + | type Confidence, | |
| 6 | 7 | type Lifecycle, | |
| 7 | 8 | type Pull, | |
| 8 | 9 | type Repo, | |
| 35 | 36 | type NeedRow, | |
| 36 | 37 | type QuickAction, | |
| 37 | 38 | RUN_LABEL, | |
| 39 | + | confidenceAsk, | |
| 38 | 40 | dateLine, | |
| 39 | 41 | dayKey, | |
| 40 | 42 | landedToday, | |
| 48 | 50 | weekOf, | |
| 49 | 51 | who, | |
| 50 | 52 | whyFor, | |
| 53 | + | withConfidence, | |
| 51 | 54 | } from "../lib/mission-control"; | |
| 55 | + | import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate"; | |
| 52 | 56 | import { Landing } from "../components/landing"; | |
| 53 | 57 | import { | |
| 54 | 58 | agents, | |
| 60 | 64 | repos as reposApi, | |
| 61 | 65 | work, | |
| 62 | 66 | } from "../lib/services.server"; | |
| 63 | − | import { getViewer } from "../lib/session.server"; | |
| 67 | + | import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server"; | |
| 64 | 68 | ||
| 65 | 69 | /** The viewer's time zone, which mission control sets, so the greeting fits their day. */ | |
| 66 | 70 | const TZ_COOKIE = "g1t_tz"; | |
| 98 | 102 | } | |
| 99 | 103 | ||
| 100 | 104 | /** Where a need came from, so its row can say what is known about it. */ | |
| 101 | − | type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "for" | "facts" | "quick" | "link" | "open">>; | |
| 105 | + | type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "for" | "facts" | "quick" | "link" | "open">> & { | |
| 106 | + | /** How sure g1t is of the agent's change, for a pull request. */ | |
| 107 | + | confidence?: Confidence | null; | |
| 108 | + | }; | |
| 109 | + | ||
| 110 | + | /** What the composer said back, when the issue opened but the agent did not start, or nothing opened. */ | |
| 111 | + | export type DelegateResult = { error: string; notStarted: null } | { error: null; notStarted: NotStarted }; | |
| 102 | 112 | ||
| 113 | + | /** | |
| 114 | + | * "Put an agent on it": opens an issue in one of the viewer's projects and | |
| 115 | + | * puts g1t-agent on it, then lands on the issue. When the agent could not | |
| 116 | + | * start, the issue is still open, and the composer says why and where to | |
| 117 | + | * fix it. | |
| 118 | + | */ | |
| 119 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 120 | + | assertSameOrigin(request); | |
| 121 | + | const user = requireUser(context, request); | |
| 122 | + | const form = await request.formData(); | |
| 123 | + | if (form.get("intent") !== "delegate") return data<DelegateResult>({ error: "Nothing to do.", notStarted: null }, { status: 400 }); | |
| 124 | + | const projects = await reposApi.list(user, { memberOnly: true }).catch(() => []); | |
| 125 | + | const repo = chosenRepo(form.get("repo"), projects.map((repo) => ({ namespace: repo.namespace, name: repo.name }))); | |
| 126 | + | if (!repo) return data<DelegateResult>({ error: "Choose one of your projects.", notStarted: null }, { status: 400 }); | |
| 127 | + | const result = await env.RUNNER.delegate(user, repo, delegateForm(form)); | |
| 128 | + | if (!result.ok) return data<DelegateResult>({ error: result.error.message, notStarted: null }, { status: 400 }); | |
| 129 | + | const { issue, agent } = result.value; | |
| 130 | + | const refused = notStarted(agent, repo, issue.number); | |
| 131 | + | if (!refused) throw redirect(issuePath(repo, issue.number)); | |
| 132 | + | return data<DelegateResult>({ error: null, notStarted: refused }); | |
| 133 | + | } | |
| 134 | + | ||
| 103 | 135 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 104 | 136 | const viewer = getViewer(context); | |
| 105 | 137 | if (!viewer) { | |
| 216 | 248 | ref: `#${pull.number}`, | |
| 217 | 249 | by: agentWork ? who(pull.agent) : who(pull.author.username), | |
| 218 | 250 | for: agentWork ? pull.author.username : null, | |
| 219 | − | facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle, runs: runsOn(repo, pull.number) }), | |
| 251 | + | facts: pullFacts({ | |
| 252 | + | checkStatus: pull.checkStatus, | |
| 253 | + | files: pull.files, | |
| 254 | + | lifecycle, | |
| 255 | + | runs: runsOn(repo, pull.number), | |
| 256 | + | confidence: pull.confidence, | |
| 257 | + | }), | |
| 220 | 258 | open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`, | |
| 259 | + | confidence: pull.confidence ?? null, | |
| 221 | 260 | }; | |
| 222 | 261 | }; | |
| 223 | 262 | const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({ | |
| 290 | 329 | const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`; | |
| 291 | 330 | const key = `pull:${pull.id}`; | |
| 292 | 331 | const extra = pullExtra(pull, repo, lifecycle); | |
| 332 | + | // Held for its low confidence: the ask says so in a sentence of its own. | |
| 333 | + | const lowConfidence = pull.confidence?.level === "low" ? pull.confidence : null; | |
| 293 | 334 | if (lifecycle?.stage === "needs_you") { | |
| 294 | − | const conflict = /conflict/i.test(lifecycle.detail); | |
| 335 | + | const held = lowConfidence != null && /confidence in this change is low/i.test(lifecycle.detail); | |
| 336 | + | const conflict = !held && /conflict/i.test(lifecycle.detail); | |
| 295 | 337 | const need: Need = { | |
| 296 | 338 | key, | |
| 297 | 339 | kind: conflict ? "conflict" : "stalled", | |
| 298 | 340 | title: pull.title, | |
| 299 | − | detail: lifecycle.detail, | |
| 341 | + | detail: held && lowConfidence ? confidenceAsk(lowConfidence) : lifecycle.detail, | |
| 300 | 342 | to, | |
| 301 | 343 | action: conflict ? "Resolve" : "Decide", | |
| 302 | 344 | at: Date.parse(pull.updatedAt), | |
| 303 | 345 | where, | |
| 304 | 346 | }; | |
| 305 | 347 | needs.push(need); | |
| 306 | − | const reason = reasonFor(need); | |
| 348 | + | const reason = held ? "low_confidence" : reasonFor(need); | |
| 307 | 349 | extras.set(key, { | |
| 308 | 350 | ...extra, | |
| 309 | 351 | quick: | |
| 310 | − | reason === "needs_review" | |
| 352 | + | reason === "needs_review" || reason === "low_confidence" | |
| 311 | 353 | ? approve(repo, pull) | |
| 312 | 354 | : /could not be run/i.test(lifecycle.detail) | |
| 313 | 355 | ? pullAction(repo, pull, { label: "Run the checks again", fields: { action: "recheck" }, done: "Checks started" }) | |
| 315 | 357 | link: reason === "outside_guardrails" ? { label: "Raise the cap", to: `/${repo.namespace}/${repo.name}/settings/guardrails` } : null, | |
| 316 | 358 | }); | |
| 317 | 359 | } else if (lifecycle?.stage === "ready") { | |
| 318 | − | needs.push({ key, kind: "ready", title: pull.title, detail: "Checks passed and it was approved. It lands when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where }); | |
| 360 | + | needs.push({ | |
| 361 | + | key, | |
| 362 | + | kind: "ready", | |
| 363 | + | title: pull.title, | |
| 364 | + | detail: lowConfidence ? confidenceAsk(lowConfidence) : "Checks passed and it was approved. It lands when you merge it.", | |
| 365 | + | to, | |
| 366 | + | action: "Merge", | |
| 367 | + | at: Date.parse(pull.updatedAt), | |
| 368 | + | where, | |
| 369 | + | }); | |
| 319 | 370 | extras.set(key, { | |
| 320 | 371 | ...extra, | |
| 321 | 372 | quick: pullAction(repo, pull, { | |
| 376 | 427 | ||
| 377 | 428 | const needRows: NeedRow[] = rankNeeds(needs).map((need) => { | |
| 378 | 429 | const extra = extras.get(need.key) ?? {}; | |
| 379 | − | const reason = reasonFor(need); | |
| 430 | + | const reason = withConfidence(reasonFor(need), extra.confidence); | |
| 380 | 431 | return { | |
| 381 | 432 | key: need.key, | |
| 382 | 433 | reason, | |
| 390 | 441 | to: need.to, | |
| 391 | 442 | open: extra.open ?? need.to, | |
| 392 | 443 | facts: extra.facts ?? [], | |
| 393 | − | why: whyFor(reason, need), | |
| 444 | + | why: whyFor(reason, need, extra.confidence), | |
| 394 | 445 | quick: extra.quick ?? null, | |
| 395 | 446 | link: extra.link ?? null, | |
| 396 | 447 | }; | |
| 531 | 582 | /** Mission control, loaded only for someone signed in (components/mission-control.tsx). */ | |
| 532 | 583 | const MissionControl = lazy(() => import("../components/mission-control")); | |
| 533 | 584 | ||
| 534 | − | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 585 | + | export default function Home({ loaderData, actionData }: Route.ComponentProps) { | |
| 535 | 586 | if (!loaderData.signedIn) return <Landing />; | |
| 536 | 587 | return ( | |
| 537 | 588 | <Suspense fallback={null}> | |
| 538 | − | <MissionControl loaderData={loaderData} /> | |
| 589 | + | <MissionControl loaderData={loaderData} delegated={actionData ?? null} /> | |
| 539 | 590 | </Suspense> | |
| 540 | 591 | ); | |
| 541 | 592 | } |
| 1 | − | import { Download } from "lucide-react"; | |
| 2 | − | import { Form, redirect, useNavigation } from "react-router"; | |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | import { Download, Sparkles } from "lucide-react"; | |
| 3 | + | import { Form, Link, redirect, useNavigation, useSearchParams } from "react-router"; | |
| 3 | 4 | ||
| 4 | 5 | import { PROVIDERS } from "@g1t/contracts"; | |
| 5 | 6 | ||
| 10 | 11 | import { Label } from "../../components/work"; | |
| 11 | 12 | import { integrations, work } from "../../lib/services.server"; | |
| 12 | 13 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 14 | + | import { accessTo } from "../../lib/access.server"; | |
| 15 | + | import { computeNoteFor } from "../../lib/compute.server"; | |
| 16 | + | import { delegateForm, issuePath, notStarted } from "../../lib/delegate"; | |
| 13 | 17 | ||
| 14 | 18 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | 19 | return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` }); | |
| 18 | 22 | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 19 | 23 | const user = requireUser(context, request); | |
| 20 | 24 | const path = { namespace: params.owner, name: params.repo }; | |
| 21 | − | const [labels, connections] = await Promise.all([ | |
| 25 | + | // Putting an agent on it needs Write (Run): Read cannot spend compute. | |
| 26 | + | const { can } = await accessTo(context, params); | |
| 27 | + | const [labels, connections, agents, computeNote] = await Promise.all([ | |
| 22 | 28 | work.listLabels(path, user), | |
| 23 | 29 | integrations.list(params.owner.toLowerCase(), user), | |
| 30 | + | can.run ? env.RUNNER.enabled(user, path) : false, | |
| 31 | + | can.run ? computeNoteFor(params.owner, "agent") : null, | |
| 24 | 32 | ]); | |
| 25 | 33 | // Systems a ticket can be imported from. | |
| 26 | 34 | const sources = connections.ok | |
| 27 | 35 | ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))] | |
| 28 | 36 | : []; | |
| 29 | − | return { labels: unwrap(labels), sources }; | |
| 37 | + | return { labels: unwrap(labels), sources, canAssign: can.run, agents, computeNote }; | |
| 30 | 38 | } | |
| 31 | 39 | ||
| 32 | 40 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 43 | 51 | if (!imported.ok) return { importError: imported.error.message }; | |
| 44 | 52 | throw redirect(`/${params.owner}/${params.repo}/issues/${imported.value.number}`); | |
| 45 | 53 | } | |
| 54 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 55 | + | // Assigned to g1t-agent as it opens: one step, and the agent starts. | |
| 56 | + | if (form.get("agent") === "on") { | |
| 57 | + | const delegated = await env.RUNNER.delegate(user, path, delegateForm(form)); | |
| 58 | + | if (!delegated.ok) return { error: delegated.error.message }; | |
| 59 | + | const { issue, agent } = delegated.value; | |
| 60 | + | const refused = notStarted(agent, path, issue.number); | |
| 61 | + | if (!refused) throw redirect(issuePath(path, issue.number)); | |
| 62 | + | return { notStarted: refused }; | |
| 63 | + | } | |
| 46 | 64 | const result = await work.openIssue( | |
| 47 | 65 | user, | |
| 48 | − | { namespace: params.owner, name: params.repo }, | |
| 66 | + | path, | |
| 49 | 67 | { | |
| 50 | 68 | title: String(form.get("title") ?? ""), | |
| 51 | 69 | body: String(form.get("body") ?? ""), | |
| 62 | 80 | ||
| 63 | 81 | export default function NewIssue({ loaderData, actionData }: Route.ComponentProps) { | |
| 64 | 82 | const busy = useNavigation().state === "submitting"; | |
| 83 | + | // "Put an agent on …" in the palette arrives with ?agent=1. | |
| 84 | + | const [params] = useSearchParams(); | |
| 85 | + | const refused = actionData && "notStarted" in actionData ? actionData.notStarted : null; | |
| 65 | 86 | const names = loaderData.sources.map((source) => PROVIDERS[source].label); | |
| 66 | 87 | return ( | |
| 67 | 88 | <div className="max-w-2xl"> | |
| 129 | 150 | > | |
| 130 | 151 | <Textarea name="checks" rows={3} placeholder="cargo test" /> | |
| 131 | 152 | </Field> | |
| 153 | + | {loaderData.canAssign && ( | |
| 154 | + | <div className="rounded-xl border border-merged/25 bg-merged/[0.04] px-3.5 py-3"> | |
| 155 | + | <CheckboxOption | |
| 156 | + | name="agent" | |
| 157 | + | defaultChecked={params.get("agent") === "1"} | |
| 158 | + | label={ | |
| 159 | + | <span className="flex items-center gap-1.5 font-medium"> | |
| 160 | + | <Sparkles size={14} className="text-merged" /> | |
| 161 | + | Assign g1t-agent now | |
| 162 | + | </span> | |
| 163 | + | } | |
| 164 | + | description="It opens a pull request for this issue in a sandbox of its own and sees it through checks and review. There is no model or agent count to choose." | |
| 165 | + | /> | |
| 166 | + | {(!loaderData.agents || loaderData.computeNote) && ( | |
| 167 | + | <p className="mt-2 pl-6 text-xs text-warn"> | |
| 168 | + | {loaderData.computeNote ?? "This workspace's agents have no model yet. The issue still opens, and says why the agent did not start."} | |
| 169 | + | </p> | |
| 170 | + | )} | |
| 171 | + | </div> | |
| 172 | + | )} | |
| 173 | + | {refused && ( | |
| 174 | + | <div className="rounded-lg border border-warn/30 bg-warn/[0.06] p-3 text-sm"> | |
| 175 | + | <p> | |
| 176 | + | Opened{" "} | |
| 177 | + | <Link to={refused.to} className="font-medium text-fg hover:underline"> | |
| 178 | + | #{refused.number} | |
| 179 | + | </Link> | |
| 180 | + | , but g1t-agent did not start. {refused.message} | |
| 181 | + | </p> | |
| 182 | + | {refused.fix && ( | |
| 183 | + | <Link to={refused.fix.to} className="mt-2 inline-block text-sm font-medium text-fg hover:underline"> | |
| 184 | + | {refused.fix.label} | |
| 185 | + | </Link> | |
| 186 | + | )} | |
| 187 | + | </div> | |
| 188 | + | )} | |
| 132 | 189 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 133 | 190 | <Button type="submit">Open issue</Button> | |
| 134 | 191 | </Form> |
| 681 | 681 | )} | |
| 682 | 682 | {lifecycle && <LifecyclePanel lifecycle={lifecycle} />} | |
| 683 | 683 | {/* The agent on it: who, doing what this minute, for how long, at what cost. */} | |
| 684 | − | <AgentPanel owner={params.owner} repo={params.repo} number={pull.number} stage={lifecycle?.stage ?? null} /> | |
| 684 | + | <AgentPanel | |
| 685 | + | owner={params.owner} | |
| 686 | + | repo={params.repo} | |
| 687 | + | number={pull.number} | |
| 688 | + | stage={lifecycle?.stage ?? null} | |
| 689 | + | confidence={pull.confidence ?? null} | |
| 690 | + | /> | |
| 685 | 691 | ||
| 686 | 692 | {/* Steering: while its agent works, people can tell it things. */} | |
| 687 | 693 | {canRun && |
| 49 | 49 | agentReview: on("agentReview"), | |
| 50 | 50 | maxRevisions: count(form.get("maxRevisions"), 0, 5), | |
| 51 | 51 | mergeQueue: on("mergeQueue"), | |
| 52 | + | holdLowConfidence: on("holdLowConfidence"), | |
| 52 | 53 | }); | |
| 53 | 54 | return settings.ok ? { saved: true, error: null } : { saved: false, error: settings.error.message }; | |
| 54 | 55 | } | |
| 134 | 135 | A g1t agent's pull request lands without anyone pressing merge once every rule above is met. With this | |
| 135 | 136 | off, it waits for a member. Pull requests from people and from other agents always wait. | |
| 136 | 137 | </Toggle> | |
| 138 | + | <Toggle | |
| 139 | + | name="holdLowConfidence" | |
| 140 | + | on={settings.holdLowConfidence} | |
| 141 | + | title="Ask a person before merging low-confidence changes" | |
| 142 | + | > | |
| 143 | + | g1t rates how sure it is of each change an agent finishes, from its checks, revisions, review, tests, size | |
| 144 | + | and guardrails. One it rates low waits for a member to approve it, instead of merging by itself or joining | |
| 145 | + | the queue, and shows on Mission control as needing you. | |
| 146 | + | </Toggle> | |
| 137 | 147 | <Link | |
| 138 | 148 | to={`${base}/settings/guardrails`} | |
| 139 | 149 | className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface" |
| 1 | 1 | import { identity } from "../../lib/services.server"; | |
| 2 | + | import { Form, redirect, useSearchParams } from "react-router"; | |
| 2 | 3 | ||
| 3 | 4 | import type { Route } from "./+types/applications"; | |
| 4 | 5 | import { page } from "../../lib/meta"; | |
| 5 | − | import { TimeAgo } from "../../components/ui"; | |
| 6 | + | import { Button, ButtonLink, ErrorText, TimeAgo } from "../../components/ui"; | |
| 6 | 7 | import { DeleteButton } from "../../components/account-settings"; | |
| 8 | + | import { AccessSummary, ScopeChecklist } from "../../components/token-scopes"; | |
| 9 | + | import { grantFromForm } from "../../lib/token-scopes"; | |
| 7 | 10 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 8 | 11 | ||
| 9 | 12 | export function meta(args: Route.MetaArgs) { | |
| 19 | 22 | assertSameOrigin(request); | |
| 20 | 23 | const user = requireUser(context, request); | |
| 21 | 24 | const form = await request.formData(); | |
| 22 | − | if (form.get("intent") === "sign-out-application") { | |
| 23 | − | await identity.revokeOAuthGrant(user, String(form.get("id") ?? "")); | |
| 25 | + | const id = String(form.get("id") ?? ""); | |
| 26 | + | switch (form.get("intent")) { | |
| 27 | + | case "sign-out-application": | |
| 28 | + | await identity.revokeOAuthGrant(user, id); | |
| 29 | + | return null; | |
| 30 | + | case "update-application": { | |
| 31 | + | const grant = grantFromForm(form); | |
| 32 | + | if (!grant.ok) return { editing: id, error: grant.error }; | |
| 33 | + | const updated = await identity.updateOAuthGrant(user, id, grant.value); | |
| 34 | + | if (!updated.ok) return { editing: id, error: updated.error.message }; | |
| 35 | + | throw redirect("/settings/applications"); | |
| 36 | + | } | |
| 24 | 37 | } | |
| 25 | 38 | return null; | |
| 26 | 39 | } | |
| 27 | 40 | ||
| 28 | − | export default function ApplicationSettings({ loaderData }: Route.ComponentProps) { | |
| 41 | + | export default function ApplicationSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 29 | 42 | const { applications } = loaderData; | |
| 43 | + | const [params] = useSearchParams(); | |
| 44 | + | const editing = actionData?.editing ?? params.get("edit"); | |
| 30 | 45 | return ( | |
| 31 | 46 | <section id="applications" className="scroll-mt-20"> | |
| 32 | 47 | {applications.length === 0 ? ( | |
| 33 | 48 | <p className="text-sm text-faint">None yet.</p> | |
| 34 | 49 | ) : ( | |
| 35 | 50 | <ul className="divide-y divide-line rounded-md border border-line"> | |
| 36 | − | {applications.map((application) => ( | |
| 37 | − | <li key={application.id} className="flex items-center gap-4 px-4 py-3"> | |
| 38 | − | <div className="min-w-0"> | |
| 39 | − | <p className="truncate text-sm">{application.clientName}</p> | |
| 40 | − | <p className="text-xs text-faint"> | |
| 41 | − | Connected <TimeAgo at={application.createdAt} /> · last used <TimeAgo at={application.lastUsedAt} /> | |
| 42 | − | </p> | |
| 43 | − | </div> | |
| 44 | − | <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" /> | |
| 45 | − | </li> | |
| 46 | − | ))} | |
| 51 | + | {applications.map((application) => { | |
| 52 | + | const legacy = application.legacy && application.scopes === null; | |
| 53 | + | const open = editing === application.id; | |
| 54 | + | return ( | |
| 55 | + | <li key={application.id} className="px-4 py-3"> | |
| 56 | + | <div className="flex flex-wrap items-start gap-x-4 gap-y-2"> | |
| 57 | + | <div className="min-w-0 grow basis-60"> | |
| 58 | + | <p className="truncate text-sm font-medium">{application.clientName}</p> | |
| 59 | + | <p className="text-xs text-faint"> | |
| 60 | + | Connected <TimeAgo at={application.createdAt} /> · last used{" "} | |
| 61 | + | <TimeAgo at={application.lastUsedAt} /> | |
| 62 | + | </p> | |
| 63 | + | <AccessSummary holder={application} /> | |
| 64 | + | {legacy && ( | |
| 65 | + | <p className="mt-1.5 text-xs text-warn"> | |
| 66 | + | Signed in before applications asked for scopes, so it can do everything you can. | |
| 67 | + | Narrow it to what it needs. | |
| 68 | + | </p> | |
| 69 | + | )} | |
| 70 | + | </div> | |
| 71 | + | <div className="ml-auto flex shrink-0 items-center gap-2"> | |
| 72 | + | {!open && ( | |
| 73 | + | <ButtonLink variant="quiet" to={`?edit=${application.id}`} preventScrollReset> | |
| 74 | + | Change access | |
| 75 | + | </ButtonLink> | |
| 76 | + | )} | |
| 77 | + | <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" /> | |
| 78 | + | </div> | |
| 79 | + | </div> | |
| 80 | + | {open && ( | |
| 81 | + | <Form method="post" className="mt-4 space-y-5 border-t border-line pt-4"> | |
| 82 | + | <input type="hidden" name="intent" value="update-application" /> | |
| 83 | + | <input type="hidden" name="id" value={application.id} /> | |
| 84 | + | <p className="text-sm text-muted"> | |
| 85 | + | {application.clientName} stays signed in. What it may do changes at once, and its | |
| 86 | + | next refresh keeps the change. | |
| 87 | + | </p> | |
| 88 | + | <ScopeChecklist initial={application.scopes} /> | |
| 89 | + | {actionData?.editing === application.id && <ErrorText>{actionData.error}</ErrorText>} | |
| 90 | + | <div className="flex gap-2"> | |
| 91 | + | <Button type="submit">Save access</Button> | |
| 92 | + | <ButtonLink variant="quiet" to="." preventScrollReset> | |
| 93 | + | Cancel | |
| 94 | + | </ButtonLink> | |
| 95 | + | </div> | |
| 96 | + | </Form> | |
| 97 | + | )} | |
| 98 | + | </li> | |
| 99 | + | ); | |
| 100 | + | })} | |
| 47 | 101 | </ul> | |
| 48 | 102 | )} | |
| 49 | 103 | </section> |
| 1 | 1 | import { identity } from "../../lib/services.server"; | |
| 2 | − | import { Form, Link } from "react-router"; | |
| 2 | + | import { Form, Link, redirect, useSearchParams } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { presetScopes, type AccessToken } from "@g1t/contracts"; | |
| 3 | 5 | ||
| 4 | 6 | import type { Route } from "./+types/tokens"; | |
| 5 | 7 | import { page } from "../../lib/meta"; | |
| 6 | − | import { Button, Field, Input, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { | |
| 9 | + | Button, | |
| 10 | + | ButtonLink, | |
| 11 | + | ErrorText, | |
| 12 | + | Field, | |
| 13 | + | Input, | |
| 14 | + | TimeAgo, | |
| 15 | + | } from "../../components/ui"; | |
| 7 | 16 | import { DeleteButton } from "../../components/account-settings"; | |
| 17 | + | import { | |
| 18 | + | AccessSummary, | |
| 19 | + | ExpiryField, | |
| 20 | + | ScopeChecklist, | |
| 21 | + | } from "../../components/token-scopes"; | |
| 22 | + | import { | |
| 23 | + | describeExpiry, | |
| 24 | + | expiryTtl, | |
| 25 | + | grantFromForm, | |
| 26 | + | } from "../../lib/token-scopes"; | |
| 8 | 27 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 9 | 28 | ||
| 10 | 29 | export function meta(args: Route.MetaArgs) { | |
| 22 | 41 | const form = await request.formData(); | |
| 23 | 42 | switch (form.get("intent")) { | |
| 24 | 43 | case "add-token": { | |
| 25 | − | const created = await identity.createAccessToken(user, String(form.get("label") ?? "")); | |
| 26 | − | return { newToken: created.token }; | |
| 44 | + | const grant = grantFromForm(form); | |
| 45 | + | if (!grant.ok) | |
| 46 | + | return { | |
| 47 | + | newToken: null, | |
| 48 | + | created: null, | |
| 49 | + | error: grant.error, | |
| 50 | + | editing: null, | |
| 51 | + | }; | |
| 52 | + | const created = await identity.createAccessToken( | |
| 53 | + | user, | |
| 54 | + | String(form.get("label") ?? ""), | |
| 55 | + | expiryTtl(form.get("expires")), | |
| 56 | + | { ...grant.value, listed: true }, | |
| 57 | + | ); | |
| 58 | + | return { | |
| 59 | + | newToken: created.token, | |
| 60 | + | created: created.info, | |
| 61 | + | error: null, | |
| 62 | + | editing: null, | |
| 63 | + | }; | |
| 64 | + | } | |
| 65 | + | case "update-token": { | |
| 66 | + | const id = String(form.get("id") ?? ""); | |
| 67 | + | const grant = grantFromForm(form); | |
| 68 | + | if (!grant.ok) | |
| 69 | + | return { | |
| 70 | + | newToken: null, | |
| 71 | + | created: null, | |
| 72 | + | error: grant.error, | |
| 73 | + | editing: id, | |
| 74 | + | }; | |
| 75 | + | const updated = await identity.updateAccessToken(user, id, grant.value); | |
| 76 | + | if (!updated.ok) | |
| 77 | + | return { | |
| 78 | + | newToken: null, | |
| 79 | + | created: null, | |
| 80 | + | error: updated.error.message, | |
| 81 | + | editing: id, | |
| 82 | + | }; | |
| 83 | + | throw redirect("/settings/tokens"); | |
| 27 | 84 | } | |
| 28 | 85 | case "delete-token": | |
| 29 | 86 | await identity.removeAccessToken(user, String(form.get("id") ?? "")); | |
| 32 | 89 | return null; | |
| 33 | 90 | } | |
| 34 | 91 | ||
| 35 | − | export default function TokenSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 92 | + | export default function TokenSettings({ | |
| 93 | + | loaderData, | |
| 94 | + | actionData, | |
| 95 | + | }: Route.ComponentProps) { | |
| 36 | 96 | const { user, tokens } = loaderData; | |
| 97 | + | const [params] = useSearchParams(); | |
| 98 | + | const editing = actionData?.editing ?? params.get("edit"); | |
| 99 | + | const workspaces = (user.workspaces ?? []).map( | |
| 100 | + | (membership) => membership.slug, | |
| 101 | + | ); | |
| 102 | + | const created = actionData?.created; | |
| 37 | 103 | return ( | |
| 38 | 104 | <section id="tokens" className="scroll-mt-20"> | |
| 39 | − | {(user.workspaces ?? []).length > 0 && ( | |
| 105 | + | {workspaces.length > 0 && ( | |
| 40 | 106 | <p className="text-sm text-muted"> | |
| 41 | − | For CI and integrations that work for a team, use a workspace's own tokens instead:{" "} | |
| 42 | − | {(user.workspaces ?? []).map((membership, i) => ( | |
| 43 | − | <span key={membership.slug}> | |
| 107 | + | For CI and integrations that work for a team, use a workspace's own | |
| 108 | + | tokens instead:{" "} | |
| 109 | + | {workspaces.map((slug, i) => ( | |
| 110 | + | <span key={slug}> | |
| 44 | 111 | {i > 0 && ", "} | |
| 45 | − | <Link to={`/${membership.slug}/-/tokens`} className="font-mono text-fg underline underline-offset-4"> | |
| 46 | − | {membership.slug} | |
| 112 | + | <Link | |
| 113 | + | to={`/${slug}/-/tokens`} | |
| 114 | + | className="font-mono text-fg underline underline-offset-4" | |
| 115 | + | > | |
| 116 | + | {slug} | |
| 47 | 117 | </Link> | |
| 48 | 118 | </span> | |
| 49 | 119 | ))} | |
| 52 | 122 | )} | |
| 53 | 123 | {actionData?.newToken && ( | |
| 54 | 124 | <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4"> | |
| 55 | − | <p className="text-sm">Copy it now. It will not be shown again.</p> | |
| 56 | − | <pre className="mt-2 overflow-x-auto font-mono text-sm text-accent">{actionData.newToken}</pre> | |
| 125 | + | <p className="text-sm"> | |
| 126 | + | {created ? ( | |
| 127 | + | <span className="font-medium">{created.name}</span> | |
| 128 | + | ) : ( | |
| 129 | + | "Your token" | |
| 130 | + | )}{" "} | |
| 131 | + | is ready. Copy it now. It will not be shown again. | |
| 132 | + | </p> | |
| 133 | + | <pre className="mt-2 font-mono text-sm break-all whitespace-pre-wrap text-accent"> | |
| 134 | + | {actionData.newToken} | |
| 135 | + | </pre> | |
| 136 | + | {created && ( | |
| 137 | + | <> | |
| 138 | + | <AccessSummary holder={created} className="mt-3" /> | |
| 139 | + | <p className="mt-1.5 text-xs text-faint"> | |
| 140 | + | {describeExpiry(created.expiresAt)} | |
| 141 | + | </p> | |
| 142 | + | </> | |
| 143 | + | )} | |
| 57 | 144 | </div> | |
| 58 | 145 | )} | |
| 59 | 146 | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> | |
| 60 | 147 | {tokens.map((token) => ( | |
| 61 | − | <li key={token.id} className="flex items-center gap-4 px-4 py-3"> | |
| 62 | − | <div className="min-w-0"> | |
| 63 | − | <p className="truncate text-sm">{token.name}</p> | |
| 64 | − | <p className="text-xs text-faint"> | |
| 65 | − | Created <TimeAgo at={token.createdAt} /> ·{" "} | |
| 66 | − | {token.lastUsedAt ? ( | |
| 67 | − | <> | |
| 68 | − | last used <TimeAgo at={token.lastUsedAt} /> | |
| 69 | − | </> | |
| 70 | − | ) : ( | |
| 71 | − | "never used" | |
| 72 | − | )} | |
| 73 | − | </p> | |
| 74 | − | </div> | |
| 75 | − | <DeleteButton intent="delete-token" id={token.id} /> | |
| 76 | − | </li> | |
| 148 | + | <TokenRow | |
| 149 | + | key={token.id} | |
| 150 | + | token={token} | |
| 151 | + | editing={editing === token.id} | |
| 152 | + | error={actionData?.editing === token.id ? actionData.error : null} | |
| 153 | + | /> | |
| 77 | 154 | ))} | |
| 78 | 155 | </ul> | |
| 79 | − | <Form method="post" className="mt-4 flex items-end gap-3"> | |
| 80 | − | <input type="hidden" name="intent" value="add-token" /> | |
| 81 | − | <div className="grow"> | |
| 82 | − | <Field label="Name"> | |
| 83 | − | <Input name="label" maxLength={100} placeholder="laptop" /> | |
| 84 | − | </Field> | |
| 156 | + | ||
| 157 | + | {!editing && ( | |
| 158 | + | <Form | |
| 159 | + | method="post" | |
| 160 | + | className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5" | |
| 161 | + | > | |
| 162 | + | <input type="hidden" name="intent" value="add-token" /> | |
| 163 | + | <h2 className="font-medium">New token</h2> | |
| 164 | + | <div className="grid gap-4 sm:grid-cols-[1fr_11rem]"> | |
| 165 | + | <Field label="Name" hint="Name it after what will use it."> | |
| 166 | + | <Input | |
| 167 | + | name="label" | |
| 168 | + | maxLength={100} | |
| 169 | + | placeholder="laptop" | |
| 170 | + | required | |
| 171 | + | /> | |
| 172 | + | </Field> | |
| 173 | + | <ExpiryField /> | |
| 174 | + | </div> | |
| 175 | + | <ScopeChecklist initial={presetScopes("agent")} /> | |
| 176 | + | {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>} | |
| 177 | + | <Button type="submit">Create token</Button> | |
| 178 | + | </Form> | |
| 179 | + | )} | |
| 180 | + | </section> | |
| 181 | + | ); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | function TokenRow({ | |
| 185 | + | token, | |
| 186 | + | editing, | |
| 187 | + | error, | |
| 188 | + | }: { | |
| 189 | + | token: AccessToken; | |
| 190 | + | editing: boolean; | |
| 191 | + | error: string | null | undefined; | |
| 192 | + | }) { | |
| 193 | + | const legacy = token.legacy && token.scopes === null; | |
| 194 | + | const expiry = describeExpiry(token.expiresAt); | |
| 195 | + | return ( | |
| 196 | + | <li className="px-4 py-3"> | |
| 197 | + | <div className="flex flex-wrap items-start gap-x-4 gap-y-2"> | |
| 198 | + | <div className="min-w-0 grow basis-60"> | |
| 199 | + | <p className="truncate text-sm font-medium">{token.name}</p> | |
| 200 | + | <p className="text-xs text-faint"> | |
| 201 | + | Created <TimeAgo at={token.createdAt} /> ·{" "} | |
| 202 | + | {token.lastUsedAt ? ( | |
| 203 | + | <> | |
| 204 | + | last used <TimeAgo at={token.lastUsedAt} /> | |
| 205 | + | </> | |
| 206 | + | ) : ( | |
| 207 | + | "never used" | |
| 208 | + | )}{" "} | |
| 209 | + | ·{" "} | |
| 210 | + | <span className={expiry === "Expired" ? "text-danger" : undefined}> | |
| 211 | + | {expiry} | |
| 212 | + | </span> | |
| 213 | + | </p> | |
| 214 | + | <AccessSummary holder={token} /> | |
| 215 | + | {legacy && ( | |
| 216 | + | <p className="mt-1.5 text-xs text-warn"> | |
| 217 | + | Made before tokens had scopes, so it can do everything you can. | |
| 218 | + | Narrow it to what it needs. | |
| 219 | + | </p> | |
| 220 | + | )} | |
| 85 | 221 | </div> | |
| 86 | − | <Button type="submit">Create token</Button> | |
| 87 | − | </Form> | |
| 88 | − | </section> | |
| 222 | + | <div className="ml-auto flex shrink-0 items-center gap-2"> | |
| 223 | + | {!editing && ( | |
| 224 | + | <ButtonLink | |
| 225 | + | variant="quiet" | |
| 226 | + | to={`?edit=${token.id}`} | |
| 227 | + | preventScrollReset | |
| 228 | + | > | |
| 229 | + | {legacy ? "Narrow this token" : "Edit access"} | |
| 230 | + | </ButtonLink> | |
| 231 | + | )} | |
| 232 | + | <DeleteButton intent="delete-token" id={token.id} /> | |
| 233 | + | </div> | |
| 234 | + | </div> | |
| 235 | + | {editing && ( | |
| 236 | + | <Form | |
| 237 | + | method="post" | |
| 238 | + | className="mt-4 space-y-5 border-t border-line pt-4" | |
| 239 | + | > | |
| 240 | + | <input type="hidden" name="intent" value="update-token" /> | |
| 241 | + | <input type="hidden" name="id" value={token.id} /> | |
| 242 | + | <p className="text-sm text-muted"> | |
| 243 | + | The token stays the same; only what it may do changes, from its next | |
| 244 | + | request. | |
| 245 | + | </p> | |
| 246 | + | <ScopeChecklist initial={token.scopes} /> | |
| 247 | + | <ErrorText>{error}</ErrorText> | |
| 248 | + | <div className="flex gap-2"> | |
| 249 | + | <Button type="submit">Save access</Button> | |
| 250 | + | <ButtonLink variant="quiet" to="." preventScrollReset> | |
| 251 | + | Cancel | |
| 252 | + | </ButtonLink> | |
| 253 | + | </div> | |
| 254 | + | </Form> | |
| 255 | + | )} | |
| 256 | + | </li> | |
| 89 | 257 | ); | |
| 90 | 258 | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.