Merge branch 'worktree-agent-ab8033ab973f8d99a'
33 files+790−1617 files not counted0/33 viewed
| 40 | 40 | mod runners; | |
| 41 | 41 | mod security; | |
| 42 | 42 | mod tools; | |
| 43 | + | mod why; | |
| 43 | 44 | mod token_policy; | |
| 44 | 45 | mod toolkit; | |
| 45 | 46 |
| 14 | 14 | Tools are resources, each with an `action`: search, repository, issue, pull_request, memory, workflow, observability, package, secret, webhook, access, team, workspace, billing, security, notifications, account, artifact. 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. | |
| 15 | 15 | Find a repository: account whoami lists your workspaces; repository list or search finds one. | |
| 16 | 16 | 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. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. | |
| 17 | − | Record your session: as you work, pull_request record_session appends your prompts, reasoning and the tools you ran to the pull request's session, so reviewers see why the change is what it is; pull_request read_session reads it back. | |
| 17 | + | Record your session: as you work, pull_request record_session appends your prompts, reasoning and the tools you ran to the pull request's session, so reviewers see why the change is what it is; pull_request read_session reads it back. pull_request record_decision keeps a decision (why the change is made this way, or what fixed a failure) and list_decisions reads them; repository why says why a line of code is the way it is. | |
| 18 | + | Merge queue: when your pull request fails combined with the ones ahead of it, g1t says which of them it breaks with and the check that failed (on the pull request, its issue and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself. | |
| 18 | 19 | Memory: memory recall before you start to learn how the project builds and what it has decided; when you learn something the next agent needs, memory remember it, one short fact at a time (scope project or workspace). Never a secret. | |
| 19 | 20 | Security: security code_alerts, vulnerability_alerts and secret_alerts show what to fix; fix it in your pull request, which the Code scanning and Dependency review checks judge. | |
| 20 | 21 | Production: observability overview and errors show what a project's running software did; follow a request or trace to its logs."; |
| 377 | 377 | &[Op::ReadSession, Op::RecordSession], | |
| 378 | 378 | ), | |
| 379 | 379 | ( | |
| 380 | + | "Decisions and why", | |
| 381 | + | "Decision records: why the work is the way it is, and how sure that is (claimed, tested by a check or the merge queue, or approved by a person). And why a line of code is the way it is: its commit, pull request, issue, decisions and session.", | |
| 382 | + | &[Op::ListDecisions, Op::RecordDecision, Op::Why], | |
| 383 | + | ), | |
| 384 | + | ( | |
| 380 | 385 | "Memory", | |
| 381 | 386 | "What agents and people learned that the next agent should know, for one project or across a workspace. Members only; never a secret.", | |
| 382 | 387 | &[Op::Remember, Op::Recall], | |
| ⋯ | |||
| 657 | 662 | Op::CreatePullRequest => "Create a pull request", | |
| 658 | 663 | Op::RecordSession => "Record session entries", | |
| 659 | 664 | Op::ReadSession => "Read a session", | |
| 665 | + | Op::RecordDecision => "Record a decision", | |
| 666 | + | Op::ListDecisions => "List a pull request's decisions", | |
| 667 | + | Op::Why => "Explain a line", | |
| 660 | 668 | Op::MarkPullRequestReady => "Mark a pull request ready", | |
| 661 | 669 | Op::ClosePullRequest => "Close a pull request", | |
| 662 | 670 | Op::ReopenPullRequest => "Reopen a pull request", | |
| ⋯ | |||
| 871 | 879 | ("DELETE", ":label") if route.path.contains("/issues/:number/") => "remove_issue_label".to_owned(), | |
| 872 | 880 | ("DELETE", "saved") => "unsave_thread".to_owned(), | |
| 873 | 881 | ("DELETE", "snooze") => "unsnooze_thread".to_owned(), | |
| 882 | + | // An issue's decisions, by its number. | |
| 883 | + | ("GET", "decisions") if route.path.contains("/issues/:number/") => "list_issue_decisions".to_owned(), | |
| 874 | 884 | _ => op.name().to_owned(), | |
| 875 | 885 | }; | |
| 876 | 886 | if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) { | |
| ⋯ | |||
| 899 | 909 | "delete_issue_subscription" => "Unsubscribe from an issue", | |
| 900 | 910 | "unsave_thread" => "Unsave a thread", | |
| 901 | 911 | "unsnooze_thread" => "Bring a snoozed thread back", | |
| 912 | + | "list_issue_decisions" => "List an issue's decisions", | |
| 902 | 913 | _ => title(route.op), | |
| 903 | 914 | }; | |
| 904 | 915 | if id.ends_with("_for_workspace") { | |
| 184 | 184 | UpdatePullRequest, | |
| 185 | 185 | RecordSession, | |
| 186 | 186 | ReadSession, | |
| 187 | + | RecordDecision, | |
| 188 | + | ListDecisions, | |
| 189 | + | Why, | |
| 187 | 190 | MarkPullRequestReady, | |
| 188 | 191 | ConvertPullRequestToDraft, | |
| 189 | 192 | ClosePullRequest, | |
| ⋯ | |||
| 686 | 689 | } | |
| 687 | 690 | ||
| 688 | 691 | impl Op { | |
| 689 | − | pub const ALL: [Op; 353] = [ | |
| 692 | + | pub const ALL: [Op; 356] = [ | |
| 690 | 693 | Op::Whoami, | |
| 691 | 694 | Op::GetWorkspace, | |
| 692 | 695 | Op::CreateWorkspace, | |
| ⋯ | |||
| 761 | 764 | Op::UpdatePullRequest, | |
| 762 | 765 | Op::RecordSession, | |
| 763 | 766 | Op::ReadSession, | |
| 767 | + | Op::RecordDecision, | |
| 768 | + | Op::ListDecisions, | |
| 769 | + | Op::Why, | |
| 764 | 770 | Op::MarkPullRequestReady, | |
| 765 | 771 | Op::ConvertPullRequestToDraft, | |
| 766 | 772 | Op::ClosePullRequest, | |
| ⋯ | |||
| 1123 | 1129 | Op::UpdatePullRequest => "update_pull_request", | |
| 1124 | 1130 | Op::RecordSession => "record_session", | |
| 1125 | 1131 | Op::ReadSession => "read_session", | |
| 1132 | + | Op::RecordDecision => "record_decision", | |
| 1133 | + | Op::ListDecisions => "list_decisions", | |
| 1134 | + | Op::Why => "why", | |
| 1126 | 1135 | Op::MarkPullRequestReady => "mark_pull_request_ready", | |
| 1127 | 1136 | Op::ClosePullRequest => "close_pull_request", | |
| 1128 | 1137 | Op::ReopenPullRequest => "reopen_pull_request", | |
| ⋯ | |||
| 1453 | 1462 | "Append entries to a pull request's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end." | |
| 1454 | 1463 | } | |
| 1455 | 1464 | Op::ReadSession => "The recorded session of a pull request, oldest entry first.", | |
| 1465 | + | Op::RecordDecision => { | |
| 1466 | + | "Record a decision on a pull request: why the change is made the way it is (proposal), something you say is true of it (claim), what you changed to fix a failure (fix), or a note. It is kept with confidence claimed, attributed to you, with the issue the pull request is for, and published as decision.recorded. evidence is an object of what it rests on, such as {\"commit\": \"…\", \"check\": \"CI\"}; session_id names the session it was made in. g1t records the decisions it saw proved itself: integration_failure and integration_passed when the merge queue combines pull requests (confidence tested), approval when a person approves (approved), and merge." | |
| 1467 | + | } | |
| 1468 | + | Op::ListDecisions => { | |
| 1469 | + | "The decision records on an issue (with those on its pull requests) or on a pull request, oldest first: each with its kind (proposal, claim, integration_failure, integration_passed, fix, approval, merge or note), its confidence (claimed: someone said so; tested: a check or a combination of pull requests proved it; approved: a person approved it), who recorded it (absent for g1t), its summary and its evidence. Issues and pull requests share numbers, so number names either." | |
| 1470 | + | } | |
| 1471 | + | Op::Why => { | |
| 1472 | + | "Why a line of a file is the way it is: the commit that last changed it (by blame, at ref or the default branch), the merged pull request it arrived in, that pull request's issue, the decision records on the pull request, and the session entries recorded around that commit (what was said and the tool calls that touched the file, at most eight). pull, issue, decisions and sessions are empty when the commit did not arrive through a pull request." | |
| 1473 | + | } | |
| 1456 | 1474 | Op::MarkPullRequestReady => { | |
| 1457 | 1475 | "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why." | |
| 1458 | 1476 | } | |
| ⋯ | |||
| 2408 | 2426 | })), | |
| 2409 | 2427 | &["repo", "number"], | |
| 2410 | 2428 | ), | |
| 2429 | + | Op::RecordDecision => object( | |
| 2430 | + | numbered(json!({ | |
| 2431 | + | "kind": { | |
| 2432 | + | "type": "string", | |
| 2433 | + | "enum": ["proposal", "claim", "fix", "note"], | |
| 2434 | + | "description": "What it is: why the change is made this way (proposal), something true of it (claim), what fixed a failure (fix), or a note.", | |
| 2435 | + | }, | |
| 2436 | + | "summary": { "type": "string", "description": "The decision, in a sentence or two. At most 2,000 characters." }, | |
| 2437 | + | "evidence": { | |
| 2438 | + | "type": "object", | |
| 2439 | + | "description": "What it rests on, such as a commit, a check's name or the pull requests involved. At most 8 KB of JSON.", | |
| 2440 | + | }, | |
| 2441 | + | "session_id": { "type": "string", "description": "The session it was made in, if you have one." }, | |
| 2442 | + | })), | |
| 2443 | + | &["repo", "number", "kind", "summary"], | |
| 2444 | + | ), | |
| 2445 | + | Op::ListDecisions => object(numbered(json!({})), &["repo", "number"]), | |
| 2446 | + | Op::Why => object( | |
| 2447 | + | json!({ | |
| 2448 | + | "repo": repo_schema(), | |
| 2449 | + | "path": { "type": "string", "description": "The file, from the repository's root, such as \"src/main.rs\"." }, | |
| 2450 | + | "line": { "type": "integer", "description": "The line, counted from 1." }, | |
| 2451 | + | "ref": { "type": "string", "description": "A branch, tag or commit. The default branch if not given." }, | |
| 2452 | + | }), | |
| 2453 | + | &["repo", "path", "line"], | |
| 2454 | + | ), | |
| 2411 | 2455 | Op::MarkPullRequestReady => object( | |
| 2412 | 2456 | numbered(json!({ "summary": { "type": "string", "description": "Markdown." } })), | |
| 2413 | 2457 | &["repo", "number", "summary"], | |
| ⋯ | |||
| 3146 | 3190 | | Op::ListPullRequests | |
| 3147 | 3191 | | Op::GetPullRequest | |
| 3148 | 3192 | | Op::ReadSession | |
| 3193 | + | | Op::ListDecisions | |
| 3194 | + | | Op::Why | |
| 3149 | 3195 | | Op::GetPullRequestChanges | |
| 3150 | 3196 | | Op::ListEvents | |
| 3151 | 3197 | | Op::GetRepoSettings | |
| ⋯ | |||
| 4445 | 4491 | .await | |
| 4446 | 4492 | } | |
| 4447 | 4493 | Op::ReadSession => pass(work, "read_session", &view()).await, | |
| 4494 | + | Op::RecordDecision => { | |
| 4495 | + | let Some(kind) = input["kind"].as_str().and_then(g1t_contracts::decisions::DecisionKind::parse) else { | |
| 4496 | + | return failed(FailureCode::Invalid, "kind must be proposal, claim, fix or note."); | |
| 4497 | + | }; | |
| 4498 | + | pass( | |
| 4499 | + | work, | |
| 4500 | + | "record_decision", | |
| 4501 | + | &g1t_contracts::decisions::RecordDecisionArgs { | |
| 4502 | + | actor: actor(), | |
| 4503 | + | repo, | |
| 4504 | + | number, | |
| 4505 | + | kind, | |
| 4506 | + | summary: text(input, "summary"), | |
| 4507 | + | evidence: input.get("evidence").cloned(), | |
| 4508 | + | session_id: optional_text(input, "session_id"), | |
| 4509 | + | }, | |
| 4510 | + | ) | |
| 4511 | + | .await | |
| 4512 | + | } | |
| 4513 | + | Op::ListDecisions => { | |
| 4514 | + | pass( | |
| 4515 | + | work, | |
| 4516 | + | "list_decisions", | |
| 4517 | + | &g1t_contracts::decisions::ListDecisionsArgs { repo, viewer: viewer.clone(), number }, | |
| 4518 | + | ) | |
| 4519 | + | .await | |
| 4520 | + | } | |
| 4521 | + | Op::Why => { | |
| 4522 | + | let Some(line) = integer(input, "line").filter(|line| *line > 0) else { | |
| 4523 | + | return failed(FailureCode::Invalid, "Give line, counted from 1."); | |
| 4524 | + | }; | |
| 4525 | + | let path = text(input, "path"); | |
| 4526 | + | if path.trim().is_empty() { | |
| 4527 | + | return failed(FailureCode::Invalid, "Give path: the file, from the repository's root."); | |
| 4528 | + | } | |
| 4529 | + | crate::why::why(services, viewer, repo, path, line, optional_text(input, "ref")).await | |
| 4530 | + | } | |
| 4448 | 4531 | Op::MarkPullRequestReady => pass(work, "ready_pull", &pull_action()).await, | |
| 4449 | 4532 | Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await, | |
| 4450 | 4533 | Op::ReopenPullRequest => pass(work, "reopen_pull", &pull_action()).await, | |
| 2227 | 2227 | }, | |
| 2228 | 2228 | "notes": "Up to 200 entries can be appended per request. Each is recorded against the pull request's head commit at the time. See [sessions and why-blame](/guides/why-blame/)." | |
| 2229 | 2229 | }, | |
| 2230 | + | "record_decision": { | |
| 2231 | + | "request": { | |
| 2232 | + | "kind": "fix", | |
| 2233 | + | "summary": "Renamed the config key #12 also introduced, so both read retry_limit.", | |
| 2234 | + | "evidence": { | |
| 2235 | + | "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b" | |
| 2236 | + | }, | |
| 2237 | + | "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh" | |
| 2238 | + | }, | |
| 2239 | + | "response": { | |
| 2240 | + | "id": "dec_01kpz9a1b3c5d7e9f1g3h5j7kb", | |
| 2241 | + | "issue": 9, | |
| 2242 | + | "pull": 14, | |
| 2243 | + | "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh", | |
| 2244 | + | "actor": "ana", | |
| 2245 | + | "kind": "fix", | |
| 2246 | + | "confidence": "claimed", | |
| 2247 | + | "summary": "Renamed the config key #12 also introduced, so both read retry_limit.", | |
| 2248 | + | "evidence": { | |
| 2249 | + | "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b" | |
| 2250 | + | }, | |
| 2251 | + | "created_at": "2026-10-12T15:31:40.019Z" | |
| 2252 | + | }, | |
| 2253 | + | "notes": "Kept as `claimed`. g1t records `integration_failure`, `integration_passed`, `approval` and `merge` itself. Publishes `decision.recorded`. See [decision records](/guides/why-blame/#decision-records)." | |
| 2254 | + | }, | |
| 2255 | + | "list_decisions": { | |
| 2256 | + | "response": [ | |
| 2257 | + | { | |
| 2258 | + | "id": "dec_01kpz8m2q4r6s8t0v2w4x6y8za", | |
| 2259 | + | "issue": 9, | |
| 2260 | + | "pull": 14, | |
| 2261 | + | "session_id": null, | |
| 2262 | + | "actor": null, | |
| 2263 | + | "kind": "integration_failure", | |
| 2264 | + | "confidence": "tested", | |
| 2265 | + | "summary": "#14 failed in the merge queue together with #12: `CI / merge_group` failed when they were combined.", | |
| 2266 | + | "evidence": { | |
| 2267 | + | "commit": "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b", | |
| 2268 | + | "check": { | |
| 2269 | + | "name": "CI / merge_group", | |
| 2270 | + | "conclusion": "failure", | |
| 2271 | + | "url": "https://g1t.sh/acme/web/actions/runs/run_01kpz8k0" | |
| 2272 | + | }, | |
| 2273 | + | "combination": [ | |
| 2274 | + | 11, | |
| 2275 | + | 12, | |
| 2276 | + | 14 | |
| 2277 | + | ], | |
| 2278 | + | "other_pulls": [ | |
| 2279 | + | 11, | |
| 2280 | + | 12 | |
| 2281 | + | ], | |
| 2282 | + | "suspects": [ | |
| 2283 | + | 12 | |
| 2284 | + | ], | |
| 2285 | + | "bisected": true | |
| 2286 | + | }, | |
| 2287 | + | "created_at": "2026-10-12T15:04:11.204Z" | |
| 2288 | + | }, | |
| 2289 | + | { | |
| 2290 | + | "id": "dec_01kpz9a1b3c5d7e9f1g3h5j7kb", | |
| 2291 | + | "issue": 9, | |
| 2292 | + | "pull": 14, | |
| 2293 | + | "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh", | |
| 2294 | + | "actor": "ana", | |
| 2295 | + | "kind": "fix", | |
| 2296 | + | "confidence": "claimed", | |
| 2297 | + | "summary": "Renamed the config key #12 also introduced, so both read retry_limit.", | |
| 2298 | + | "evidence": { | |
| 2299 | + | "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b" | |
| 2300 | + | }, | |
| 2301 | + | "created_at": "2026-10-12T15:31:40.019Z" | |
| 2302 | + | } | |
| 2303 | + | ], | |
| 2304 | + | "notes": "`GET /repos/{owner}/{name}/issues/{number}/decisions` lists an issue's, with those on its pull requests." | |
| 2305 | + | }, | |
| 2306 | + | "why": { | |
| 2307 | + | "query": { | |
| 2308 | + | "path": "src/config.rs", | |
| 2309 | + | "line": 42 | |
| 2310 | + | }, | |
| 2311 | + | "response": { | |
| 2312 | + | "path": "src/config.rs", | |
| 2313 | + | "line": 42, | |
| 2314 | + | "commit": { | |
| 2315 | + | "hash": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b", | |
| 2316 | + | "tree_hash": "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0", | |
| 2317 | + | "message": "Read retry_limit, as #12 names it", | |
| 2318 | + | "author": { | |
| 2319 | + | "name": "Ana Lima", | |
| 2320 | + | "email": "ana@example.com" | |
| 2321 | + | }, | |
| 2322 | + | "parents": [ | |
| 2323 | + | "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b" | |
| 2324 | + | ], | |
| 2325 | + | "authored_at": "2026-10-12T15:29:02Z" | |
| 2326 | + | }, | |
| 2327 | + | "pull": { | |
| 2328 | + | "number": 14, | |
| 2329 | + | "title": "Retry failed webhook deliveries", | |
| 2330 | + | "author": "ana", | |
| 2331 | + | "agent": "claude-code", | |
| 2332 | + | "merged_by": "g1t", | |
| 2333 | + | "merged_at": "2026-10-12T15:52:18.330Z" | |
| 2334 | + | }, | |
| 2335 | + | "issue": { | |
| 2336 | + | "number": 9, | |
| 2337 | + | "title": "Webhook deliveries are not retried", | |
| 2338 | + | "state": "closed", | |
| 2339 | + | "body": "A delivery that times out is never sent again." | |
| 2340 | + | }, | |
| 2341 | + | "decisions": [ | |
| 2342 | + | { | |
| 2343 | + | "id": "dec_01kpz8m2q4r6s8t0v2w4x6y8za", | |
| 2344 | + | "issue": 9, | |
| 2345 | + | "pull": 14, | |
| 2346 | + | "session_id": null, | |
| 2347 | + | "actor": null, | |
| 2348 | + | "kind": "integration_failure", | |
| 2349 | + | "confidence": "tested", | |
| 2350 | + | "summary": "#14 failed in the merge queue together with #12: `CI / merge_group` failed when they were combined.", | |
| 2351 | + | "evidence": { | |
| 2352 | + | "commit": "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b", | |
| 2353 | + | "check": { | |
| 2354 | + | "name": "CI / merge_group", | |
| 2355 | + | "conclusion": "failure", | |
| 2356 | + | "url": "https://g1t.sh/acme/web/actions/runs/run_01kpz8k0" | |
| 2357 | + | }, | |
| 2358 | + | "combination": [ | |
| 2359 | + | 11, | |
| 2360 | + | 12, | |
| 2361 | + | 14 | |
| 2362 | + | ], | |
| 2363 | + | "other_pulls": [ | |
| 2364 | + | 11, | |
| 2365 | + | 12 | |
| 2366 | + | ], | |
| 2367 | + | "suspects": [ | |
| 2368 | + | 12 | |
| 2369 | + | ], | |
| 2370 | + | "bisected": true | |
| 2371 | + | }, | |
| 2372 | + | "created_at": "2026-10-12T15:04:11.204Z" | |
| 2373 | + | }, | |
| 2374 | + | { | |
| 2375 | + | "id": "dec_01kpz9a1b3c5d7e9f1g3h5j7kb", | |
| 2376 | + | "issue": 9, | |
| 2377 | + | "pull": 14, | |
| 2378 | + | "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh", | |
| 2379 | + | "actor": "ana", | |
| 2380 | + | "kind": "fix", | |
| 2381 | + | "confidence": "claimed", | |
| 2382 | + | "summary": "Renamed the config key #12 also introduced, so both read retry_limit.", | |
| 2383 | + | "evidence": { | |
| 2384 | + | "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b" | |
| 2385 | + | }, | |
| 2386 | + | "created_at": "2026-10-12T15:31:40.019Z" | |
| 2387 | + | } | |
| 2388 | + | ], | |
| 2389 | + | "sessions": [ | |
| 2390 | + | { | |
| 2391 | + | "seq": 31, | |
| 2392 | + | "kind": "message", | |
| 2393 | + | "text": "#12 renamed retries to retry_limit; reading the new name here.", | |
| 2394 | + | "tool": null, | |
| 2395 | + | "commit": null, | |
| 2396 | + | "at": "2026-10-12T15:27:40.118Z" | |
| 2397 | + | } | |
| 2398 | + | ] | |
| 2399 | + | }, | |
| 2400 | + | "notes": "`ref` reads the file at a branch, tag or commit. See [sessions and why-blame](/guides/why-blame/#why-over-the-api)." | |
| 2401 | + | }, | |
| 2230 | 2402 | "list_workflows": { | |
| 2231 | 2403 | "response": [ | |
| 2232 | 2404 | { |
| 167 | 167 | Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent), | |
| 168 | 168 | Op::ListCheckNames => through::<Vec<work::SeenCheck>>(op, sent), | |
| 169 | 169 | Op::GetMergeQueue => through::<work::QueueView>(op, sent), | |
| 170 | + | Op::ListDecisions => through::<Vec<g1t_contracts::decisions::Decision>>(op, sent), | |
| 171 | + | Op::RecordDecision => through::<g1t_contracts::decisions::Decision>(op, sent), | |
| 172 | + | Op::Why => through::<g1t_contracts::decisions::Why>(op, sent), | |
| 170 | 173 | Op::ListIssues => through::<Vec<work::Issue>>(op, sent), | |
| 171 | 174 | Op::CreateIssue | Op::UpdateIssue | Op::CloseIssue | Op::ReopenIssue => { | |
| 172 | 175 | through::<work::Issue>(op, sent) |
| 997 | 997 | Op::RecordSession, | |
| 998 | 998 | &[], | |
| 999 | 999 | ), | |
| 1000 | + | // Decision records, on a pull request or an issue (with its pull | |
| 1001 | + | // requests'), and why a line is the way it is. | |
| 1002 | + | route("GET", "/repos/:owner/:name/pulls/:number/decisions", Op::ListDecisions, &[]), | |
| 1003 | + | route("POST", "/repos/:owner/:name/pulls/:number/decisions", Op::RecordDecision, &[]), | |
| 1004 | + | route("GET", "/repos/:owner/:name/issues/:number/decisions", Op::ListDecisions, &[]), | |
| 1005 | + | route("GET", "/repos/:owner/:name/why", Op::Why, &[("path", "path"), ("line", "line"), ("ref", "ref")]), | |
| 1000 | 1006 | route( | |
| 1001 | 1007 | "POST", | |
| 1002 | 1008 | "/repos/:owner/:name/pulls/:number/ready", |
| 126 | 126 | a("list_deleted", Op::ListDeletedRepos, "A workspace's deleted repositories"), | |
| 127 | 127 | a("restore", Op::RestoreRepo, "Restore a deleted one"), | |
| 128 | 128 | a("purge", Op::PurgeRepo, "Remove a deleted one for good"), | |
| 129 | + | a("why", Op::Why, "Why a line is the way it is: commit, pull request, issue, decisions, session"), | |
| 129 | 130 | a("security_alerts", Op::ListSecurityAlerts, "Secret and dependency alerts, filtered by state"), | |
| 130 | 131 | a("dismiss_alert", Op::DismissSecurityAlert, "Dismiss an alert with a reason"), | |
| 131 | 132 | a("reopen_alert", Op::ReopenSecurityAlert, "Reopen a dismissed alert"), | |
| ⋯ | |||
| 166 | 167 | a("update", Op::UpdatePullRequest, "Change its base branch, labels, milestone, assignees or reviewers"), | |
| 167 | 168 | a("record_session", Op::RecordSession, "Append prompt, reasoning and tool entries to its session"), | |
| 168 | 169 | a("read_session", Op::ReadSession, "Its recorded session"), | |
| 170 | + | a("record_decision", Op::RecordDecision, "Record why: a proposal, claim, fix or note, kept as claimed"), | |
| 171 | + | a("list_decisions", Op::ListDecisions, "Decision records on a pull request, or an issue and its pull requests"), | |
| 169 | 172 | a("ready", Op::MarkPullRequestReady, "Mark a draft ready, with a summary"), | |
| 170 | 173 | a("draft", Op::ConvertPullRequestToDraft, "Turn it back into a draft"), | |
| 171 | 174 | a("request_reviewers", Op::RequestReviewers, "Ask people or teams to review it"), | |
| 1 | + | //! `why`: why a line of a file is the way it is. | |
| 2 | + | //! | |
| 3 | + | //! The line's commit comes from the repos service's blame; the merged pull | |
| 4 | + | //! request it arrived in is the one whose head it is, or else the one whose | |
| 5 | + | //! stretch of the default branch's history holds it (as the site's blame | |
| 6 | + | //! view finds it, `apps/web/app/lib/provenance.server.ts`); then that pull | |
| 7 | + | //! request's issue, its decision records, and the session entries recorded | |
| 8 | + | //! around the commit. | |
| 9 | + | ||
| 10 | + | use g1t_contracts::decisions::{Decision, ListDecisionsArgs, Why, WhyIssue, WhyPull}; | |
| 11 | + | use g1t_contracts::repos::{Blame, BlameArgs, Commit, LogArgs, RepoPath}; | |
| 12 | + | use g1t_contracts::work::{IssueDetail, ListPullsArgs, Pull, PullStatus, SessionEntry, SessionEntryKind, State, ViewArgs}; | |
| 13 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 14 | + | use serde_json::Value; | |
| 15 | + | use worker::Result; | |
| 16 | + | ||
| 17 | + | use crate::operations::Services; | |
| 18 | + | ||
| 19 | + | /// How far back the default branch is read to place a commit. | |
| 20 | + | const HISTORY_READ: u32 = 300; | |
| 21 | + | /// How many steps of a session are shown for one commit. | |
| 22 | + | const MAX_STEPS: usize = 8; | |
| 23 | + | /// Longest a step's text is shown. | |
| 24 | + | const MAX_STEP_CHARS: usize = 600; | |
| 25 | + | /// Longest an issue's description is shown. | |
| 26 | + | const MAX_BODY_CHARS: usize = 900; | |
| 27 | + | ||
| 28 | + | /// The commit that last changed `line`, from a blame of the file. | |
| 29 | + | pub(crate) fn commit_of_line(blame: &Blame, line: u32) -> Option<&Commit> { | |
| 30 | + | let range = blame.ranges.iter().find(|range| range.start <= line && line <= range.end)?; | |
| 31 | + | blame.commits.iter().find(|commit| commit.hash == range.commit) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// The merged pull request `hash` arrived in: the one whose head it is, | |
| 35 | + | /// or else the one whose stretch of `history` (the default branch, newest | |
| 36 | + | /// first) holds it, from its head back to the commit it started from. | |
| 37 | + | pub(crate) fn pull_for_commit<'a>(merged: &'a [Pull], history: &[String], hash: &str) -> Option<&'a Pull> { | |
| 38 | + | let merged: Vec<&Pull> = merged | |
| 39 | + | .iter() | |
| 40 | + | .filter(|pull| pull.status == PullStatus::Merged && pull.head_commit.is_some()) | |
| 41 | + | .collect(); | |
| 42 | + | if let Some(direct) = merged.iter().find(|pull| pull.head_commit.as_deref() == Some(hash)) { | |
| 43 | + | return Some(direct); | |
| 44 | + | } | |
| 45 | + | let position = |commit: &str| history.iter().position(|known| known == commit); | |
| 46 | + | let at = position(hash)?; | |
| 47 | + | let mut best: Option<(&Pull, usize)> = None; | |
| 48 | + | for pull in merged { | |
| 49 | + | let Some(head) = pull.head_commit.as_deref().and_then(position) else { continue }; | |
| 50 | + | if head > at { | |
| 51 | + | continue; | |
| 52 | + | } | |
| 53 | + | if let Some(base) = pull.merge_base.as_deref().and_then(position) | |
| 54 | + | && base <= at | |
| 55 | + | { | |
| 56 | + | continue; | |
| 57 | + | } | |
| 58 | + | if best.is_none_or(|(_, best_head)| head > best_head) { | |
| 59 | + | best = Some((pull, head)); | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | best.map(|(pull, _)| pull) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// The steps of a session behind `hash`: what was said and done between | |
| 66 | + | /// the push before it and the push that carried it. Steps that touch | |
| 67 | + | /// `file` come first; at most [`MAX_STEPS`], each cut to | |
| 68 | + | /// [`MAX_STEP_CHARS`]. | |
| 69 | + | pub(crate) fn steps_for(session: &[SessionEntry], hash: &str, file: &str) -> Vec<SessionEntry> { | |
| 70 | + | // A push is noted "Pushed b1c42490059b." | |
| 71 | + | let pushed = |entry: &SessionEntry| -> Option<String> { | |
| 72 | + | if entry.kind != SessionEntryKind::Note { | |
| 73 | + | return None; | |
| 74 | + | } | |
| 75 | + | let rest = entry.text.strip_prefix("Pushed ")?; | |
| 76 | + | let short: String = rest.chars().take_while(char::is_ascii_hexdigit).collect(); | |
| 77 | + | (short.len() >= 7).then_some(short) | |
| 78 | + | }; | |
| 79 | + | let end = session.iter().position(|entry| { | |
| 80 | + | entry.commit.as_deref() == Some(hash) || pushed(entry).is_some_and(|short| hash.starts_with(&short)) | |
| 81 | + | }); | |
| 82 | + | let window: &[SessionEntry] = match end { | |
| 83 | + | Some(end) => { | |
| 84 | + | let mut start = end; | |
| 85 | + | while start > 0 && pushed(&session[start - 1]).is_none() { | |
| 86 | + | start -= 1; | |
| 87 | + | } | |
| 88 | + | &session[start..=end] | |
| 89 | + | } | |
| 90 | + | None => session, | |
| 91 | + | }; | |
| 92 | + | let name = file.rsplit('/').next().unwrap_or(file); | |
| 93 | + | let useful: Vec<&SessionEntry> = window | |
| 94 | + | .iter() | |
| 95 | + | .filter(|entry| { | |
| 96 | + | entry.kind == SessionEntryKind::Message | |
| 97 | + | || (entry.kind == SessionEntryKind::ToolCall && (entry.text.contains(file) || entry.text.contains(name))) | |
| 98 | + | }) | |
| 99 | + | .collect(); | |
| 100 | + | let touching: Vec<&SessionEntry> = useful.iter().copied().filter(|entry| entry.text.contains(name)).collect(); | |
| 101 | + | let mut chosen: Vec<&SessionEntry> = if touching.is_empty() { | |
| 102 | + | useful.clone() | |
| 103 | + | } else { | |
| 104 | + | touching.into_iter().chain(useful.iter().copied().filter(|entry| entry.kind == SessionEntryKind::Message)).collect() | |
| 105 | + | }; | |
| 106 | + | chosen.sort_by_key(|entry| entry.seq); | |
| 107 | + | chosen.dedup_by_key(|entry| entry.seq); | |
| 108 | + | let skip = chosen.len().saturating_sub(MAX_STEPS); | |
| 109 | + | chosen | |
| 110 | + | .into_iter() | |
| 111 | + | .skip(skip) | |
| 112 | + | .map(|entry| { | |
| 113 | + | let mut entry = entry.clone(); | |
| 114 | + | if entry.text.chars().count() > MAX_STEP_CHARS { | |
| 115 | + | entry.text = format!("{}…", entry.text.chars().take(MAX_STEP_CHARS).collect::<String>()); | |
| 116 | + | } | |
| 117 | + | entry | |
| 118 | + | }) | |
| 119 | + | .collect() | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Answers `why`. | |
| 123 | + | pub(crate) async fn why( | |
| 124 | + | services: &Services, | |
| 125 | + | viewer: &Viewer, | |
| 126 | + | repo: RepoPath, | |
| 127 | + | path: String, | |
| 128 | + | line: u32, | |
| 129 | + | git_ref: Option<String>, | |
| 130 | + | ) -> Result<Outcome<Value>> { | |
| 131 | + | let blame: Outcome<Blame> = g1t_kit::call( | |
| 132 | + | &services.repos, | |
| 133 | + | "blame", | |
| 134 | + | &BlameArgs { | |
| 135 | + | path: repo.clone(), | |
| 136 | + | viewer: viewer.clone(), | |
| 137 | + | git_ref, | |
| 138 | + | file_path: path.clone(), | |
| 139 | + | }, | |
| 140 | + | ) | |
| 141 | + | .await?; | |
| 142 | + | let blame = match blame { | |
| 143 | + | Outcome::Ok(blame) => blame, | |
| 144 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 145 | + | }; | |
| 146 | + | let Some(commit) = commit_of_line(&blame, line).cloned() else { | |
| 147 | + | let lines = blame.ranges.last().map_or(0, |range| range.end); | |
| 148 | + | return Ok(Outcome::fail( | |
| 149 | + | FailureCode::Invalid, | |
| 150 | + | format!("{path} has {lines} lines at that ref: give a line from 1 to {lines}."), | |
| 151 | + | )); | |
| 152 | + | }; | |
| 153 | + | ||
| 154 | + | let closed: Outcome<Vec<Pull>> = g1t_kit::call( | |
| 155 | + | &services.work, | |
| 156 | + | "list_pulls", | |
| 157 | + | &ListPullsArgs { | |
| 158 | + | repo: repo.clone(), | |
| 159 | + | viewer: viewer.clone(), | |
| 160 | + | state: Some(State::Closed), | |
| 161 | + | label: None, | |
| 162 | + | milestone: None, | |
| 163 | + | base: None, | |
| 164 | + | }, | |
| 165 | + | ) | |
| 166 | + | .await?; | |
| 167 | + | let merged = match closed { | |
| 168 | + | Outcome::Ok(pulls) => pulls, | |
| 169 | + | Outcome::Fail(_) => Vec::new(), | |
| 170 | + | }; | |
| 171 | + | let mut pull = pull_for_commit(&merged, &[], &commit.hash).cloned(); | |
| 172 | + | if pull.is_none() && !merged.is_empty() { | |
| 173 | + | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 174 | + | &services.repos, | |
| 175 | + | "log", | |
| 176 | + | &LogArgs { | |
| 177 | + | path: repo.clone(), | |
| 178 | + | viewer: viewer.clone(), | |
| 179 | + | git_ref: None, | |
| 180 | + | limit: HISTORY_READ, | |
| 181 | + | }, | |
| 182 | + | ) | |
| 183 | + | .await?; | |
| 184 | + | if let Outcome::Ok(log) = log { | |
| 185 | + | let history: Vec<String> = log.into_iter().map(|commit| commit.hash).collect(); | |
| 186 | + | pull = pull_for_commit(&merged, &history, &commit.hash).cloned(); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | let Some(pull) = pull else { | |
| 191 | + | return ok(Why { path, line, commit, pull: None, issue: None, decisions: Vec::new(), sessions: Vec::new() }); | |
| 192 | + | }; | |
| 193 | + | let view = |number: u32| ViewArgs { repo: repo.clone(), number, viewer: viewer.clone(), after_seq: 0 }; | |
| 194 | + | // A few small reads, one after another: the API has no join helper. | |
| 195 | + | let issue = match pull.issue { | |
| 196 | + | Some(number) => match g1t_kit::call::<_, Outcome<IssueDetail>>(&services.work, "get_issue", &view(number)).await? { | |
| 197 | + | Outcome::Ok(detail) => Some(detail.issue), | |
| 198 | + | Outcome::Fail(_) => None, | |
| 199 | + | }, | |
| 200 | + | None => None, | |
| 201 | + | }; | |
| 202 | + | let decisions: Outcome<Vec<Decision>> = g1t_kit::call( | |
| 203 | + | &services.work, | |
| 204 | + | "list_decisions", | |
| 205 | + | &ListDecisionsArgs { repo: repo.clone(), viewer: viewer.clone(), number: pull.number }, | |
| 206 | + | ) | |
| 207 | + | .await?; | |
| 208 | + | let session: Outcome<Vec<SessionEntry>> = g1t_kit::call(&services.work, "read_session", &view(pull.number)).await?; | |
| 209 | + | let decisions = match decisions { | |
| 210 | + | Outcome::Ok(decisions) => decisions, | |
| 211 | + | Outcome::Fail(_) => Vec::new(), | |
| 212 | + | }; | |
| 213 | + | let sessions = match session { | |
| 214 | + | Outcome::Ok(entries) => steps_for(&entries, &commit.hash, &path), | |
| 215 | + | Outcome::Fail(_) => Vec::new(), | |
| 216 | + | }; | |
| 217 | + | let issue = issue.map(|issue| WhyIssue { | |
| 218 | + | number: issue.number, | |
| 219 | + | title: issue.title, | |
| 220 | + | state: if issue.state == State::Open { "open" } else { "closed" }.to_owned(), | |
| 221 | + | body: if issue.body.chars().count() > MAX_BODY_CHARS { | |
| 222 | + | format!("{}…", issue.body.chars().take(MAX_BODY_CHARS).collect::<String>()) | |
| 223 | + | } else { | |
| 224 | + | issue.body | |
| 225 | + | }, | |
| 226 | + | }); | |
| 227 | + | let pull = WhyPull { | |
| 228 | + | number: pull.number, | |
| 229 | + | title: pull.title.clone(), | |
| 230 | + | author: pull.owner().username.clone(), | |
| 231 | + | agent: pull.agent.clone(), | |
| 232 | + | merged_by: pull.merged_by.clone(), | |
| 233 | + | merged_at: pull.merged_at.clone(), | |
| 234 | + | }; | |
| 235 | + | ok(Why { path, line, commit, pull: Some(pull), issue, decisions, sessions }) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | fn ok(why: Why) -> Result<Outcome<Value>> { | |
| 239 | + | Ok(Outcome::Ok(serde_json::to_value(why)?)) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[cfg(test)] | |
| 243 | + | mod tests { | |
| 244 | + | use super::*; | |
| 245 | + | use g1t_contracts::repos::{BlameRange, Signature}; | |
| 246 | + | ||
| 247 | + | fn commit(hash: &str) -> Commit { | |
| 248 | + | Commit { | |
| 249 | + | hash: hash.into(), | |
| 250 | + | tree_hash: "t".into(), | |
| 251 | + | message: "m".into(), | |
| 252 | + | author: Signature { name: "Ana".into(), email: "ana@example.com".into() }, | |
| 253 | + | parents: Vec::new(), | |
| 254 | + | authored_at: "2026-10-12T00:00:00Z".into(), | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | fn merged(number: u32, head: &str, base: Option<&str>) -> Pull { | |
| 259 | + | serde_json::from_value(serde_json::json!({ | |
| 260 | + | "id": format!("pr_{number}"), "repoId": "rep_1", "number": number, "issue": 9, "title": "t", "body": null, | |
| 261 | + | "agent": "claude-code", "runtime": "external", "status": "merged", "fork": null, "forkRepoId": null, | |
| 262 | + | "branch": "b", "headCommit": head, "mergeBase": base, "mergedBy": "ana", "mergedAt": "2026-10-12T00:00:00Z", | |
| 263 | + | "supersededBy": null, "checkStatus": null, | |
| 264 | + | "author": { "id": "usr_1", "username": "ana" }, | |
| 265 | + | "createdAt": "", "updatedAt": "" | |
| 266 | + | })) | |
| 267 | + | .unwrap() | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn a_line_is_found_in_its_range() { | |
| 272 | + | let blame = Blame { | |
| 273 | + | head: "h".into(), | |
| 274 | + | ranges: vec![ | |
| 275 | + | BlameRange { start: 1, end: 3, commit: "aaa".into() }, | |
| 276 | + | BlameRange { start: 4, end: 4, commit: "bbb".into() }, | |
| 277 | + | ], | |
| 278 | + | commits: vec![commit("aaa"), commit("bbb")], | |
| 279 | + | partial: false, | |
| 280 | + | }; | |
| 281 | + | assert_eq!(commit_of_line(&blame, 3).map(|c| c.hash.as_str()), Some("aaa")); | |
| 282 | + | assert_eq!(commit_of_line(&blame, 4).map(|c| c.hash.as_str()), Some("bbb")); | |
| 283 | + | assert!(commit_of_line(&blame, 5).is_none()); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn a_commit_is_placed_in_the_pull_request_it_arrived_in() { | |
| 288 | + | // Newest first: m2 landed #12 on top of b1; m1 landed #11 on top of b0. | |
| 289 | + | let history: Vec<String> = ["m2", "c3", "c2", "b1", "m1", "c1", "b0"].map(String::from).to_vec(); | |
| 290 | + | let pulls = vec![merged(11, "m1", Some("b0")), merged(12, "m2", Some("b1"))]; | |
| 291 | + | assert_eq!(pull_for_commit(&pulls, &[], "m1").map(|p| p.number), Some(11)); | |
| 292 | + | assert_eq!(pull_for_commit(&pulls, &history, "c2").map(|p| p.number), Some(12)); | |
| 293 | + | assert_eq!(pull_for_commit(&pulls, &history, "c1").map(|p| p.number), Some(11)); | |
| 294 | + | // Before any pull request. | |
| 295 | + | assert!(pull_for_commit(&pulls, &history, "b0").is_none()); | |
| 296 | + | assert!(pull_for_commit(&pulls, &history, "zzz").is_none()); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | fn entry(seq: u32, kind: SessionEntryKind, text: &str, commit: Option<&str>) -> SessionEntry { | |
| 300 | + | SessionEntry { seq, kind, text: text.into(), tool: None, commit: commit.map(String::from), at: String::new() } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[test] | |
| 304 | + | fn the_session_steps_are_those_behind_the_commit() { | |
| 305 | + | use SessionEntryKind::*; | |
| 306 | + | let session = vec![ | |
| 307 | + | entry(1, Message, "Starting on the parser.", None), | |
| 308 | + | entry(2, Note, "Pushed 1111111aaaa.", None), | |
| 309 | + | entry(3, Message, "The lexer drops the last token.", None), | |
| 310 | + | entry(4, ToolCall, "Edit src/lexer.rs", None), | |
| 311 | + | entry(5, ToolCall, "Run cargo test", None), | |
| 312 | + | entry(6, Note, "Pushed 2222222bbbb.", None), | |
| 313 | + | entry(7, Message, "Now the docs.", None), | |
| 314 | + | ]; | |
| 315 | + | let steps = steps_for(&session, "2222222bbbbcccc", "src/lexer.rs"); | |
| 316 | + | let seqs: Vec<u32> = steps.iter().map(|step| step.seq).collect(); | |
| 317 | + | assert_eq!(seqs, [3, 4]); | |
| 318 | + | // A commit no push names: the whole session is looked through. | |
| 319 | + | let all = steps_for(&session, "9999999", "src/lexer.rs"); | |
| 320 | + | assert_eq!(all.iter().map(|s| s.seq).collect::<Vec<_>>(), [1, 3, 4, 7]); | |
| 321 | + | let long = vec![entry(1, Message, &"x".repeat(MAX_STEP_CHARS + 10), None)]; | |
| 322 | + | assert!(steps_for(&long, "h", "a.rs")[0].text.ends_with('…')); | |
| 323 | + | } | |
| 324 | + | } |
| 107 | 107 | into the fork and push. The pull request can then be merged, or joins the | |
| 108 | 108 | [merge queue](/guides/merge-queue/) when the repository uses one. | |
| 109 | 109 | ||
| 110 | + | When the merge queue takes an agent's pull request out because it fails | |
| 111 | + | combined with the pull requests ahead of it, g1t | |
| 112 | + | [says which ones and which check failed](/guides/merge-queue/#who-is-told): | |
| 113 | + | in the pull request's session, as an `integration_failure` decision | |
| 114 | + | (`pull_request` `list_decisions`), and as the `integration.failed` event. | |
| 115 | + | The agent reads the other pull request's changes, fixes its own, and | |
| 116 | + | pushes; once the checks pass, the pull request rejoins the queue by | |
| 117 | + | itself. It can say what it changed with `pull_request` `record_decision` | |
| 118 | + | (`kind` `fix`), and `repository` `why` explains any line it is unsure of. | |
| 119 | + | ||
| 110 | 120 | ### Repository instructions | |
| 111 | 121 | ||
| 112 | 122 | Each agent reads a repository's instructions from a file at its root: |
| 134 | 134 | An entry fails when its `merge_group` workflows or required checks fail | |
| 135 | 135 | on the combined state, when it does not merge cleanly with what is ahead of it, or | |
| 136 | 136 | when the state cannot be built. | |
| 137 | − | It leaves the queue, and: | |
| 138 | 137 | ||
| 139 | − | 1. Its pull request records the failure, saying why: which workflow failed | |
| 140 | − | on the state, which required check did not report, or, for a conflict, | |
| 141 | − | the pull request ahead it collided with and the files. | |
| 142 | − | 2. Its conversation records that it was taken out of the queue, and why: a | |
| 143 | − | conflict links the pull request it collided with and each conflicting | |
| 144 | − | file, which opens in the pull request's changes. | |
| 145 | − | 3. The entries that were tested on top of it are tested again without it. | |
| 138 | + | ### Who answers for it | |
| 139 | + | ||
| 140 | + | A state that fails while an entry ahead of it is still being tested is | |
| 141 | + | held until those ahead are settled. If one of them fails too, the held | |
| 142 | + | entry is tested again without it and is not blamed. Once every entry ahead | |
| 143 | + | has passed, the held entry answers for the failure. So when #41 is | |
| 144 | + | broken, it leaves the queue, and #44 and #46, which were tested with #41 | |
| 145 | + | in them, are tested again without it rather than taken out with it. | |
| 146 | + | ||
| 147 | + | The entry that answers for a failure leaves the queue, and g1t works out | |
| 148 | + | what it failed with: | |
| 149 | + | ||
| 150 | + | | What happened | Who it failed with (`suspects`) | | |
| 151 | + | | --- | --- | | |
| 152 | + | | Tested on `main` alone | Nobody: the change is the cause. | | |
| 153 | + | | Its own checks fail on its head too | Nobody: the change is the cause. | | |
| 154 | + | | It conflicts with an entry ahead | That entry, and the conflicting files. | | |
| 155 | + | | A required check did not report, or the state could not be built | Nobody: the note says what to fix. | | |
| 156 | + | | Its own checks pass, one entry ahead | That entry. | | |
| 157 | + | | Its own checks pass, several entries ahead | Found by building smaller combinations, below. | | |
| 158 | + | ||
| 159 | + | ### Narrowing it down | |
| 160 | + | ||
| 161 | + | An entry whose own checks pass but which fails combined with several | |
| 162 | + | entries ahead breaks *with* some of them. g1t builds `main` with the first | |
| 163 | + | half of those entries and the failing one, and runs the `merge_group` | |
| 164 | + | workflows on it, as for any queued state. If that fails, the cause is in | |
| 165 | + | that half; if it passes, in the other half. It halves again until one entry | |
| 166 | + | is left, or it has built three extra combinations. With the default four | |
| 167 | + | entries to a batch, at most three are ahead of a failing entry, and two | |
| 168 | + | builds always find the one it breaks with; three builds find one among up | |
| 169 | + | to eight. | |
| 170 | + | ||
| 171 | + | These combinations are built in sandboxes as the queue's states are, on | |
| 172 | + | `g1t-queue/<id>` branches that are deleted afterwards, and never land. They | |
| 173 | + | do not hold up the queue: the entries behind are tested again at once. When | |
| 174 | + | a combination cannot be built or checked, or its checks are still running | |
| 175 | + | past the rule's timeout when the queue next moves, g1t stops and reports | |
| 176 | + | the entries still suspected. | |
| 177 | + | ||
| 178 | + | The halving assumes one entry ahead is the cause. When the failure needs | |
| 179 | + | two of them together, what is reported is the half that still failed, and | |
| 180 | + | can name more than one pull request. | |
| 181 | + | ||
| 182 | + | ### Who is told | |
| 183 | + | ||
| 184 | + | 1. Its pull request records the failed check, saying why: which workflow | |
| 185 | + | failed on the state, which required check did not report, or, for a | |
| 186 | + | conflict, the pull request ahead it collided with and the files. | |
| 187 | + | 2. Its conversation records that it was taken out of the queue, then what | |
| 188 | + | g1t found: the pull requests it fails with, whether that was narrowed | |
| 189 | + | down, and the check that failed. | |
| 190 | + | 3. Its issue's conversation says the same in one line, and so does the | |
| 191 | + | pull request's [session](/guides/why-blame/#sessions), so the agent | |
| 192 | + | working on it reads it there. | |
| 193 | + | 4. A [decision record](/guides/why-blame/#decision-records) of kind | |
| 194 | + | `integration_failure`, confidence `tested`, keeps it, with the | |
| 195 | + | combination, the check and the suspects as evidence. | |
| 196 | + | 5. The `integration.failed` [event](/guides/webhooks/#events) carries the | |
| 197 | + | same to webhooks and to anyone following the repository's events: | |
| 198 | + | `pull` (the entry that failed, also `number`), `issue`, `combination` | |
| 199 | + | (the pull requests tested together, in order), `check` (`name`, | |
| 200 | + | `conclusion`, `url`), `bisected`, `suspects`, `other_pulls` and `commit`. | |
| 201 | + | 6. The entries that were tested on top of it are tested again without it. | |
| 202 | + | ||
| 203 | + | A combined state that passes is recorded too: an `integration_passed` | |
| 204 | + | decision and the `integration.passed` event, with `pull` and `combination`. | |
| 205 | + | ||
| 206 | + | ### Getting back in the queue | |
| 207 | + | ||
| 208 | + | The merge stays asked for. Push a fix to the pull request, starting from | |
| 209 | + | `main` as it is now: once every check on the new commit has finished, none | |
| 210 | + | failed and every required check passed, g1t merges it again as whoever | |
| 211 | + | queued it, which puts it back in the queue. Everything a merge needs is | |
| 212 | + | checked again then, so if an approval was dismissed by the push, the | |
| 213 | + | conversation says so and you merge it again once it is approved. Until a | |
| 214 | + | new commit's checks pass, merging it again is refused: "It failed in the | |
| 215 | + | merge queue; push a fix to try again." | |
| 146 | 216 | ||
| 147 | − | Its author fixes it, starting from `main` as it is now, and merges it | |
| 148 | − | again to put it back in the queue. | |
| 217 | + | A pull request whose checks are not reported by workflows (none run on its | |
| 218 | + | pushes) is not put back by itself: merge it again once it is fixed. | |
| 149 | 219 | ||
| 150 | 220 | ## The Merge queue page | |
| 151 | 221 | ||
| ⋯ | |||
| 209 | 279 | | `results` | What building the state recorded, each with `command`, `passed` and `output`. The workflow runs on it are on its commit, `combined_commit`. | | |
| 210 | 280 | | `error` | Why it failed: a conflict, a workflow that failed on it, a required check that did not report, or what could not be built. | | |
| 211 | 281 | | `enqueued_by` | Who added it: a username, or `g1t` when it was merged automatically. | | |
| 282 | + | ||
| 283 | + | An agent working on a pull request learns of a failure without polling the | |
| 284 | + | queue: from the `integration.failed` event, from the note in the pull | |
| 285 | + | request's session (`pull_request` `read_session`), or from its decisions | |
| 286 | + | (`pull_request` `list_decisions`, or | |
| 287 | + | `GET /repos/{owner}/{name}/pulls/{number}/decisions`). | |
| 111 | 111 | | `deployment_status.created` | A deployment got a status, from any source. `data.repo_id`, `data.deployment` without its `payload`, and `data.deployment_status`. | | |
| 112 | 112 | | `release.created`, `.published`, `.released`, `.prereleased`, `.edited`, `.unpublished`, `.deleted` | A [release](/guides/releases/) changed; one change can send several, as the [release activity types](/guides/actions/#releases) say. `data.release_id`, `data.tag_name`, and `data.release` (`tag_name`, `target`, `name`, `body`, `draft`, `prerelease`, `author`, `created_at`, `published_at`), as it was for `release.deleted`. On `release.edited`, `data.changes` holds the old `name` and `body`. | | |
| 113 | 113 | | `queue.changed` | The merge queue gained, lost or settled an entry. | | |
| 114 | − | | `session.appended` | A pull request's [session](/guides/why-blame/#sessions) grew: a coding agent recorded entries with `record_session`. Busy: choose it only if you need it. | | |
| 114 | + | | `integration.failed` | A pull request failed in the [merge queue](/guides/merge-queue/#when-an-entry-fails) combined with the ones ahead of it. `pull` (also `number`) is the one that answers for it, `issue` its issue, `combination` the pull requests tested together, `check` the check that failed (`name`, `conclusion`, `url`), `suspects` the pull requests ahead it fails with, `bisected` whether smaller combinations were built to find them, and `other_pulls` the rest of the combination. | | |
| 115 | + | | `integration.passed` | A pull request's combined state passed in the merge queue: `pull` and `combination`. | | |
| 116 | + | | `decision.recorded` | A [decision record](/guides/why-blame/#decision-records) was kept on an issue or pull request: `decision`, with `number` and `issue`. | | |
| 117 | + | | `session.appended` | A pull request's [session](/guides/why-blame/#sessions) grew: a coding agent recorded entries with `record_session`, or g1t noted a merge queue failure. Busy: choose it only if you need it. | | |
| 115 | 118 | ||
| 116 | 119 | [Managing a repository](/guides/managing-repositories/) says what each of | |
| 117 | 120 | the `repo.` and `branch.` changes does. |
| 87 | 87 | request when it caught up with `main` is credited to whoever wrote it on | |
| 88 | 88 | `main`, not to the merge. | |
| 89 | 89 | ||
| 90 | + | ### Why over the API | |
| 91 | + | ||
| 92 | + | The `repository` tool's `why` action, or | |
| 93 | + | `GET /repos/{owner}/{name}/why?path=&line=&ref=`, answers the same for one | |
| 94 | + | line, with the decision records too. `ref` is a branch, tag or commit; the | |
| 95 | + | default branch when left out. | |
| 96 | + | ||
| 97 | + | ```sh | |
| 98 | + | curl "https://api.g1t.sh/repos/acme/web/why?path=src/config.rs&line=42" \ | |
| 99 | + | -H "Authorization: Bearer $G1T_TOKEN" | |
| 100 | + | ``` | |
| 101 | + | ||
| 102 | + | | Field | | | |
| 103 | + | | --- | --- | | |
| 104 | + | | `commit` | The commit that last changed the line: `hash`, `message`, `author`, `parents`, `authored_at`. | | |
| 105 | + | | `pull` | The merged pull request it arrived in: `number`, `title`, `author`, `agent`, `merged_by`, `merged_at`. Absent when it did not arrive through one. | | |
| 106 | + | | `issue` | That pull request's issue: `number`, `title`, `state` and `body` (cut at 900 characters). | | |
| 107 | + | | `decisions` | The [decision records](#decision-records) on the pull request, oldest first. | | |
| 108 | + | | `sessions` | Up to eight entries of the pull request's session behind that commit: the messages, and the tool calls that touched the file. | | |
| 109 | + | ||
| 110 | + | A pull request is found for a commit as on the site: the one whose head it | |
| 111 | + | is, or the one whose stretch of the default branch's last 300 commits holds | |
| 112 | + | it. It is public for a public repository, and needs the `code:read` scope | |
| 113 | + | otherwise. | |
| 114 | + | ||
| 115 | + | ## Decision records | |
| 116 | + | ||
| 117 | + | A decision record is a reason kept on an issue or pull request, with how | |
| 118 | + | sure it is. Sessions say what happened; decisions say why the result is | |
| 119 | + | the way it is, in a sentence, with what it rests on. | |
| 120 | + | ||
| 121 | + | | Confidence | Meaning | | |
| 122 | + | | --- | --- | | |
| 123 | + | | `claimed` | Someone said so: an agent or a person recorded it. | | |
| 124 | + | | `tested` | A check or the merge queue proved it. | | |
| 125 | + | | `approved` | A person approved it. | | |
| 126 | + | ||
| 127 | + | g1t keeps these itself: | |
| 128 | + | ||
| 129 | + | | Kind | When | Confidence | | |
| 130 | + | | --- | --- | --- | | |
| 131 | + | | `integration_failure` | The [merge queue](/guides/merge-queue/#when-an-entry-fails) found a combination of pull requests fails: which ones, and the check. | `tested` | | |
| 132 | + | | `integration_passed` | A combined state passed in the merge queue. | `tested` | | |
| 133 | + | | `approval` | Someone approved the pull request. | `approved` for a person, `claimed` for an agent's token | | |
| 134 | + | | `merge` | It was merged. | `tested` through the queue or a repository's own auto-merge, `approved` when a person merged it, `claimed` for an agent's token | | |
| 135 | + | ||
| 136 | + | Agents and people record their own, of kind `proposal` (why the change is | |
| 137 | + | made this way), `claim` (something true of it), `fix` (what fixed a | |
| 138 | + | failure) or `note`, always as `claimed`, with the `pull_request` tool's | |
| 139 | + | `record_decision` action or | |
| 140 | + | `POST /repos/{owner}/{name}/pulls/{number}/decisions`: | |
| 141 | + | ||
| 142 | + | ```sh | |
| 143 | + | curl -X POST https://api.g1t.sh/repos/acme/web/pulls/14/decisions \ | |
| 144 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 145 | + | -H "Content-Type: application/json" \ | |
| 146 | + | -d '{"kind": "fix", | |
| 147 | + | "summary": "Renamed the config key #12 also introduced, so both read retry_limit.", | |
| 148 | + | "evidence": {"commit": "7d3e9a1c"}}' | |
| 149 | + | ``` | |
| 150 | + | ||
| 151 | + | `summary` is at most 2,000 characters and `evidence` an object of at most | |
| 152 | + | 8 KB. Whoever opened the pull request can record one, and so can anyone | |
| 153 | + | who may comment on it, once their email address is confirmed. Each is | |
| 154 | + | attributed to whoever recorded it, carries | |
| 155 | + | the issue the pull request is for, and is published as `decision.recorded`. | |
| 156 | + | ||
| 157 | + | Read them with the `pull_request` tool's `list_decisions` action, with | |
| 158 | + | `GET /repos/{owner}/{name}/pulls/{number}/decisions`, or for an issue and | |
| 159 | + | all its pull requests with | |
| 160 | + | `GET /repos/{owner}/{name}/issues/{number}/decisions`. A decision is as | |
| 161 | + | visible as the repository, so do not put secrets in one. | |
| 162 | + | ||
| 90 | 163 | ## Commit pages | |
| 91 | 164 | ||
| 92 | 165 | Every commit has a page of its own, `g1t.sh/<workspace>/<repo>/commit/<hash>`, |
| 300 | 300 | | [`security_alerts`](/reference/api/security/list-security-alerts/) | Its security alerts: secrets found in pushes and history (`kind` `secret`) and dependencies with known vulnerabilities (`dependency`), each `open`, `dismissed` or `fixed`. `state` and `kind` filter them. Write role. | `repo` | `repo:read` | | |
| 301 | 301 | | [`dismiss_alert`](/reference/api/security/dismiss-security-alert/) | Dismiss one by `id` with a `reason` and an optional `comment`. A secret takes `false_positive`, `used_in_tests`, `revoked` or `wont_fix`, and needs the Admin role, since a dismissed secret is let through push protection; a dependency takes `fix_started`, `no_bandwidth`, `tolerable_risk`, `inaccurate` or `not_used`, and needs Write. | `repo`, `id`, `reason` | `repo:admin` | | |
| 302 | 302 | | [`reopen_alert`](/reference/api/security/reopen-security-alert/) | Open a dismissed alert again. The same roles as dismissing. | `repo`, `id` | `repo:admin` | | |
| 303 | + | | [`why`](/reference/api/decisions-and-why/why/) | Why a line is the way it is: the `commit` that last changed `line` of `path` (at `ref`, or the default branch), the merged `pull` request it arrived in, its `issue`, the `decisions` recorded on the pull request, and the `sessions` entries behind the commit. | `repo`, `path`, `line` | `code:read` | | |
| 303 | 304 | ||
| 304 | 305 | `update_settings` takes `required_checks` (at most 20 names), | |
| 305 | 306 | `required_approvals`, `allow_ignoring_checks`, `require_up_to_date`, | |
| ⋯ | |||
| 351 | 352 | | [`update`](/reference/api/pull-requests/update-pull-request/) | Change its `base` (the branch it merges into; Write role), `labels`, `milestone`, `assignees` or `reviewers`. `state` `open` reopens it and `closed` closes it. | `repo`, `number` | `pull_requests:write` | | |
| 352 | 353 | | [`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` | | |
| 353 | 354 | | [`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` | | |
| 355 | + | | [`record_decision`](/reference/api/decisions-and-why/record-decision/) | Record a decision on a pull request, kept as `claimed`: a `kind` (`proposal`, `claim`, `fix` or `note`), a `summary`, and optional `evidence` (an object) and `session_id`. | `repo`, `number`, `kind`, `summary` | `pull_requests:write` | | |
| 356 | + | | [`list_decisions`](/reference/api/decisions-and-why/list-decisions/) | The decision records on a pull request, or on an issue and its pull requests, oldest first, each with its `kind`, `confidence` (`claimed`, `tested` or `approved`), `summary` and `evidence`. | `repo`, `number` | `pull_requests:read` | | |
| 354 | 357 | | [`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` | | |
| 355 | 358 | | [`draft`](/reference/api/pull-requests/convert-pull-request-to-draft/) | Turn a pull request that is ready for review back into a draft. It leaves the merge queue. Its author, or the Triage role. | `repo`, `number` | `pull_requests:write` | | |
| 356 | 359 | | [`request_reviewers`](/reference/api/pull-requests/request-reviewers/) | Ask more people (`reviewers`, by username) or teams (`team_reviewers`, as `workspace/team` or a slug of the repository's workspace) to review it, added to whoever is asked already. Its author, or the Triage role. | `repo`, `number` | `pull_requests:write` | | |
| ⋯ | |||
| 364 | 367 | `record_session` takes a list of `entries`, each with a `kind` (`prompt`, | |
| 365 | 368 | `message`, `tool_call`, `tool_result` or `note`) and `text`, and `tool` for | |
| 366 | 369 | tool entries. See [sessions and why-blame](/guides/why-blame/) and the | |
| 367 | − | [merge queue](/guides/merge-queue/). | |
| 370 | + | [merge queue](/guides/merge-queue/). g1t records `integration_failure`, | |
| 371 | + | `integration_passed`, `approval` and `merge` decisions itself; see | |
| 372 | + | [decision records](/guides/why-blame/#decision-records). | |
| 368 | 373 | ||
| 369 | 374 | ## `memory` | |
| 370 | 375 | ||
Binary or large file; its contents are not shown.
| 56 | 56 | "release.deleted", | |
| 57 | 57 | ], | |
| 58 | 58 | }, | |
| 59 | − | { title: "Checks and the queue", events: ["checks.completed", "workflow.completed", "queue.changed"] }, | |
| 60 | 59 | { | |
| 60 | + | title: "Checks and the queue", | |
| 61 | + | events: ["checks.completed", "workflow.completed", "queue.changed", "integration.failed", "integration.passed"], | |
| 62 | + | }, | |
| 63 | + | { | |
| 61 | 64 | title: "Statuses and check runs", | |
| 62 | 65 | events: [ | |
| 63 | 66 | "status.created", | |
| ⋯ | |||
| 73 | 76 | title: "Deployments", | |
| 74 | 77 | events: ["deployment.created", "deployment_status.created", "deployment.succeeded", "deployment.failed"], | |
| 75 | 78 | }, | |
| 76 | − | { title: "Sessions", events: ["session.appended"] }, | |
| 79 | + | { title: "Sessions and decisions", events: ["session.appended", "decision.recorded"] }, | |
| 77 | 80 | { title: "Access", events: ["workspace.member_joined", "repo.collaborator_added", "repo.collaborator_removed", "repo.collaborator_role_changed"] }, | |
| 78 | 81 | { | |
| 79 | 82 | title: "Teams", | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…