Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-ab8033ab973f8d99a' | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.