A file's history and a repository's failed runs are one read each, so Ask (and any agent) answers "why does this file exist" and "why did the last Deploy fail" from what happened rather than by paging. list_commits with `path` now gives that file's or directory's own history: the commits on the first-parent chain that changed it (added, changed, removed), newest first, with `last_changed` the newest and `complete` false when a long history was not walked to its start in time. The repos service's new path_log walks the history as last_commits does, reading ahead in batches, a tree only where the path's directory changed, within its read budget and 8 s. list_workflow_runs takes GitHub's `status`, a status (queued, in_progress, waiting, completed) or a conclusion (failure, success, cancelled…), on REST (status, and head_sha on a workflow's runs too) and MCP, and the actions service filters by either. Ask's system prompt finds failed runs with status failure, says a run still going has no result rather than calling it passed, judges health from the newest completed run and what is still going, and reads a file's history for why it exists, quoting its commit messages. The quality bar no longer reads a run's number, in a link to the run or a table of runs, as an issue's. Its last run on production scored 97.1% of checks and a judge mean of 3.81 over 27 questions, against 52.1% and 3.08 before Ask had tools; these address its weakest answers. Checked: path_log's walks over a file, a directory, a missing path, a limit, pages and a read budget; cargo test for the workspace (the actions crates on their own), the wasm builds, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests and the docs build; the OpenAPI document, the CLI's operations and its reference are regenerated, and the MCP reference says both.
13 files+247−510/13 viewed
| 1 | 1 | //! `list_commits`: a branch's history, newest first, with each commit's | |
| 2 | − | //! message, author, time and page; with `path`, also the commit that last | |
| 3 | − | //! changed that file or directory. What an agent (or Ask g1t) reads to say | |
| 4 | − | //! what changed lately and why: g1t's commit messages say why. | |
| 2 | + | //! message, author, time and page; with `path`, that file's or directory's | |
| 3 | + | //! own history: the commits that changed it. What an agent (or Ask g1t) | |
| 4 | + | //! reads to say what changed lately and why: g1t's commit messages say why. | |
| 5 | 5 | //! | |
| 6 | − | //! The repos service reads it, as the caller may see it: `log` for the | |
| 7 | − | //! history, `last_commits` of the path's directory for its last change. | |
| 8 | − | //! The shaping is [`commit`], a plain function. | |
| 6 | + | //! The repos service reads it, as the caller may see it: `log` for a | |
| 7 | + | //! branch, `path_log` for a path. The shaping is [`commit`], a plain | |
| 8 | + | //! function. | |
| 9 | 9 | ||
| 10 | − | use g1t_contracts::repos::{Commit, LastCommits, LastCommitsArgs, LogArgs, RepoPath}; | |
| 10 | + | use g1t_contracts::repos::{Commit, LogArgs, PathLog, PathLogArgs, RepoPath}; | |
| 11 | 11 | use g1t_contracts::{Outcome, Viewer}; | |
| 12 | 12 | use serde_json::{Value, json}; | |
| 13 | 13 | use worker::Result; | |
| ⋯ | |||
| 22 | 22 | const PATH_BUDGET_MS: u64 = 8_000; | |
| 23 | 23 | ||
| 24 | 24 | pub fn description() -> &'static str { | |
| 25 | − | "A branch's commits, newest first (the default branch unless `ref` names a branch, tag or commit): each with its `sha`, `message` (why it was made: g1t's commit messages say), `author`, `authored_at` and `html_url`. `limit` is 30 unless you say, at most 100. With `path` (a file or directory), also `last_changed`: the commit that last changed it." | |
| 25 | + | "A branch's commits, newest first (the default branch unless `ref` names a branch, tag or commit): each with its `sha`, `message` (why it was made: g1t's commit messages say), `author`, `authored_at` and `html_url`. `limit` is 30 unless you say, at most 100. With `path` (a file or directory), only the commits that changed it, newest first (its history: added, changed, removed), with `last_changed`, the newest of them, and `complete` false when a long history was not walked to its start in time." | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | pub fn input() -> Value { | |
| ⋯ | |||
| 31 | 31 | "properties": { | |
| 32 | 32 | "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }, | |
| 33 | 33 | "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out." }, | |
| 34 | − | "path": { "type": "string", "description": "A file or directory, such as scripts/deploy/npm-ci.mjs: adds the commit that last changed it." }, | |
| 34 | + | "path": { "type": "string", "description": "A file or directory, such as scripts/deploy/npm-ci.mjs: only the commits that changed it." }, | |
| 35 | 35 | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "How many commits: 30 unless you say, at most 100." }, | |
| 36 | 36 | }, | |
| 37 | 37 | "required": ["repo"], | |
| ⋯ | |||
| 70 | 70 | let site = services.addresses.site.clone(); | |
| 71 | 71 | let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned); | |
| 72 | 72 | let path = input["path"].as_str().unwrap_or_default().trim().trim_matches('/').to_owned(); | |
| 73 | − | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 73 | + | let full = format!("{}/{}", repo.namespace, repo.name); | |
| 74 | + | if path.is_empty() { | |
| 75 | + | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 76 | + | &services.repos, | |
| 77 | + | "log", | |
| 78 | + | &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, limit: limit(input) }, | |
| 79 | + | ) | |
| 80 | + | .await?; | |
| 81 | + | return Ok(match log { | |
| 82 | + | Outcome::Ok(commits) => Outcome::Ok(json!({ | |
| 83 | + | "repo": full, | |
| 84 | + | "commits": commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(), | |
| 85 | + | })), | |
| 86 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 87 | + | }); | |
| 88 | + | } | |
| 89 | + | // A path's own history: the commits that changed it, newest first. | |
| 90 | + | let history: Outcome<PathLog> = g1t_kit::call( | |
| 74 | 91 | &services.repos, | |
| 75 | − | "log", | |
| 76 | − | &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref: git_ref.clone(), limit: limit(input) }, | |
| 92 | + | "path_log", | |
| 93 | + | &PathLogArgs { | |
| 94 | + | path: repo.clone(), | |
| 95 | + | viewer: viewer.clone(), | |
| 96 | + | git_ref, | |
| 97 | + | file_path: path.clone(), | |
| 98 | + | limit: limit(input), | |
| 99 | + | budget_ms: Some(PATH_BUDGET_MS), | |
| 100 | + | }, | |
| 77 | 101 | ) | |
| 78 | 102 | .await?; | |
| 79 | − | let commits = match log { | |
| 80 | − | Outcome::Ok(commits) => commits, | |
| 103 | + | let history = match history { | |
| 104 | + | Outcome::Ok(history) => history, | |
| 81 | 105 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 82 | 106 | }; | |
| 83 | − | let mut answer = json!({ | |
| 84 | − | "repo": format!("{}/{}", repo.namespace, repo.name), | |
| 85 | − | "commits": commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(), | |
| 86 | − | }); | |
| 87 | − | if !path.is_empty() { | |
| 88 | − | let (directory, name) = match path.rsplit_once('/') { | |
| 89 | − | Some((directory, name)) => (directory.to_owned(), name.to_owned()), | |
| 90 | − | None => (String::new(), path.clone()), | |
| 91 | − | }; | |
| 92 | − | let last: Outcome<LastCommits> = g1t_kit::call( | |
| 93 | − | &services.repos, | |
| 94 | − | "last_commits", | |
| 95 | − | &LastCommitsArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, tree_path: directory, budget_ms: Some(PATH_BUDGET_MS) }, | |
| 96 | − | ) | |
| 97 | − | .await?; | |
| 98 | − | answer["path"] = json!(path); | |
| 99 | − | answer["last_changed"] = match last { | |
| 100 | − | Outcome::Ok(last) => match last.entries.iter().find(|entry| entry.name == name) { | |
| 101 | − | Some(entry) => commit(&site, repo, &entry.commit), | |
| 102 | − | None if last.complete => json!(format!("{path} is not in this repository at this ref.")), | |
| 103 | − | None => json!("Not found in time: its history is long. Look through the commits for it."), | |
| 104 | − | }, | |
| 105 | − | Outcome::Fail(failure) => json!(failure.message), | |
| 106 | − | }; | |
| 107 | − | } | |
| 108 | − | Ok(Outcome::Ok(answer)) | |
| 107 | + | let last_changed = match history.commits.first() { | |
| 108 | + | Some(first) => commit(&site, repo, first), | |
| 109 | + | None if history.complete => json!(format!("{path} is not in this repository at this ref.")), | |
| 110 | + | None => json!("Not found in time: its history is long. Ask with a smaller limit, or read the branch's commits."), | |
| 111 | + | }; | |
| 112 | + | Ok(Outcome::Ok(json!({ | |
| 113 | + | "repo": full, | |
| 114 | + | "path": path, | |
| 115 | + | "commits": history.commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(), | |
| 116 | + | "last_changed": last_changed, | |
| 117 | + | "complete": history.complete, | |
| 118 | + | }))) | |
| 109 | 119 | } | |
| 110 | 120 | ||
| 111 | 121 | #[cfg(test)] | |
| 1563 | 1563 | "One workflow, named by its id, its file name (ci.yml), its path or its name (CI): what list_workflows says of it, with `source`, its file as written on `ref` (the default branch unless you give a branch or tag), and `ref`, the branch or tag read." | |
| 1564 | 1564 | } | |
| 1565 | 1565 | Op::ListWorkflowRuns => { | |
| 1566 | − | "A repository's workflow runs, newest first: of one workflow (its id or file name), a branch, an event, a pull request's number, or a commit." | |
| 1566 | + | "A repository's workflow runs, newest first: of one workflow (its id or file name), a branch, an event, a pull request's number, a commit, or a status or conclusion (`status`: failure for the failed ones, in_progress for those still going). A run whose `status` is not completed has no result yet." | |
| 1567 | 1567 | } | |
| 1568 | 1568 | Op::GetWorkflowRun => { | |
| 1569 | 1569 | "One workflow run with its jobs: each job's steps and how they went, its annotations (::error:: and the like), and why it stopped. Read a job's log with get_job_logs. `attempts` lists every attempt (each re-run is one) with who started it and how it ended; give `attempt` to read an earlier one, whose jobs keep their own ids and logs." | |
| ⋯ | |||
| 2599 | 2599 | "event": { "type": "string", "description": "push, pull_request, schedule, workflow_dispatch…" }, | |
| 2600 | 2600 | "pull": { "type": "integer", "description": "A pull request's number." }, | |
| 2601 | 2601 | "sha": { "type": "string", "description": "A commit." }, | |
| 2602 | + | "status": { "type": "string", "description": "Only runs with this status (queued, in_progress, waiting, completed) or conclusion (success, failure, cancelled, skipped, timed_out): failure finds the failed ones." }, | |
| 2602 | 2603 | "limit": { "type": "integer", "description": "At most 100; 50 if not given." }, | |
| 2603 | 2604 | }), | |
| 2604 | 2605 | &["repo"], | |
| ⋯ | |||
| 4699 | 4700 | "event": optional_text(input, "event"), | |
| 4700 | 4701 | "pull": integer(input, "pull"), | |
| 4701 | 4702 | "sha": optional_text(input, "sha"), | |
| 4703 | + | "status": optional_text(input, "status"), | |
| 4702 | 4704 | "limit": integer(input, "limit"), | |
| 4703 | 4705 | }), | |
| 4704 | 4706 | ) | |
| 769 | 769 | "GET", | |
| 770 | 770 | "/repos/:owner/:name/actions/workflows/:workflow/runs", | |
| 771 | 771 | Op::ListWorkflowRuns, | |
| 772 | − | &[("branch", "branch"), ("event", "event"), ("per_page", "limit")], | |
| 772 | + | &[("branch", "branch"), ("event", "event"), ("status", "status"), ("head_sha", "sha"), ("per_page", "limit")], | |
| 773 | 773 | ), | |
| 774 | 774 | route( | |
| 775 | 775 | "POST", | |
| ⋯ | |||
| 799 | 799 | "GET", | |
| 800 | 800 | "/repos/:owner/:name/actions/runs", | |
| 801 | 801 | Op::ListWorkflowRuns, | |
| 802 | − | &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")], | |
| 802 | + | &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("status", "status"), ("per_page", "limit")], | |
| 803 | 803 | ), | |
| 804 | 804 | route( | |
| 805 | 805 | "GET", | |
| 244 | 244 | | --- | --- | --- | --- | | |
| 245 | 245 | | [`list`](/reference/api/repositories/list-repos/) | Repositories you can see, optionally filtered by `query`. | None | `repo:read` | | |
| 246 | 246 | | [`get`](/reference/api/repositories/get-repo/) | One repository's details. | `repo` | `repo:read` | | |
| 247 | − | | [`commits`](/reference/api/repositories/list-commits/) | A branch's commits, newest first, each with its `sha`, `message`, `author`, `authored_at` and `html_url`; `ref` names another branch, tag or commit, `limit` is 30 unless you say (at most 100). With `path`, `last_changed`: the commit that last changed that file or directory. | `repo` | `code:read` | | |
| 247 | + | | [`commits`](/reference/api/repositories/list-commits/) | A branch's commits, newest first, each with its `sha`, `message`, `author`, `authored_at` and `html_url`; `ref` names another branch, tag or commit, `limit` is 30 unless you say (at most 100). With `path`, only the commits that changed that file or directory (its history), newest first, with `last_changed` (the newest) and `complete` (false when a long history was not walked to its start in time). | `repo` | `code:read` | | |
| 248 | 248 | | [`contents`](/reference/api/repositories/get-contents/) | What it holds at `path` (the root when left out) at `ref` (the default branch when left out): a file's `text` (null for a binary one; at most 200,000 characters), or a directory's `entries` and its README. | `repo` | `code:read` | | |
| 249 | 249 | | [`tarball`](/reference/api/repositories/download-tarball-archive/) | A link to a branch, tag or commit (`ref`, the default branch when left out) as a `.tar.gz` that needs no token, good for 10 minutes, as `url`, with the `commit` it is of. | `repo` | `code:read` | | |
| 250 | 250 | | [`zipball`](/reference/api/repositories/download-zipball-archive/) | The same as a `.zip`. | `repo` | `code:read` | | |
| ⋯ | |||
| 422 | 422 | | --- | --- | --- | --- | | |
| 423 | 423 | | [`list`](/reference/api/actions/list-workflows/) | The workflows, with their events, state, problems, notes on what runs differently, manual-run inputs and last run. | `repo` | `workflows:read` | | |
| 424 | 424 | | [`get`](/reference/api/actions/get-workflow/) | One workflow by its id, file name, path or name, as `list` gives it, with `source`, its file on `ref` (the default branch unless given). | `repo`, `workflow` | `workflows:read` | | |
| 425 | − | | [`list_runs`](/reference/api/actions/list-runs-of-workflow/) | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull` or `sha`. | `repo` | `workflows:read` | | |
| 425 | + | | [`list_runs`](/reference/api/actions/list-runs-of-workflow/) | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull`, `sha`, or `status`: a status (`queued`, `in_progress`, `completed`) or a conclusion (`failure` for the failed runs, `success`, `cancelled`). | `repo` | `workflows:read` | | |
| 426 | 426 | | [`get_run`](/reference/api/actions/get-workflow-run/) | A run with its jobs, their steps and annotations, and its `attempts`; `attempt` reads an earlier attempt, with the jobs it had then. | `repo`, `id` | `workflows:read` | | |
| 427 | 427 | | [`job_logs`](/reference/api/actions/get-job-logs/) | A job's log after `after`, or with `tail` its last chunks (at most 500), where a failed job says why; `done` says if more will come. | `repo`, `job` | `workflows:read` | | |
| 428 | 428 | | [`dispatch`](/reference/api/actions/dispatch-workflow/) | Run a `workflow_dispatch` workflow on `ref` with `inputs`. Write role. | `repo`, `workflow` | `workflows:write` | | |
Binary or large file; its contents are not shown.
| 36 | 36 | ||
| 37 | 37 | /** `#N` in an answer, leaving out run, deploy and job numbers. */ | |
| 38 | 38 | export function numbersIn(text) { | |
| 39 | + | const all = String(text); | |
| 40 | + | const lines = all.split("\n"); | |
| 39 | 41 | const found = new Set(); | |
| 40 | − | for (const m of String(text).matchAll(/#(\d{1,6})\b/g)) { | |
| 41 | − | const before = String(text).slice(Math.max(0, m.index - 24), m.index); | |
| 42 | − | if (/(run|deploy|build|workflow|attempt|job|ci|release|step)\W*$/i.test(before)) continue; | |
| 42 | + | for (const m of all.matchAll(/#(\d{1,6})\b/g)) { | |
| 43 | + | const before = all.slice(Math.max(0, m.index - 24), m.index); | |
| 44 | + | if (/(run|deploy|build|workflow|attempt|job|ci|release|step|scanning|caches)\W*$/i.test(before)) continue; | |
| 45 | + | // A link's own address says what it is: a run or a deployment is not an issue. | |
| 46 | + | const link = all.slice(m.index).match(/^#\d+\]\(([^)]+)\)/); | |
| 47 | + | if (link && /\/actions\/runs\/|\/deployments|\/releases\//.test(link[1])) continue; | |
| 48 | + | // In a table whose header names runs, a #N is a run's number. | |
| 49 | + | const row = all.slice(0, m.index).split("\n").length - 1; | |
| 50 | + | if (lines[row]?.trimStart().startsWith("|")) { | |
| 51 | + | let at = row; | |
| 52 | + | while (at > 0 && lines[at - 1].trimStart().startsWith("|")) at--; | |
| 53 | + | if (/\b(run|deploy|workflow|build)s?\b/i.test(lines[at] ?? "")) continue; | |
| 54 | + | } | |
| 43 | 55 | found.add(Number(m[1])); | |
| 44 | 56 | } | |
| 45 | 57 | return [...found]; |
| 671 | 671 | pub pull: Option<u32>, | |
| 672 | 672 | #[serde(default)] | |
| 673 | 673 | pub sha: Option<String>, | |
| 674 | + | /// GitHub's `status` filter: a status (`queued`, `in_progress`, | |
| 675 | + | /// `waiting`, `completed`) or a conclusion (`success`, `failure`, | |
| 676 | + | /// `cancelled`, `skipped`, `timed_out`, `neutral`, `action_required`). | |
| 677 | + | #[serde(default)] | |
| 678 | + | pub status: Option<String>, | |
| 674 | 679 | #[serde(default)] | |
| 675 | 680 | pub limit: Option<u32>, | |
| 676 | 681 | } |
| 574 | 574 | pub budget_ms: Option<u64>, | |
| 575 | 575 | } | |
| 576 | 576 | ||
| 577 | + | /// `path_log`: the commits on the first-parent chain from `ref` (the | |
| 578 | + | /// default branch when absent) that changed `file_path`, a file or a | |
| 579 | + | /// directory, newest first, at most `limit`. Returns `Outcome<PathLog>`. | |
| 580 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 581 | + | #[serde(rename_all = "camelCase")] | |
| 582 | + | pub struct PathLogArgs { | |
| 583 | + | pub path: RepoPath, | |
| 584 | + | pub viewer: Viewer, | |
| 585 | + | #[serde(default, rename = "ref")] | |
| 586 | + | pub git_ref: Option<String>, | |
| 587 | + | pub file_path: String, | |
| 588 | + | pub limit: u32, | |
| 589 | + | /// Answer within this many milliseconds with what was found. | |
| 590 | + | #[serde(default)] | |
| 591 | + | pub budget_ms: Option<u64>, | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | /// A path's history: `complete` is false when the walk ran out of time or | |
| 595 | + | /// reads before the first commit and before `limit`. | |
| 596 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 597 | + | #[serde(rename_all = "camelCase")] | |
| 598 | + | pub struct PathLog { | |
| 599 | + | pub commits: Vec<Commit>, | |
| 600 | + | pub complete: bool, | |
| 601 | + | } | |
| 602 | + | ||
| 577 | 603 | /// An entry of a directory and the commit that last changed it. | |
| 578 | 604 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 579 | 605 | pub struct LastCommit { |
| 609 | 609 | Param { name: "workflow", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 610 | 610 | Param { name: "branch", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 611 | 611 | Param { name: "event", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 612 | + | Param { name: "status", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 613 | + | Param { name: "head_sha", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 612 | 614 | Param { name: "per_page", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 613 | 615 | ], | |
| 614 | 616 | body: &[], | |
| ⋯ | |||
| 643 | 645 | Param { name: "event", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 644 | 646 | Param { name: "pull", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 645 | 647 | Param { name: "head_sha", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 648 | + | Param { name: "status", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 646 | 649 | Param { name: "per_page", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 647 | 650 | ], | |
| 648 | 651 | body: &[], | |
| 279 | 279 | sql.push_str(" AND sha = ?"); | |
| 280 | 280 | binds.push(sha.as_str().into()); | |
| 281 | 281 | } | |
| 282 | + | // GitHub's `status` takes a status or a conclusion. | |
| 283 | + | if let Some(status) = a.status.as_deref().map(str::trim).filter(|status| !status.is_empty()) { | |
| 284 | + | sql.push_str(" AND (status = ? OR conclusion = ?)"); | |
| 285 | + | binds.push(status.into()); | |
| 286 | + | binds.push(status.into()); | |
| 287 | + | } | |
| 282 | 288 | sql.push_str(" ORDER BY id DESC LIMIT ?"); | |
| 283 | 289 | binds.push(a.limit.unwrap_or(RUNS_SHOWN).clamp(1, 100).into()); | |
| 284 | 290 | let rows = self.db.prepare(sql).bind(&binds)?.all().await?.results::<RunRow>()?; |
| 317 | 317 | \"owner/name\", here usually \"{slug}/…\".\n\ | |
| 318 | 318 | - For one pull request, read it with pull_request `get` (status, checks, reviews) and `changes` for \ | |
| 319 | 319 | the diff; for one issue, issue `get`.\n\ | |
| 320 | − | - Why a run or a deploy failed: workflow `list_runs` to find the run (by workflow name and number), \ | |
| 320 | + | - Why a run or a deploy failed: workflow `list_runs` with `status` \"failure\" (and `workflow`, such as \ | |
| 321 | + | deploy.yml) finds the failed runs at once; don't page through every run. A run whose `status` is not \ | |
| 322 | + | completed (queued, in_progress, waiting, pending) has no result yet: say it is still going, never that it \ | |
| 323 | + | passed. To say whether something is healthy now, look at the newest completed run and anything still going. \ | |
| 324 | + | Then \ | |
| 321 | 325 | `get_run` for its jobs and steps and which one failed, then `job_logs` with `tail` (such as 200) on the \ | |
| 322 | 326 | failed job, and quote the error line it ends with. Name the run's number, the job and the step.\n\ | |
| 323 | 327 | - How a project builds, tests or deploys: read its files with the repository tool's `contents`: the \ | |
| 324 | 328 | root first (its README), then CONTRIBUTING, package.json's scripts, Cargo.toml, a Makefile or the \ | |
| 325 | 329 | workflows under .g1t/workflows. Give the commands they actually contain.\n\ | |
| 326 | − | - What changed and why: the repository tool's `commits` (with `path` for one file, which adds the \ | |
| 327 | − | commit that last changed it); g1t's commit messages say why. Cite commits by short sha with links.\n\ | |
| 330 | + | - What changed and why: the repository tool's `commits`; with `path` (a file or directory) it gives \ | |
| 331 | + | only the commits that changed it, newest first, so why a file exists or how it came to be is its oldest \ | |
| 332 | + | commits and what changed lately its newest. g1t's commit messages say why: quote what they say rather \ | |
| 333 | + | than guess. Cite commits by short sha with links.\n\ | |
| 328 | 334 | - Whether production is healthy: the latest Deploy runs and deployments, then the observability \ | |
| 329 | 335 | tool's `overview` and `errors` for the project. If it reports no telemetry at all, say so rather than \ | |
| 330 | 336 | calling it healthy.\n\ |
| 310 | 310 | Ok(Walk { progress: Progress { head: head.to_owned(), found, open, at }, reads: trees.reads }) | |
| 311 | 311 | } | |
| 312 | 312 | ||
| 313 | + | /// The commits on the first-parent chain from `head` that changed `path` | |
| 314 | + | /// (a file or a directory; empty for the whole tree), newest first: at most | |
| 315 | + | /// `limit`, within `max_reads` reads of the store and before `out_of_time` | |
| 316 | + | /// says so. A commit changed the path when its hash there differs from its | |
| 317 | + | /// parent's, appearing and going away included. `complete` is true when the | |
| 318 | + | /// walk reached the first commit or found `limit`. | |
| 319 | + | pub async fn path_log<R: GitRepo>( | |
| 320 | + | repo: &R, | |
| 321 | + | head: &str, | |
| 322 | + | path: &str, | |
| 323 | + | limit: usize, | |
| 324 | + | out_of_time: &dyn Fn() -> bool, | |
| 325 | + | max_reads: u32, | |
| 326 | + | ) -> Result<(Vec<Commit>, bool)> { | |
| 327 | + | let mut trees = Trees { repo, read: HashMap::new(), reads: 0 }; | |
| 328 | + | let path = path.trim_matches('/'); | |
| 329 | + | let (dir, name) = match path.rsplit_once('/') { | |
| 330 | + | Some((dir, name)) => (dir.to_owned(), name.to_owned()), | |
| 331 | + | None => (String::new(), path.to_owned()), | |
| 332 | + | }; | |
| 333 | + | let segments = dir.split('/').filter(|segment| !segment.is_empty()).count(); | |
| 334 | + | let batch = (READ_AHEAD * (segments + 1)) as u32 + 1; | |
| 335 | + | let mut history = trees.log(head).await?; | |
| 336 | + | let mut changed = Vec::new(); | |
| 337 | + | let mut index = 0usize; | |
| 338 | + | let mut read_ahead_to = 0usize; | |
| 339 | + | // The hash of the path in a commit's tree: the root's own for an empty path. | |
| 340 | + | async fn hash_at<R: GitRepo>(trees: &mut Trees<'_, R>, root: &str, dir: &str, name: &str) -> Result<Option<String>> { | |
| 341 | + | if name.is_empty() { | |
| 342 | + | return Ok(Some(root.to_owned())); | |
| 343 | + | } | |
| 344 | + | let Some(parent) = trees.dir(root, dir).await? else { return Ok(None) }; | |
| 345 | + | Ok(trees.entries(Some(&parent)).await?.remove(name)) | |
| 346 | + | } | |
| 347 | + | let Some(first) = history.first().cloned() else { return Ok((changed, true)) }; | |
| 348 | + | let mut current = hash_at(&mut trees, &first.tree_hash, &dir, &name).await?; | |
| 349 | + | loop { | |
| 350 | + | if changed.len() >= limit { | |
| 351 | + | return Ok((changed, true)); | |
| 352 | + | } | |
| 353 | + | let Some(commit) = history.get(index).cloned() else { return Ok((changed, true)) }; | |
| 354 | + | let needs_page = index + 1 >= history.len() && !commit.parents.is_empty(); | |
| 355 | + | if (needs_page || index >= read_ahead_to) && index > 0 && (out_of_time() || trees.reads + batch > max_reads) { | |
| 356 | + | return Ok((changed, false)); | |
| 357 | + | } | |
| 358 | + | if needs_page { | |
| 359 | + | let more = trees.log(&commit.parents[0]).await?; | |
| 360 | + | history.drain(..index); | |
| 361 | + | read_ahead_to = read_ahead_to.saturating_sub(index); | |
| 362 | + | index = 0; | |
| 363 | + | history.extend(more); | |
| 364 | + | } | |
| 365 | + | if index >= read_ahead_to { | |
| 366 | + | trees.read.clear(); | |
| 367 | + | let ahead = history.iter().skip(index + 1).take(READ_AHEAD).map(|commit| commit.tree_hash.clone()).collect(); | |
| 368 | + | trees.prefetch_dirs(ahead, &dir).await?; | |
| 369 | + | read_ahead_to = index + READ_AHEAD; | |
| 370 | + | } | |
| 371 | + | let Some(parent) = history.get(index + 1).cloned() else { | |
| 372 | + | // The oldest commit read: the first commit added what it has. | |
| 373 | + | let first = commit.parents.is_empty(); | |
| 374 | + | if first && current.is_some() { | |
| 375 | + | changed.push(commit); | |
| 376 | + | } | |
| 377 | + | return Ok((changed, first)); | |
| 378 | + | }; | |
| 379 | + | let before = hash_at(&mut trees, &parent.tree_hash, &dir, &name).await?; | |
| 380 | + | if before != current { | |
| 381 | + | changed.push(commit); | |
| 382 | + | } | |
| 383 | + | current = before; | |
| 384 | + | index += 1; | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 313 | 388 | /// Where the progress for a ref and path of a repository is kept: in this | |
| 314 | 389 | /// colo's cache, and shared between colos when the service has `GIT_CACHE` | |
| 315 | 390 | /// (shared.rs). | |
| ⋯ | |||
| 449 | 524 | fake | |
| 450 | 525 | } | |
| 451 | 526 | ||
| 527 | + | #[test] | |
| 528 | + | fn a_files_history_is_the_commits_that_changed_it() { | |
| 529 | + | let fake = repo(); | |
| 530 | + | let never = || false; | |
| 531 | + | let names = |found: Vec<Commit>| found.into_iter().map(|commit| commit.hash).collect::<Vec<_>>(); | |
| 532 | + | let (found, complete) = run(path_log(&fake, "main", "src/a.rs", 10, &never, MAX_READS)).unwrap(); | |
| 533 | + | assert_eq!((names(found), complete), (vec!["c2".to_owned(), "c1".to_owned()], true)); | |
| 534 | + | let (found, _) = run(path_log(&fake, "main", "README.md", 10, &never, MAX_READS)).unwrap(); | |
| 535 | + | assert_eq!(names(found), vec!["c3", "c1"]); | |
| 536 | + | // A directory changes with what is in it. | |
| 537 | + | let (found, _) = run(path_log(&fake, "main", "src", 10, &never, MAX_READS)).unwrap(); | |
| 538 | + | assert_eq!(names(found), vec!["c2", "c1"]); | |
| 539 | + | let (found, complete) = run(path_log(&fake, "main", "src/a.rs", 1, &never, MAX_READS)).unwrap(); | |
| 540 | + | assert_eq!((names(found), complete), (vec!["c2".to_owned()], true)); | |
| 541 | + | let (found, _) = run(path_log(&fake, "main", "nope.txt", 10, &never, MAX_READS)).unwrap(); | |
| 542 | + | assert!(found.is_empty()); | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | #[test] | |
| 546 | + | fn a_long_history_is_walked_across_pages_within_its_reads() { | |
| 547 | + | let fake = long(130); | |
| 548 | + | let never = || false; | |
| 549 | + | let (found, complete) = run(path_log(&fake, "main", ".dockerignore", 10, &never, MAX_READS)).unwrap(); | |
| 550 | + | assert_eq!((found.len(), found[0].hash.as_str(), complete), (1, "c0", true)); | |
| 551 | + | let (found, complete) = run(path_log(&fake, "main", ".dockerignore", 10, &never, 40)).unwrap(); | |
| 552 | + | assert!(found.is_empty() && !complete, "out of reads before the first commit"); | |
| 553 | + | } | |
| 554 | + | ||
| 452 | 555 | /// A long history, newest first: `.dockerignore` is added by the first | |
| 453 | 556 | /// commit and never changed; `README.md` changes in every commit after | |
| 454 | 557 | /// it, under a tree of its own (`t<n>`). | |
| 87 | 87 | /// stops and keeps its progress for the next call. The site asks from a | |
| 88 | 88 | /// waitUntil, which may run 30 s past its response. | |
| 89 | 89 | const LAST_COMMITS_WALK_MS: u64 = 20_000; | |
| 90 | + | /// How long a path's history may walk when the caller gives no budget. | |
| 91 | + | const PATH_LOG_WALK_MS: u64 = 10_000; | |
| 90 | 92 | ||
| 91 | 93 | /// One path segment, percent-encoded for a cache key. | |
| 92 | 94 | fn urlencoding_segment(segment: &str) -> String { | |
| ⋯ | |||
| 948 | 950 | Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?)) | |
| 949 | 951 | } | |
| 950 | 952 | ||
| 953 | + | /// The commits that changed a file or directory, newest first: the | |
| 954 | + | /// first-parent chain walked within `PATH_LOG_WALK_MS` (or the caller's | |
| 955 | + | /// budget) and `last_commits::MAX_READS`. | |
| 956 | + | async fn path_log(&self, a: g1t_contracts::repos::PathLogArgs) -> Result<Outcome<g1t_contracts::repos::PathLog>> { | |
| 957 | + | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 958 | + | return Ok(not_found()); | |
| 959 | + | }; | |
| 960 | + | let git = self.read_git(&repo).await?; | |
| 961 | + | let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone()); | |
| 962 | + | let Some(head) = git.log(&git_ref, 1).await?.into_iter().next() else { | |
| 963 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such branch, tag or commit.")); | |
| 964 | + | }; | |
| 965 | + | let started = worker::Date::now().as_millis(); | |
| 966 | + | let budget = a.budget_ms.unwrap_or(PATH_LOG_WALK_MS); | |
| 967 | + | let out_of_time = move || worker::Date::now().as_millis().saturating_sub(started) > budget; | |
| 968 | + | let limit = a.limit.clamp(1, 100) as usize; | |
| 969 | + | let (commits, complete) = last_commits::path_log(&git, &head.hash, &a.file_path, limit, &out_of_time, last_commits::MAX_READS).await?; | |
| 970 | + | Ok(Outcome::Ok(g1t_contracts::repos::PathLog { commits, complete })) | |
| 971 | + | } | |
| 972 | + | ||
| 951 | 973 | /// Which commit last changed each entry of a directory. A finished | |
| 952 | 974 | /// answer is kept in this colo's cache by repository, head commit and | |
| 953 | 975 | /// path: a commit's history never changes, so it is good for as long as | |
| ⋯ | |||
| 2657 | 2679 | "git_access" => reply(&repos.git_access(args(body)?).await?), | |
| 2658 | 2680 | "branches" => reply(&repos.branches(args(body)?).await?), | |
| 2659 | 2681 | "last_commits" => reply(&repos.last_commits(args(body)?).await?), | |
| 2682 | + | "path_log" => reply(&repos.path_log(args(body)?).await?), | |
| 2660 | 2683 | "branch_drift" => reply(&repos.branch_drift(args(body)?).await?), | |
| 2661 | 2684 | "tags" => reply(&repos.tags(args(body)?).await?), | |
| 2662 | 2685 | // The About: what the Files page shows beside the files (about.rs). | |