The merge queue works out who answers for a failed combination and tells the work: a state that fails while an entry ahead of it is still being tested is held until those ahead settle, so a broken change is blamed on itself and the entries tested with it are tested again instead of being taken out; an entry whose own checks pass but which fails with several entries ahead has smaller combinations (the default branch, half of the suspects and the entry) built and checked by the merge_group workflows, halving the suspects within three extra builds, and never landed. The result goes on the pull request's and its issue's timelines and into its session, as an integration_failure decision record (tested), and as integration.failed with the pull request, its issue, the combination, the failing check's name, conclusion and link, whether it was bisected, the suspects and the other pull requests; a combination that passes is an integration_passed decision and integration.passed. An entry the queue took out keeps its merge request: once a new commit's checks all pass, g1t merges it again as whoever queued it, which puts it back in the queue under the branch's rules as they are then.
Decision records are kept in the work service's new decisions table: g1t records integration results, approvals (approved for a person, claimed for an agent's token) and merges (tested through the queue or auto-merge, approved when a person merged), and record_decision keeps an agent's or a person's proposal, claim, fix or note as claimed; each is published as decision.recorded. The API gains record_decision and list_decisions (POST and GET /repos/{owner}/{name}/pulls/{number}/decisions, GET .../issues/{number}/decisions for an issue and its pull requests) and why (GET /repos/{owner}/{name}/why?path=&line=&ref=), which blames the line and answers with its commit, the merged pull request it arrived in, that pull request's issue, its decisions and the session entries behind the commit; on MCP they are pull_request record_decision and list_decisions and repository why. integration.failed, integration.passed and decision.recorded join the webhooks catalog.
| 39 | 39 | mod runners; | |
| 40 | 40 | mod security; | |
| 41 | 41 | mod tools; | |
| 42 | + | mod why; | |
| 42 | 43 | mod token_policy; | |
| 43 | 44 | mod toolkit; | |
| 44 | 45 |
| 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"], | |
| ⋯ | |||
| 3127 | 3171 | | Op::ListPullRequests | |
| 3128 | 3172 | | Op::GetPullRequest | |
| 3129 | 3173 | | Op::ReadSession | |
| 3174 | + | | Op::ListDecisions | |
| 3175 | + | | Op::Why | |
| 3130 | 3176 | | Op::GetPullRequestChanges | |
| 3131 | 3177 | | Op::ListEvents | |
| 3132 | 3178 | | Op::GetRepoSettings | |
| ⋯ | |||
| 4426 | 4472 | .await | |
| 4427 | 4473 | } | |
| 4428 | 4474 | Op::ReadSession => pass(work, "read_session", &view()).await, | |
| 4475 | + | Op::RecordDecision => { | |
| 4476 | + | let Some(kind) = input["kind"].as_str().and_then(g1t_contracts::decisions::DecisionKind::parse) else { | |
| 4477 | + | return failed(FailureCode::Invalid, "kind must be proposal, claim, fix or note."); | |
| 4478 | + | }; | |
| 4479 | + | pass( | |
| 4480 | + | work, | |
| 4481 | + | "record_decision", | |
| 4482 | + | &g1t_contracts::decisions::RecordDecisionArgs { | |
| 4483 | + | actor: actor(), | |
| 4484 | + | repo, | |
| 4485 | + | number, | |
| 4486 | + | kind, | |
| 4487 | + | summary: text(input, "summary"), | |
| 4488 | + | evidence: input.get("evidence").cloned(), | |
| 4489 | + | session_id: optional_text(input, "session_id"), | |
| 4490 | + | }, | |
| 4491 | + | ) | |
| 4492 | + | .await | |
| 4493 | + | } | |
| 4494 | + | Op::ListDecisions => { | |
| 4495 | + | pass( | |
| 4496 | + | work, | |
| 4497 | + | "list_decisions", | |
| 4498 | + | &g1t_contracts::decisions::ListDecisionsArgs { repo, viewer: viewer.clone(), number }, | |
| 4499 | + | ) | |
| 4500 | + | .await | |
| 4501 | + | } | |
| 4502 | + | Op::Why => { | |
| 4503 | + | let Some(line) = integer(input, "line").filter(|line| *line > 0) else { | |
| 4504 | + | return failed(FailureCode::Invalid, "Give line, counted from 1."); | |
| 4505 | + | }; | |
| 4506 | + | let path = text(input, "path"); | |
| 4507 | + | if path.trim().is_empty() { | |
| 4508 | + | return failed(FailureCode::Invalid, "Give path: the file, from the repository's root."); | |
| 4509 | + | } | |
| 4510 | + | crate::why::why(services, viewer, repo, path, line, optional_text(input, "ref")).await | |
| 4511 | + | } | |
| 4429 | 4512 | Op::MarkPullRequestReady => pass(work, "ready_pull", &pull_action()).await, | |
| 4430 | 4513 | Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await, | |
| 4431 | 4514 | 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) |
| 989 | 989 | Op::RecordSession, | |
| 990 | 990 | &[], | |
| 991 | 991 | ), | |
| 992 | + | // Decision records, on a pull request or an issue (with its pull | |
| 993 | + | // requests'), and why a line is the way it is. | |
| 994 | + | route("GET", "/repos/:owner/:name/pulls/:number/decisions", Op::ListDecisions, &[]), | |
| 995 | + | route("POST", "/repos/:owner/:name/pulls/:number/decisions", Op::RecordDecision, &[]), | |
| 996 | + | route("GET", "/repos/:owner/:name/issues/:number/decisions", Op::ListDecisions, &[]), | |
| 997 | + | route("GET", "/repos/:owner/:name/why", Op::Why, &[("path", "path"), ("line", "line"), ("ref", "ref")]), | |
| 992 | 998 | route( | |
| 993 | 999 | "POST", | |
| 994 | 1000 | "/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 | + | } |
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", | |
| 277 | 277 | "list_pull_requests", | |
| 278 | 278 | "get_pull_request", | |
| 279 | 279 | "read_session", | |
| 280 | + | "list_decisions", | |
| 281 | + | "why", | |
| 280 | 282 | "get_pull_request_changes", | |
| 281 | 283 | "list_events", | |
| 282 | 284 | "get_context", | |
| ⋯ | |||
| 519 | 521 | ||
| 520 | 522 | /// Operations that change a pull request, which a runner may do only to | |
| 521 | 523 | /// the pull request its run works on. | |
| 522 | − | const PULL_WRITES: &[&str] = &["record_session", "mark_pull_request_ready"]; | |
| 524 | + | const PULL_WRITES: &[&str] = &["record_session", "record_decision", "mark_pull_request_ready"]; | |
| 523 | 525 | ||
| 524 | 526 | /// Whether something was allowed, and the rule that decided it. | |
| 525 | 527 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 1 | + | //! Decision records, what the merge queue learns when changes are combined, | |
| 2 | + | //! and why a line of code is the way it is. | |
| 3 | + | //! | |
| 4 | + | //! A decision record is a structured reason with a confidence: `claimed` | |
| 5 | + | //! when an agent or a person said so, `tested` when a check or a combination | |
| 6 | + | //! of pull requests proved it, `approved` when a person approved it. The | |
| 7 | + | //! work service records the tested and approved ones itself (integration | |
| 8 | + | //! results, approvals, merges); `record_decision` records claimed ones. | |
| 9 | + | ||
| 10 | + | use serde::{Deserialize, Serialize}; | |
| 11 | + | ||
| 12 | + | use crate::repos::{Commit, RepoPath}; | |
| 13 | + | use crate::work::SessionEntry; | |
| 14 | + | use crate::{User, Viewer}; | |
| 15 | + | ||
| 16 | + | /// What a decision is about. | |
| 17 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 18 | + | #[serde(rename_all = "snake_case")] | |
| 19 | + | pub enum DecisionKind { | |
| 20 | + | /// Why a change is made the way it is. | |
| 21 | + | Proposal, | |
| 22 | + | /// Something said to be true about the work, such as what it fixes. | |
| 23 | + | Claim, | |
| 24 | + | /// The merge queue found that a combination of pull requests fails. | |
| 25 | + | IntegrationFailure, | |
| 26 | + | /// The merge queue found that a combination of pull requests passes. | |
| 27 | + | IntegrationPassed, | |
| 28 | + | /// What was changed to fix a failure. | |
| 29 | + | Fix, | |
| 30 | + | /// A person approved the pull request. | |
| 31 | + | Approval, | |
| 32 | + | /// It was merged. | |
| 33 | + | Merge, | |
| 34 | + | /// Anything else worth keeping. | |
| 35 | + | Note, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | impl DecisionKind { | |
| 39 | + | pub fn as_str(self) -> &'static str { | |
| 40 | + | match self { | |
| 41 | + | DecisionKind::Proposal => "proposal", | |
| 42 | + | DecisionKind::Claim => "claim", | |
| 43 | + | DecisionKind::IntegrationFailure => "integration_failure", | |
| 44 | + | DecisionKind::IntegrationPassed => "integration_passed", | |
| 45 | + | DecisionKind::Fix => "fix", | |
| 46 | + | DecisionKind::Approval => "approval", | |
| 47 | + | DecisionKind::Merge => "merge", | |
| 48 | + | DecisionKind::Note => "note", | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub fn parse(kind: &str) -> Option<DecisionKind> { | |
| 53 | + | serde_json::from_value(serde_json::Value::String(kind.to_owned())).ok() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// The kinds `record_decision` takes. The others are what g1t saw | |
| 57 | + | /// happen, and only g1t records them. | |
| 58 | + | pub const RECORDABLE: [DecisionKind; 4] = | |
| 59 | + | [DecisionKind::Proposal, DecisionKind::Claim, DecisionKind::Fix, DecisionKind::Note]; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// How sure the record is. | |
| 63 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 64 | + | #[serde(rename_all = "snake_case")] | |
| 65 | + | pub enum DecisionConfidence { | |
| 66 | + | /// Someone said so. | |
| 67 | + | Claimed, | |
| 68 | + | /// A check or a combination of pull requests proved it. | |
| 69 | + | Tested, | |
| 70 | + | /// A person approved it. | |
| 71 | + | Approved, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl DecisionConfidence { | |
| 75 | + | pub fn as_str(self) -> &'static str { | |
| 76 | + | match self { | |
| 77 | + | DecisionConfidence::Claimed => "claimed", | |
| 78 | + | DecisionConfidence::Tested => "tested", | |
| 79 | + | DecisionConfidence::Approved => "approved", | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// One decision record. | |
| 85 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 86 | + | #[serde(rename_all = "camelCase")] | |
| 87 | + | pub struct Decision { | |
| 88 | + | pub id: String, | |
| 89 | + | /// The issue it is about, when known. | |
| 90 | + | pub issue: Option<u32>, | |
| 91 | + | /// The pull request it is about, when known. | |
| 92 | + | pub pull: Option<u32>, | |
| 93 | + | /// The session it was recorded in, if one was named. | |
| 94 | + | #[serde(default)] | |
| 95 | + | pub session_id: Option<String>, | |
| 96 | + | /// Username of who recorded it; absent when g1t did. | |
| 97 | + | pub actor: Option<String>, | |
| 98 | + | pub kind: DecisionKind, | |
| 99 | + | pub confidence: DecisionConfidence, | |
| 100 | + | pub summary: String, | |
| 101 | + | /// What it rests on, as an object: the commit, the check (`name`, | |
| 102 | + | /// `conclusion`, `url`), the `combination` of pull requests tested | |
| 103 | + | /// together, `other_pulls`, `suspects`. | |
| 104 | + | pub evidence: serde_json::Value, | |
| 105 | + | /// RFC 3339. | |
| 106 | + | pub created_at: String, | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// `record_decision`: an agent's or a person's decision on a pull request, | |
| 110 | + | /// recorded as `claimed`. Returns `Outcome<Decision>`. | |
| 111 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 112 | + | #[serde(rename_all = "camelCase")] | |
| 113 | + | pub struct RecordDecisionArgs { | |
| 114 | + | pub actor: User, | |
| 115 | + | pub repo: RepoPath, | |
| 116 | + | /// The pull request's number. | |
| 117 | + | pub number: u32, | |
| 118 | + | pub kind: DecisionKind, | |
| 119 | + | pub summary: String, | |
| 120 | + | #[serde(default)] | |
| 121 | + | pub evidence: Option<serde_json::Value>, | |
| 122 | + | #[serde(default)] | |
| 123 | + | pub session_id: Option<String>, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// `list_decisions`: the decisions on an issue (and on its pull requests) | |
| 127 | + | /// or on a pull request, oldest first. Returns `Outcome<Vec<Decision>>`. | |
| 128 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 129 | + | #[serde(rename_all = "camelCase")] | |
| 130 | + | pub struct ListDecisionsArgs { | |
| 131 | + | pub repo: RepoPath, | |
| 132 | + | pub viewer: Viewer, | |
| 133 | + | /// An issue's or a pull request's number: they share a sequence. | |
| 134 | + | pub number: u32, | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// The check that failed a combined state. | |
| 138 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 139 | + | #[serde(rename_all = "camelCase")] | |
| 140 | + | pub struct FailedCheck { | |
| 141 | + | /// The check's name, such as `CI / merge_group`; `merge` for a merge | |
| 142 | + | /// conflict, `build` when the state could not be built. | |
| 143 | + | pub name: String, | |
| 144 | + | /// `failure`, `error`, `missing` (a required check never reported), | |
| 145 | + | /// or `conflict`. | |
| 146 | + | pub conclusion: String, | |
| 147 | + | /// Where to see it, such as the workflow run's page. | |
| 148 | + | #[serde(default)] | |
| 149 | + | pub url: Option<String>, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// `integration.failed`: a combined state in the merge queue failed, and | |
| 153 | + | /// g1t worked out which pull request answers for it and which others it | |
| 154 | + | /// breaks with. | |
| 155 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 156 | + | #[serde(rename_all = "camelCase")] | |
| 157 | + | pub struct IntegrationFailed { | |
| 158 | + | pub repo_id: String, | |
| 159 | + | pub pull_id: String, | |
| 160 | + | /// The responsible pull request, as `pull`. | |
| 161 | + | pub number: u32, | |
| 162 | + | pub pull: u32, | |
| 163 | + | /// Its issue, if it has one. | |
| 164 | + | pub issue: Option<u32>, | |
| 165 | + | /// The pull requests tested together, in queue order; the last is | |
| 166 | + | /// `pull`. | |
| 167 | + | pub combination: Vec<u32>, | |
| 168 | + | pub check: FailedCheck, | |
| 169 | + | /// Whether smaller combinations were built to narrow `suspects` down. | |
| 170 | + | pub bisected: bool, | |
| 171 | + | /// The pull requests ahead of it that it fails with. Empty when it | |
| 172 | + | /// fails by itself. | |
| 173 | + | pub suspects: Vec<u32>, | |
| 174 | + | /// The other pull requests in `combination`. | |
| 175 | + | pub other_pulls: Vec<u32>, | |
| 176 | + | /// The combined state that failed. | |
| 177 | + | pub commit: Option<String>, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// `integration.passed`: a combined state in the merge queue passed. | |
| 181 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 182 | + | #[serde(rename_all = "camelCase")] | |
| 183 | + | pub struct IntegrationPassed { | |
| 184 | + | pub repo_id: String, | |
| 185 | + | pub pull_id: String, | |
| 186 | + | pub number: u32, | |
| 187 | + | pub pull: u32, | |
| 188 | + | pub issue: Option<u32>, | |
| 189 | + | pub combination: Vec<u32>, | |
| 190 | + | pub commit: Option<String>, | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// `decision.recorded`. | |
| 194 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 195 | + | #[serde(rename_all = "camelCase")] | |
| 196 | + | pub struct DecisionRecorded { | |
| 197 | + | pub repo_id: String, | |
| 198 | + | /// The pull request it is about, or else its issue. | |
| 199 | + | pub number: u32, | |
| 200 | + | pub issue: Option<u32>, | |
| 201 | + | pub decision: Decision, | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// The pull request a line arrived in, as `why` shows it. | |
| 205 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 206 | + | #[serde(rename_all = "camelCase")] | |
| 207 | + | pub struct WhyPull { | |
| 208 | + | pub number: u32, | |
| 209 | + | pub title: String, | |
| 210 | + | pub author: String, | |
| 211 | + | pub agent: String, | |
| 212 | + | pub merged_by: Option<String>, | |
| 213 | + | pub merged_at: Option<String>, | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// The issue that pull request was for. | |
| 217 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 218 | + | #[serde(rename_all = "camelCase")] | |
| 219 | + | pub struct WhyIssue { | |
| 220 | + | pub number: u32, | |
| 221 | + | pub title: String, | |
| 222 | + | pub state: String, | |
| 223 | + | /// Its description, cut short when long. | |
| 224 | + | pub body: String, | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// `why`: why a line is the way it is. The commit that last changed it, | |
| 228 | + | /// the merged pull request it arrived in, that pull request's issue, the | |
| 229 | + | /// decisions recorded on them, and the session entries behind the commit. | |
| 230 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 231 | + | #[serde(rename_all = "camelCase")] | |
| 232 | + | pub struct Why { | |
| 233 | + | pub path: String, | |
| 234 | + | pub line: u32, | |
| 235 | + | pub commit: Commit, | |
| 236 | + | pub pull: Option<WhyPull>, | |
| 237 | + | pub issue: Option<WhyIssue>, | |
| 238 | + | pub decisions: Vec<Decision>, | |
| 239 | + | pub sessions: Vec<SessionEntry>, | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[cfg(test)] | |
| 243 | + | mod tests { | |
| 244 | + | use super::*; | |
| 245 | + | ||
| 246 | + | #[test] | |
| 247 | + | fn kinds_read_and_write_as_their_names() { | |
| 248 | + | for kind in [ | |
| 249 | + | DecisionKind::Proposal, | |
| 250 | + | DecisionKind::Claim, | |
| 251 | + | DecisionKind::IntegrationFailure, | |
| 252 | + | DecisionKind::IntegrationPassed, | |
| 253 | + | DecisionKind::Fix, | |
| 254 | + | DecisionKind::Approval, | |
| 255 | + | DecisionKind::Merge, | |
| 256 | + | DecisionKind::Note, | |
| 257 | + | ] { | |
| 258 | + | assert_eq!(DecisionKind::parse(kind.as_str()), Some(kind)); | |
| 259 | + | assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str()); | |
| 260 | + | } | |
| 261 | + | assert_eq!(DecisionKind::parse("merged"), None); | |
| 262 | + | assert!(!DecisionKind::RECORDABLE.contains(&DecisionKind::IntegrationFailure)); | |
| 263 | + | assert!(!DecisionKind::RECORDABLE.contains(&DecisionKind::Approval)); | |
| 264 | + | assert_eq!(serde_json::to_value(DecisionConfidence::Tested).unwrap(), "tested"); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn an_integration_failure_carries_number_and_issue_for_filters() { | |
| 269 | + | let event = IntegrationFailed { | |
| 270 | + | repo_id: "rep_1".into(), | |
| 271 | + | pull_id: "pr_1".into(), | |
| 272 | + | number: 14, | |
| 273 | + | pull: 14, | |
| 274 | + | issue: Some(9), | |
| 275 | + | combination: vec![11, 12, 14], | |
| 276 | + | check: FailedCheck { name: "CI / merge_group".into(), conclusion: "failure".into(), url: None }, | |
| 277 | + | bisected: true, | |
| 278 | + | suspects: vec![12], | |
| 279 | + | other_pulls: vec![11, 12], | |
| 280 | + | commit: Some("abc".into()), | |
| 281 | + | }; | |
| 282 | + | let sent = serde_json::to_value(&event).unwrap(); | |
| 283 | + | assert_eq!(sent["number"], 14); | |
| 284 | + | assert_eq!(sent["issue"], 9); | |
| 285 | + | assert_eq!(sent["otherPulls"], serde_json::json!([11, 12])); | |
| 286 | + | assert_eq!(sent["check"]["conclusion"], "failure"); | |
| 287 | + | } | |
| 288 | + | } |
| 19 | 19 | pub mod coding_agents; | |
| 20 | 20 | pub mod credentials; | |
| 21 | 21 | pub mod datasets; | |
| 22 | + | pub mod decisions; | |
| 22 | 23 | pub mod deploy_keys; | |
| 23 | 24 | pub mod events; | |
| 24 | 25 | pub mod folios; |
| 988 | 988 | // A repository's files, as clone and fetch need. | |
| 989 | 989 | ("download_tarball_archive", Scope::CodeRead), | |
| 990 | 990 | ("download_zipball_archive", Scope::CodeRead), | |
| 991 | + | ("why", Scope::CodeRead), | |
| 991 | 992 | // Projects follow their repositories. | |
| 992 | 993 | ("list_projects", Scope::RepoRead), | |
| 993 | 994 | ("get_project", Scope::RepoRead), | |
| ⋯ | |||
| 1109 | 1110 | ("get_pull_request_changes", Scope::PullRequestsRead), | |
| 1110 | 1111 | ("read_session", Scope::PullRequestsRead), | |
| 1111 | 1112 | ("get_merge_queue", Scope::PullRequestsRead), | |
| 1113 | + | ("list_decisions", Scope::PullRequestsRead), | |
| 1112 | 1114 | ("create_pull_request", Scope::PullRequestsWrite), | |
| 1113 | 1115 | ("update_pull_request", Scope::PullRequestsWrite), | |
| 1114 | 1116 | ("record_session", Scope::PullRequestsWrite), | |
| 1117 | + | ("record_decision", Scope::PullRequestsWrite), | |
| 1115 | 1118 | ("mark_pull_request_ready", Scope::PullRequestsWrite), | |
| 1116 | 1119 | ("close_pull_request", Scope::PullRequestsWrite), | |
| 1117 | 1120 | ("reopen_pull_request", Scope::PullRequestsWrite), | |
| 15 | 15 | use crate::{User, Viewer}; | |
| 16 | 16 | ||
| 17 | 17 | /// Every event a webhook can be sent, in the order people are shown them. | |
| 18 | − | pub const EVENT_TYPES: [&str; 107] = [ | |
| 18 | + | pub const EVENT_TYPES: [&str; 110] = [ | |
| 19 | 19 | "git.push", | |
| 20 | 20 | "git.deleted", | |
| 21 | 21 | "branch.renamed", | |
| ⋯ | |||
| 98 | 98 | "deployment.created", | |
| 99 | 99 | "deployment_status.created", | |
| 100 | 100 | "queue.changed", | |
| 101 | + | "integration.failed", | |
| 102 | + | "integration.passed", | |
| 103 | + | "decision.recorded", | |
| 101 | 104 | "session.appended", | |
| 102 | 105 | "package.published", | |
| 103 | 106 | "package.version_deleted", | |
Comments
Sign in to comment.