109 files+651−5993 files not counted0/109 viewed
| 128 | 128 | run: node scripts/deploy.mjs manifest --check | |
| 129 | 129 | - name: The Claude Code plugin's tests | |
| 130 | 130 | run: node --test plugins/claude-code/test/hooks.test.mjs | |
| 131 | + | # What plugins-release.yml publishes to flagon-io/g1t-plugins: the | |
| 132 | + | # manifests agree, and the tree is only the plugin. | |
| 133 | + | - name: The plugin's published marketplace | |
| 134 | + | run: | | |
| 135 | + | node scripts/plugins-release.mjs check | |
| 136 | + | node --test scripts/plugins-release.test.mjs | |
| 131 | 137 | ||
| 132 | 138 | build: | |
| 133 | 139 | name: Build |
| 1 | + | # Publishes g1t's Claude Code plugin marketplace to flagon-io/g1t-plugins | |
| 2 | + | # on g1t.sh, so people add it without cloning this repository: | |
| 3 | + | # | |
| 4 | + | # claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 5 | + | # claude plugin install g1t@g1t | |
| 6 | + | # | |
| 7 | + | # This repository is the plugin's source: .claude-plugin/marketplace.json | |
| 8 | + | # and plugins/claude-code/. scripts/plugins-release.mjs builds the published | |
| 9 | + | # tree from them (the manifest, the plugin without its tests, a README and | |
| 10 | + | # the license, the plugin's source rewritten to ./plugins/g1t), checks it | |
| 11 | + | # holds nothing else and nothing secret, and commits it to g1t-plugins' | |
| 12 | + | # main only when it changed; each new version is tagged g1t--v<version>, | |
| 13 | + | # as `claude plugin tag` would. A change to the plugin's files without a | |
| 14 | + | # new version stops the run, as Claude Code updates installed plugins only | |
| 15 | + | # when the version changes. | |
| 16 | + | # | |
| 17 | + | # Runs on a push to main that changes the plugin, its manifest or the | |
| 18 | + | # script, and by hand. | |
| 19 | + | # | |
| 20 | + | # Needs the repository secret PLUGINS_PUBLISH_TOKEN: a workspace token of | |
| 21 | + | # flagon-io (g1t.sh/flagon-io/-/tokens, made by an owner; it acts as the | |
| 22 | + | # workspace, not a person), select repositories: g1t-plugins only, with | |
| 23 | + | # Code: read and write (code:write) and nothing else. g1t-plugins must | |
| 24 | + | # exist, public; this never creates it. The published tree has no workflow | |
| 25 | + | # files, so the token needs no workflow_files:write. The job's own token | |
| 26 | + | # reaches this repository only, and only to read it. | |
| 27 | + | name: Plugins release | |
| 28 | + | ||
| 29 | + | on: | |
| 30 | + | push: | |
| 31 | + | branches: [main] | |
| 32 | + | paths: | |
| 33 | + | - .claude-plugin/** | |
| 34 | + | - plugins/claude-code/** | |
| 35 | + | - scripts/plugins-release.mjs | |
| 36 | + | - LICENSE | |
| 37 | + | workflow_dispatch: | |
| 38 | + | ||
| 39 | + | permissions: | |
| 40 | + | contents: read | |
| 41 | + | ||
| 42 | + | concurrency: | |
| 43 | + | group: plugins-release | |
| 44 | + | cancel-in-progress: false | |
| 45 | + | ||
| 46 | + | jobs: | |
| 47 | + | publish: | |
| 48 | + | name: Publish flagon-io/g1t-plugins | |
| 49 | + | runs-on: ubuntu-latest | |
| 50 | + | timeout-minutes: 10 | |
| 51 | + | steps: | |
| 52 | + | - uses: actions/checkout@v5 | |
| 53 | + | with: | |
| 54 | + | # Without the job's own token in git's config: it would go with | |
| 55 | + | # the push to g1t-plugins beside the publish token, and be refused. | |
| 56 | + | persist-credentials: false | |
| 57 | + | - name: The plugin's tests | |
| 58 | + | run: | | |
| 59 | + | node --test plugins/claude-code/test/hooks.test.mjs | |
| 60 | + | node --test scripts/plugins-release.test.mjs | |
| 61 | + | - name: Publish | |
| 62 | + | env: | |
| 63 | + | PLUGINS_PUBLISH_TOKEN: ${{ secrets.PLUGINS_PUBLISH_TOKEN }} | |
| 64 | + | G1T_PLUGINS_REMOTE: ${{ github.server_url }}/flagon-io/g1t-plugins.git | |
| 65 | + | G1T_SOURCE_COMMIT: ${{ github.sha }} | |
| 66 | + | run: | | |
| 67 | + | if [ -z "${PLUGINS_PUBLISH_TOKEN}" ]; then | |
| 68 | + | echo "::error::Set the repository secret PLUGINS_PUBLISH_TOKEN (this workflow's header says what it needs)." | |
| 69 | + | exit 1 | |
| 70 | + | fi | |
| 71 | + | echo "::add-mask::${PLUGINS_PUBLISH_TOKEN}" | |
| 72 | + | node scripts/plugins-release.mjs publish |
| 15 | 15 | | Something a person does on g1t.sh | The guide for it in `apps/docs/src/content/docs/guides/` | | |
| 16 | 16 | | An API route, a field, or an MCP tool | `apps/api/src/operations.rs` descriptions (they feed the OpenAPI document and the API reference), the example and notes for it in `apps/api/src/reference.json`, `reference/mcp.md`, and any guide that shows the call. Then refresh the docs' copy of the OpenAPI document with `G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi`; `cargo test` fails until you do. | | |
| 17 | 17 | | What coding agents are told about g1t | `guides/bring-your-own-agent.mdx`, `guides/claude-code-plugin.md`, the plugin's skills in `plugins/claude-code/`, and `apps/web/public/llms.txt` | | |
| 18 | + | | The Claude Code plugin's files (`plugins/claude-code/`, except `test/`) | Its version, the same in `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`: Claude Code updates an installed plugin only when it changes. On main, `plugins-release.yml` publishes it to `flagon-io/g1t-plugins`, and refuses a changed plugin with a version already published. Never commit to g1t-plugins itself. | | |
| 18 | 19 | | Settings, limits or prices | The page that names them, and the table it is in | | |
| 19 | 20 | | A new feature | A section in the guide that owns it, linked from the docs home if it is a new task | | |
| 20 | 21 | | How g1t is put together: a service, a binding, a store, an event route, the path of a request | Its page in the docs' Architecture section (`apps/docs/src/content/docs/architecture/`), and its diagram in `apps/docs/src/diagrams/architecture.ts`. [How g1t ships](https://docs.g1t.sh/architecture/shipping/#keeping-this-section-true) says which page covers what. | |
| 1320 | 1320 | ||
| 1321 | 1321 | [[package]] | |
| 1322 | 1322 | name = "g1t" | |
| 1323 | − | version = "0.3.2" | |
| 1323 | + | version = "0.3.3" | |
| 1324 | 1324 | dependencies = [ | |
| 1325 | 1325 | "anstyle-query", | |
| 1326 | 1326 | "base64 0.22.1", |
| 30 | 30 | ||
| 31 | 31 | # 3. Install g1t's Claude Code plugin. It adds g1t's MCP server and records | |
| 32 | 32 | # your sessions; run /mcp in Claude Code and sign plugin:g1t:g1t in. | |
| 33 | − | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 33 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 34 | 34 | claude plugin install g1t@g1t | |
| 35 | 35 | ``` | |
| 36 | 36 | ||
| ⋯ | |||
| 143 | 143 | | `crates/sshd` | Git over SSH, bridged to Cloudflare Artifacts. Not deployed. | Rust | | |
| 144 | 144 | | `packages/contracts` | The same interfaces for TypeScript callers. | TypeScript | | |
| 145 | 145 | | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | CSS | | |
| 146 | − | | `plugins/claude-code` | g1t's Claude Code plugin: hooks, skills and commands. | JavaScript | | |
| 146 | + | | `plugins/claude-code` | g1t's Claude Code plugin: hooks, skills and commands, published to `flagon-io/g1t-plugins` on every change. | JavaScript | | |
| 147 | 147 | | `plugins/other-harnesses` | The AGENTS.md snippet for Codex, Cursor and other harnesses. | Markdown | | |
| 148 | 148 | | `deploy` | `stack.jsonc`, every deployable part and its resources; `self-host`, the Docker Compose version. | JSON, Docker Compose | | |
| 149 | 149 | ||
| 11 | 11 | //! not carry, come from the work service's newest pull requests of the | |
| 12 | 12 | //! repositories that had any; people's names from identity. Everything | |
| 13 | 13 | //! after the reads is [`shape`], a plain function, tested on its own. | |
| 14 | + | //! | |
| 15 | + | //! Shipping on g1t is reaching the default branch, then production. For the | |
| 16 | + | //! repositories whose default branch moved, `landed` lists the commits, each | |
| 17 | + | //! with its author's g1t username where their address is one's | |
| 18 | + | //! (commits.rs), and whether it is in production ([`production`]): carried | |
| 19 | + | //! by a successful deploy (a run of a deploy workflow on the default branch, | |
| 20 | + | //! for a repository that deploys itself, or a project's production | |
| 21 | + | //! deployment on g1t.page), still deploying, or failed to. | |
| 14 | 22 | ||
| 15 | 23 | use std::collections::{BTreeSet, HashMap}; | |
| 16 | 24 | ||
| 25 | + | use g1t_contracts::actions::{RunsArgs, WorkflowRun}; | |
| 17 | 26 | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS}; | |
| 18 | 27 | use g1t_contracts::identity::UsernamesArgs; | |
| 19 | 28 | use g1t_contracts::repos::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath}; | |
| ⋯ | |||
| 23 | 32 | use serde_json::{Value, json}; | |
| 24 | 33 | use worker::Result; | |
| 25 | 34 | ||
| 35 | + | use crate::commits::{self, Authors}; | |
| 26 | 36 | use crate::operations::Services; | |
| 27 | 37 | ||
| 28 | 38 | /// The span when none is given: the last day. | |
| ⋯ | |||
| 52 | 62 | const LANDED_MAX: usize = 40; | |
| 53 | 63 | /// How far back each of those repositories' history is read. | |
| 54 | 64 | const LANDED_LOG: u32 = 60; | |
| 65 | + | /// How many of each of those repositories' default-branch runs in the span | |
| 66 | + | /// are read for its deploys. | |
| 67 | + | const LANDED_RUNS: u32 = 100; | |
| 55 | 68 | ||
| 56 | 69 | /// What is read when no kinds are given: everything but comments, which | |
| 57 | 70 | /// would crowd out the rest on a busy day. | |
| 58 | 71 | const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"]; | |
| 59 | 72 | ||
| 60 | 73 | pub fn description() -> &'static str { | |
| 61 | − | "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, issues opened and closed, releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments, default-branch pushes and failed runs: what shipped, and what broke. `landed` lists the commits made on default branches in the span, one line each (`repo`, `commit`, `subject`, `author`, `time`, `url`): what shipped where it was pushed rather than merged. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events." | |
| 74 | + | "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, issues opened and closed, releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run, and `production` true for a run of a deploy workflow on the default branch: a repository that deploys itself). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments (projects' production deployments and successful deploy runs on default branches), failed ones, default-branch pushes and failed runs, and in `shipped` the landed commits by where they are. `landed` lists the commits made on default branches in the span, one line each (`repo`, `commit`, `subject`, `author` as git wrote it, `username` when the author's address is a g1t account's, `time`, `url`), and, where the repository deploys, `production`: `live` (a successful deploy carried it, named in `deploy` with its `url`), `deploying`, `failed` (its deploy failed and none since succeeded) or `not_deployed`. That is what shipped: what reached the default branch, and which of it is live. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events." | |
| 62 | 75 | } | |
| 63 | 76 | ||
| 64 | 77 | pub fn input() -> Value { | |
| ⋯ | |||
| 296 | 309 | if conclusion == "failure" { | |
| 297 | 310 | runs_failed += 1; | |
| 298 | 311 | } | |
| 312 | + | // A repository that deploys itself: its deploy workflow's | |
| 313 | + | // runs on the default branch are its production deployments. | |
| 314 | + | let on_default = text(data, "ref") == Some(format!("refs/heads/{}", repo.default_branch).as_str()); | |
| 315 | + | if on_default | |
| 316 | + | && text(data, "event") != Some("pull_request") | |
| 317 | + | && deploy_workflow(text(data, "workflow").unwrap_or_default(), text(data, "path").unwrap_or_default()) | |
| 318 | + | { | |
| 319 | + | shown["production"] = json!(true); | |
| 320 | + | match conclusion { | |
| 321 | + | "success" => production += 1, | |
| 322 | + | "failure" | "timed_out" => failed += 1, | |
| 323 | + | _ => {} | |
| 324 | + | } | |
| 325 | + | } | |
| 299 | 326 | } | |
| 300 | 327 | "package.published" => { | |
| 301 | 328 | for key in ["ecosystem", "name", "version"] { | |
| ⋯ | |||
| 320 | 347 | "failed_deployments": failed, | |
| 321 | 348 | "default_branch_pushes": landed, | |
| 322 | 349 | "failed_runs": runs_failed, | |
| 350 | + | // The landed commits by where they are in production (`run` | |
| 351 | + | // fills it in). | |
| 352 | + | "shipped": shipped(&[]), | |
| 323 | 353 | }, | |
| 324 | 354 | // What landed on default branches in the span, one line a commit | |
| 325 | 355 | // (`run` fills it in): what shipped where nothing was merged. | |
| ⋯ | |||
| 329 | 359 | }) | |
| 330 | 360 | } | |
| 331 | 361 | ||
| 362 | + | /// Whether a workflow deploys: its name or file says so (Deploy, | |
| 363 | + | /// deploy.yml, deploy-production.yml), and it is not for previews. | |
| 364 | + | pub fn deploy_workflow(name: &str, path: &str) -> bool { | |
| 365 | + | let file = path.rsplit('/').next().unwrap_or_default(); | |
| 366 | + | let named = format!("{name} {file}").to_lowercase(); | |
| 367 | + | named.contains("deploy") && !named.contains("preview") | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | /// Where a deploy of a commit to production is. | |
| 371 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 372 | + | pub enum DeployState { | |
| 373 | + | Succeeded, | |
| 374 | + | /// Queued, waiting or running. | |
| 375 | + | Running, | |
| 376 | + | Failed, | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | /// One deploy of a commit to production: a run of a repository's deploy | |
| 380 | + | /// workflow on its default branch, or a project's production deployment. | |
| 381 | + | #[derive(Clone, Debug, PartialEq)] | |
| 382 | + | pub struct Deploy { | |
| 383 | + | pub sha: String, | |
| 384 | + | pub state: DeployState, | |
| 385 | + | /// What it was, for a person: "Deploy #311". | |
| 386 | + | pub label: String, | |
| 387 | + | pub url: String, | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | /// A run as a deploy to production, when it is one: its deploy workflow's, | |
| 391 | + | /// on the default branch and not for a pull request. Cancelled and skipped | |
| 392 | + | /// runs deployed nothing. | |
| 393 | + | pub fn run_deploy(site: &str, repo: &str, default_branch: &str, run: &WorkflowRun) -> Option<Deploy> { | |
| 394 | + | if run.git_ref != format!("refs/heads/{default_branch}") || run.event == "pull_request" || !deploy_workflow(&run.name, &run.path) { | |
| 395 | + | return None; | |
| 396 | + | } | |
| 397 | + | let state = match (run.status.as_str(), run.conclusion.as_deref()) { | |
| 398 | + | ("completed", Some("success")) => DeployState::Succeeded, | |
| 399 | + | ("completed", Some("failure" | "timed_out")) => DeployState::Failed, | |
| 400 | + | ("completed", _) => return None, | |
| 401 | + | _ => DeployState::Running, | |
| 402 | + | }; | |
| 403 | + | Some(Deploy { | |
| 404 | + | sha: run.sha.clone(), | |
| 405 | + | state, | |
| 406 | + | label: format!("{} #{}", run.name, run.number), | |
| 407 | + | url: format!("{}/{repo}/actions/runs/{}", site.trim_end_matches('/'), run.id), | |
| 408 | + | }) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | /// A repository's deploys to production that the events name: projects' | |
| 412 | + | /// production deployments, and its deploy workflow's finished runs. | |
| 413 | + | pub fn event_deploys(site: &str, repo: &Repo, events: &[Event]) -> Vec<Deploy> { | |
| 414 | + | let site = site.trim_end_matches('/'); | |
| 415 | + | let full = format!("{}/{}", repo.namespace, repo.name); | |
| 416 | + | let mut found = Vec::new(); | |
| 417 | + | for event in events.iter().filter(|event| event.repo_id.as_deref() == Some(repo.id.as_str())) { | |
| 418 | + | let data = &event.data; | |
| 419 | + | match event.kind.as_str() { | |
| 420 | + | "deployment.succeeded" | "deployment.failed" if text(data, "kind") == Some("production") => { | |
| 421 | + | let Some(sha) = text(data, "commit") else { continue }; | |
| 422 | + | found.push(Deploy { | |
| 423 | + | sha: sha.to_owned(), | |
| 424 | + | state: if event.kind == "deployment.succeeded" { DeployState::Succeeded } else { DeployState::Failed }, | |
| 425 | + | label: format!("{}'s production deployment", text(data, "project").unwrap_or(&repo.name)), | |
| 426 | + | url: match text(data, "path") { | |
| 427 | + | Some(path) => format!("{site}{path}"), | |
| 428 | + | None => format!("{site}/{full}/deployments"), | |
| 429 | + | }, | |
| 430 | + | }); | |
| 431 | + | } | |
| 432 | + | "workflow.completed" => { | |
| 433 | + | let run = WorkflowRun { | |
| 434 | + | id: text(data, "runId").unwrap_or_default().to_owned(), | |
| 435 | + | path: text(data, "path").unwrap_or_default().to_owned(), | |
| 436 | + | name: text(data, "workflow").unwrap_or_default().to_owned(), | |
| 437 | + | number: data.get("number").and_then(Value::as_u64).unwrap_or_default(), | |
| 438 | + | event: text(data, "event").unwrap_or_default().to_owned(), | |
| 439 | + | git_ref: text(data, "ref").unwrap_or_default().to_owned(), | |
| 440 | + | sha: text(data, "sha").unwrap_or_default().to_owned(), | |
| 441 | + | status: "completed".to_owned(), | |
| 442 | + | conclusion: text(data, "conclusion").map(str::to_owned), | |
| 443 | + | ..WorkflowRun::default() | |
| 444 | + | }; | |
| 445 | + | if run.id.is_empty() || run.sha.is_empty() { | |
| 446 | + | continue; | |
| 447 | + | } | |
| 448 | + | found.extend(run_deploy(site, &full, &repo.default_branch, &run)); | |
| 449 | + | } | |
| 450 | + | _ => {} | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | found | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | /// Where each commit of a default branch's history (newest first) is in | |
| 457 | + | /// production, given the deploys seen: `live` when a successful deploy was | |
| 458 | + | /// of it or of a commit after it (which carries it), else `deploying` when | |
| 459 | + | /// one is under way, `failed` when one failed, or `not_deployed`; with the | |
| 460 | + | /// deploy that says so. None for every commit when the repository was not | |
| 461 | + | /// seen deploying at all. | |
| 462 | + | pub fn production<'a>(log: &[Commit], deploys: &'a [Deploy]) -> Vec<Option<(&'static str, Option<&'a Deploy>)>> { | |
| 463 | + | if deploys.is_empty() { | |
| 464 | + | return vec![None; log.len()]; | |
| 465 | + | } | |
| 466 | + | let same = |sha: &str, hash: &str| !sha.is_empty() && (hash.starts_with(sha) || sha.starts_with(hash)); | |
| 467 | + | // Each deploy at its commit's place in the history. | |
| 468 | + | let placed: Vec<(usize, &Deploy)> = deploys | |
| 469 | + | .iter() | |
| 470 | + | .filter_map(|deploy| log.iter().position(|commit| same(&deploy.sha, &commit.hash)).map(|at| (at, deploy))) | |
| 471 | + | .collect(); | |
| 472 | + | // The nearest deploy at or after a commit in the given state: the one | |
| 473 | + | // that first carried it. | |
| 474 | + | let nearest = |index: usize, state: DeployState| { | |
| 475 | + | placed.iter().filter(|(at, deploy)| *at <= index && deploy.state == state).max_by_key(|(at, _)| *at).map(|(_, deploy)| *deploy) | |
| 476 | + | }; | |
| 477 | + | (0..log.len()) | |
| 478 | + | .map(|index| { | |
| 479 | + | Some(if let Some(deploy) = nearest(index, DeployState::Succeeded) { | |
| 480 | + | ("live", Some(deploy)) | |
| 481 | + | } else if let Some(deploy) = nearest(index, DeployState::Running) { | |
| 482 | + | ("deploying", Some(deploy)) | |
| 483 | + | } else if let Some(deploy) = nearest(index, DeployState::Failed) { | |
| 484 | + | ("failed", Some(deploy)) | |
| 485 | + | } else { | |
| 486 | + | ("not_deployed", None) | |
| 487 | + | }) | |
| 488 | + | }) | |
| 489 | + | .collect() | |
| 490 | + | } | |
| 491 | + | ||
| 332 | 492 | /// One repository's default-branch commits made in the span, newest first, | |
| 333 | 493 | /// each as one line: its short sha, the first line of its message, who | |
| 334 | − | /// wrote it, when, and its page. | |
| 335 | − | pub fn landed(site: &str, repo: &str, commits: &[Commit], since: u64, until: u64) -> Vec<Value> { | |
| 494 | + | /// wrote it (and their username, when known), when, its page, and where it | |
| 495 | + | /// is in production when the repository deploys. | |
| 496 | + | pub fn landed(site: &str, repo: &str, commits: &[Commit], since: u64, until: u64, authors: &Authors, deploys: &[Deploy]) -> Vec<Value> { | |
| 336 | 497 | let site = site.trim_end_matches('/'); | |
| 498 | + | let states = production(commits, deploys); | |
| 337 | 499 | commits | |
| 338 | 500 | .iter() | |
| 339 | − | .filter(|commit| parse_rfc3339(&commit.authored_at).is_some_and(|at| at >= since && at < until)) | |
| 340 | − | .map(|commit| { | |
| 501 | + | .zip(states) | |
| 502 | + | .filter(|(commit, _)| parse_rfc3339(&commit.authored_at).is_some_and(|at| at >= since && at < until)) | |
| 503 | + | .map(|(commit, state)| { | |
| 341 | 504 | let subject: String = commit.message.lines().next().unwrap_or_default().trim().chars().take(160).collect(); | |
| 342 | − | json!({ | |
| 505 | + | let mut line = json!({ | |
| 343 | 506 | "repo": repo, | |
| 344 | 507 | "commit": commit.hash.chars().take(12).collect::<String>(), | |
| 345 | 508 | "subject": subject, | |
| 346 | 509 | "author": commit.author.name, | |
| 347 | 510 | "time": commit.authored_at, | |
| 348 | 511 | "url": format!("{site}/{repo}/commit/{}", commit.hash), | |
| 349 | − | }) | |
| 512 | + | }); | |
| 513 | + | if let Some(username) = commits::username(commit, authors) { | |
| 514 | + | line["username"] = json!(username); | |
| 515 | + | } | |
| 516 | + | if let Some((state, deploy)) = state { | |
| 517 | + | line["production"] = json!(state); | |
| 518 | + | if let Some(deploy) = deploy { | |
| 519 | + | line["deploy"] = json!({ "name": deploy.label, "url": deploy.url }); | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | line | |
| 350 | 523 | }) | |
| 351 | 524 | .collect() | |
| 352 | 525 | } | |
| 353 | 526 | ||
| 527 | + | /// How many landed commits are where, for the summary. | |
| 528 | + | pub fn shipped(lines: &[Value]) -> Value { | |
| 529 | + | let count = |state: &str| lines.iter().filter(|line| line["production"] == state).count(); | |
| 530 | + | json!({ | |
| 531 | + | "live": count("live"), | |
| 532 | + | "deploying": count("deploying"), | |
| 533 | + | "failed": count("failed"), | |
| 534 | + | "not_deployed": count("not_deployed"), | |
| 535 | + | }) | |
| 536 | + | } | |
| 537 | + | ||
| 354 | 538 | pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 355 | 539 | let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase(); | |
| 356 | 540 | if workspace.is_empty() { | |
| ⋯ | |||
| 463 | 647 | } | |
| 464 | 648 | pushed.sort_by(|a, b| b.1.cmp(&a.1)); | |
| 465 | 649 | let by_id: HashMap<&str, &Repo> = repos.iter().map(|repo| (repo.id.as_str(), repo)).collect(); | |
| 650 | + | // Each one's history, and its default branch's runs in the span: the | |
| 651 | + | // deploys among them, finished or not, say what reached production. | |
| 466 | 652 | let logs = pushed.iter().take(LANDED_REPOS).filter_map(|(id, _)| by_id.get(id.as_str()).copied()).map(|repo| async move { | |
| 467 | 653 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; | |
| 468 | − | let log: Result<Outcome<Vec<Commit>>> = | |
| 469 | − | g1t_kit::call(&services.repos, "log", &LogArgs { path, viewer: viewer.clone(), git_ref: None, limit: LANDED_LOG }).await; | |
| 470 | − | (format!("{}/{}", repo.namespace, repo.name), log) | |
| 654 | + | let log_args = LogArgs { path: path.clone(), viewer: viewer.clone(), git_ref: None, limit: LANDED_LOG }; | |
| 655 | + | let runs_args = RunsArgs { | |
| 656 | + | repo: path, | |
| 657 | + | viewer: viewer.clone(), | |
| 658 | + | workflow: None, | |
| 659 | + | branch: Some(repo.default_branch.clone()), | |
| 660 | + | event: None, | |
| 661 | + | pull: None, | |
| 662 | + | sha: None, | |
| 663 | + | status: None, | |
| 664 | + | since: Some(rfc3339(since)), | |
| 665 | + | until: None, | |
| 666 | + | limit: Some(LANDED_RUNS), | |
| 667 | + | }; | |
| 668 | + | let (log, runs) = futures_util::future::join( | |
| 669 | + | g1t_kit::call::<_, Outcome<Vec<Commit>>>(&services.repos, "log", &log_args), | |
| 670 | + | g1t_kit::call::<_, Outcome<Vec<WorkflowRun>>>(&services.actions, "runs", &runs_args), | |
| 671 | + | ) | |
| 672 | + | .await; | |
| 673 | + | (repo, log, runs) | |
| 471 | 674 | }); | |
| 472 | − | let mut lines = Vec::new(); | |
| 473 | − | for (repo, log) in futures_util::future::join_all(logs).await { | |
| 675 | + | let mut histories = Vec::new(); | |
| 676 | + | for (repo, log, runs) in futures_util::future::join_all(logs).await { | |
| 474 | 677 | // A history that cannot be read leaves the events to say it. | |
| 475 | − | if let Ok(Outcome::Ok(commits)) = log { | |
| 476 | − | lines.extend(landed(&site, &repo, &commits, since, until)); | |
| 678 | + | let Ok(Outcome::Ok(commits)) = log else { continue }; | |
| 679 | + | let full = format!("{}/{}", repo.namespace, repo.name); | |
| 680 | + | let mut deploys = event_deploys(&site, repo, &events); | |
| 681 | + | if let Ok(Outcome::Ok(runs)) = runs { | |
| 682 | + | for deploy in runs.iter().filter_map(|run| run_deploy(&site, &full, &repo.default_branch, run)) { | |
| 683 | + | // A run the events named too is the same deploy; the run says | |
| 684 | + | // where it is now. | |
| 685 | + | deploys.retain(|known| known.url != deploy.url); | |
| 686 | + | deploys.push(deploy); | |
| 687 | + | } | |
| 477 | 688 | } | |
| 689 | + | histories.push((full, commits, deploys)); | |
| 690 | + | } | |
| 691 | + | let all: Vec<&Commit> = histories.iter().flat_map(|(_, commits, _)| commits.iter()).collect(); | |
| 692 | + | let authors = commits::read_authors(services, &all).await; | |
| 693 | + | let mut lines = Vec::new(); | |
| 694 | + | for (repo, commits, deploys) in &histories { | |
| 695 | + | lines.extend(landed(&site, repo, commits, since, until, &authors, deploys)); | |
| 478 | 696 | } | |
| 479 | 697 | lines.truncate(LANDED_MAX); | |
| 698 | + | answer["summary"]["shipped"] = shipped(&lines); | |
| 480 | 699 | answer["landed"] = json!(lines); | |
| 481 | 700 | Ok(Outcome::Ok(answer)) | |
| 482 | 701 | } | |
| ⋯ | |||
| 551 | 770 | ]; | |
| 552 | 771 | let since = parse_rfc3339("2026-10-12T06:00:00Z").unwrap(); | |
| 553 | 772 | let until = parse_rfc3339("2026-10-13T06:00:00Z").unwrap(); | |
| 554 | − | let lines = landed("https://g1t.sh/", "acme/web", &commits, since, until); | |
| 773 | + | let lines = landed("https://g1t.sh/", "acme/web", &commits, since, until, &Authors::new(), &[]); | |
| 555 | 774 | assert_eq!(lines.len(), 1); | |
| 556 | 775 | assert_eq!(lines[0]["subject"], "Ask reads g1t"); | |
| 557 | 776 | assert_eq!(lines[0]["commit"], "aaaaaaaaaaaa"); | |
| 558 | 777 | assert_eq!(lines[0]["author"], "Chase"); | |
| 559 | 778 | assert_eq!(lines[0]["url"], "https://g1t.sh/acme/web/commit/aaaaaaaaaaaaaaaa"); | |
| 779 | + | // Nobody known by the address, and no deploys seen: no username made | |
| 780 | + | // up from the name, and nothing said about production. | |
| 781 | + | assert!(lines[0].get("username").is_none()); | |
| 782 | + | assert!(lines[0].get("production").is_none()); | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | fn by(hash: &str, email: &str, at: &str) -> Commit { | |
| 786 | + | Commit { | |
| 787 | + | hash: hash.to_owned(), | |
| 788 | + | tree_hash: String::new(), | |
| 789 | + | message: format!("Change {hash}"), | |
| 790 | + | author: g1t_contracts::repos::Signature { name: "Chase Pierce".into(), email: email.into() }, | |
| 791 | + | parents: Vec::new(), | |
| 792 | + | authored_at: at.to_owned(), | |
| 793 | + | } | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | fn run(id: &str, name: &str, number: u64, sha: &str, status: &str, conclusion: Option<&str>) -> WorkflowRun { | |
| 797 | + | WorkflowRun { | |
| 798 | + | id: id.into(), | |
| 799 | + | path: ".g1t/workflows/deploy.yml".into(), | |
| 800 | + | name: name.into(), | |
| 801 | + | number, | |
| 802 | + | event: "push".into(), | |
| 803 | + | git_ref: "refs/heads/main".into(), | |
| 804 | + | sha: sha.into(), | |
| 805 | + | status: status.into(), | |
| 806 | + | conclusion: conclusion.map(str::to_owned), | |
| 807 | + | ..WorkflowRun::default() | |
| 808 | + | } | |
| 560 | 809 | } | |
| 561 | 810 | ||
| 562 | 811 | #[test] | |
| 812 | + | fn what_shipped_is_what_reached_the_default_branch_and_which_of_it_is_live() { | |
| 813 | + | // Newest first: e was pushed last and is still deploying, which | |
| 814 | + | // carries d, whose own deploy failed; c went out with Deploy #311, | |
| 815 | + | // which carried b and a too. | |
| 816 | + | let log = [ | |
| 817 | + | by("eeee0000", "syntaqx@gmail.com", "2026-10-14T13:00:00Z"), | |
| 818 | + | by("dddd0000", "syntaqx@gmail.com", "2026-10-14T11:00:00Z"), | |
| 819 | + | by("cccc0000", "syntaqx@gmail.com", "2026-10-14T09:00:00Z"), | |
| 820 | + | by("bbbb0000", "someone@example.com", "2026-10-14T08:00:00Z"), | |
| 821 | + | by("aaaa0000", "syntaqx@gmail.com", "2026-10-13T08:00:00Z"), | |
| 822 | + | ]; | |
| 823 | + | let site = "https://g1t.sh"; | |
| 824 | + | let runs = [ | |
| 825 | + | run("run_313", "Deploy", 313, "eeee0000", "in_progress", None), | |
| 826 | + | run("run_312", "Deploy", 312, "dddd0000", "completed", Some("failure")), | |
| 827 | + | run("run_311", "Deploy", 311, "cccc0000", "completed", Some("success")), | |
| 828 | + | // CI is not a deploy; a cancelled deploy deployed nothing; a | |
| 829 | + | // deploy of another branch is not production. | |
| 830 | + | WorkflowRun { path: ".g1t/workflows/ci.yml".into(), ..run("run_9", "CI", 9, "eeee0000", "completed", Some("success")) }, | |
| 831 | + | run("run_310", "Deploy", 310, "dddd0000", "completed", Some("cancelled")), | |
| 832 | + | WorkflowRun { git_ref: "refs/heads/next".into(), ..run("run_8", "Deploy", 8, "eeee0000", "completed", Some("success")) }, | |
| 833 | + | ]; | |
| 834 | + | let deploys: Vec<Deploy> = runs.iter().filter_map(|found| run_deploy(site, "flagon-io/g1t", "main", found)).collect(); | |
| 835 | + | assert_eq!(deploys.len(), 3); | |
| 836 | + | assert_eq!(deploys[2], Deploy { | |
| 837 | + | sha: "cccc0000".into(), | |
| 838 | + | state: DeployState::Succeeded, | |
| 839 | + | label: "Deploy #311".into(), | |
| 840 | + | url: "https://g1t.sh/flagon-io/g1t/actions/runs/run_311".into(), | |
| 841 | + | }); | |
| 842 | + | let owners = HashMap::from([( | |
| 843 | + | "syntaqx@gmail.com".to_owned(), | |
| 844 | + | g1t_contracts::accounts::EmailOwner { id: "usr_1".into(), username: "syntaqx".into(), avatar: None }, | |
| 845 | + | )]); | |
| 846 | + | let authors = commits::authors(&log.iter().collect::<Vec<_>>(), &owners); | |
| 847 | + | let since = parse_rfc3339("2026-10-14T06:00:00Z").unwrap(); | |
| 848 | + | let until = parse_rfc3339("2026-10-14T20:00:00Z").unwrap(); | |
| 849 | + | let lines = landed(site, "flagon-io/g1t", &log, since, until, &authors, &deploys); | |
| 850 | + | let states: Vec<&str> = lines.iter().map(|line| line["production"].as_str().unwrap()).collect(); | |
| 851 | + | assert_eq!(states, ["deploying", "deploying", "live", "live"], "a is yesterday's"); | |
| 852 | + | assert_eq!(lines[2]["deploy"], json!({ "name": "Deploy #311", "url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_311" })); | |
| 853 | + | assert_eq!(lines[1]["deploy"]["name"], "Deploy #313"); | |
| 854 | + | // The author by their username; someone unknown by their name alone. | |
| 855 | + | assert_eq!(lines[0]["username"], "syntaqx"); | |
| 856 | + | assert_eq!(lines[0]["author"], "Chase Pierce"); | |
| 857 | + | assert!(lines[3].get("username").is_none()); | |
| 858 | + | assert_eq!(shipped(&lines), json!({ "live": 2, "deploying": 2, "failed": 0, "not_deployed": 0 })); | |
| 859 | + | // Before #313 started: d failed to deploy, and e is not deployed. | |
| 860 | + | let lines = landed(site, "flagon-io/g1t", &log, since, until, &authors, &deploys[1..]); | |
| 861 | + | assert_eq!(shipped(&lines), json!({ "live": 2, "deploying": 0, "failed": 1, "not_deployed": 1 })); | |
| 862 | + | assert_eq!(lines[1]["deploy"]["name"], "Deploy #312"); | |
| 863 | + | assert!(lines[0].get("deploy").is_none()); | |
| 864 | + | // Once #313 succeeds, everything is live; before any deploy of today's | |
| 865 | + | // commits, they are not deployed. | |
| 866 | + | let done = [Deploy { state: DeployState::Succeeded, ..deploys[0].clone() }]; | |
| 867 | + | assert!(production(&log, &done).iter().all(|state| state.map(|(state, _)| state) == Some("live"))); | |
| 868 | + | let old = [Deploy { sha: "ffff9999".into(), ..deploys[2].clone() }]; | |
| 869 | + | assert!(production(&log, &old).iter().all(|state| state.map(|(state, _)| state) == Some("not_deployed"))); | |
| 870 | + | assert!(production(&log, &[]).iter().all(Option::is_none)); | |
| 871 | + | } | |
| 872 | + | ||
| 873 | + | #[test] | |
| 874 | + | fn deploy_workflows_are_known_by_name_or_file() { | |
| 875 | + | assert!(deploy_workflow("Deploy", ".g1t/workflows/deploy.yml")); | |
| 876 | + | assert!(deploy_workflow("Ship it", ".g1t/workflows/deploy-production.yml")); | |
| 877 | + | assert!(deploy_workflow("Deploy site", ".g1t/workflows/site.yml")); | |
| 878 | + | assert!(!deploy_workflow("CI", ".g1t/workflows/ci.yml")); | |
| 879 | + | assert!(!deploy_workflow("Deploy preview", ".g1t/workflows/preview.yml")); | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | #[test] | |
| 563 | 883 | fn what_shipped_reads_as_one_list_with_links_and_names() { | |
| 564 | 884 | let repos = [repo("rep_1", "web"), repo("rep_2", "api")]; | |
| 565 | 885 | let events = [ | |
| ⋯ | |||
| 589 | 909 | }); | |
| 590 | 910 | assert_eq!( | |
| 591 | 911 | shown["summary"], | |
| 592 | − | json!({ "merged_pull_requests": 1, "releases": 1, "production_deployments": 1, "failed_deployments": 0, "default_branch_pushes": 1, "failed_runs": 1 }) | |
| 912 | + | json!({ | |
| 913 | + | "merged_pull_requests": 1, "releases": 1, "production_deployments": 1, "failed_deployments": 1, "default_branch_pushes": 1, "failed_runs": 1, | |
| 914 | + | "shipped": { "live": 0, "deploying": 0, "failed": 0, "not_deployed": 0 }, | |
| 915 | + | }) | |
| 593 | 916 | ); | |
| 594 | 917 | let listed = shown["events"].as_array().unwrap(); | |
| 595 | 918 | assert_eq!(listed.len(), 6, "{listed:#?}"); | |
| ⋯ | |||
| 605 | 928 | assert_eq!(listed[3]["actor"], "bo"); | |
| 606 | 929 | assert_eq!( | |
| 607 | 930 | listed[4], | |
| 608 | − | json!({ "kind": "workflow_completed", "time": "2026-10-12T11:30:00Z", "repo": "acme/web", "actor": "ana", "workflow": "Deploy", "run_number": 257, "conclusion": "failure", "branch": "main", "commit": "fedcba987654", "run_id": "run_257", "url": "https://g1t.sh/acme/web/actions/runs/run_257" }) | |
| 931 | + | json!({ "kind": "workflow_completed", "time": "2026-10-12T11:30:00Z", "repo": "acme/web", "actor": "ana", "workflow": "Deploy", "run_number": 257, "conclusion": "failure", "branch": "main", "commit": "fedcba987654", "run_id": "run_257", "url": "https://g1t.sh/acme/web/actions/runs/run_257", "production": true }) | |
| 609 | 932 | ); | |
| 610 | 933 | assert_eq!(listed[5]["resolved_by"], 7); | |
| 611 | 934 | assert_eq!(shown["complete"], true); | |
| 6 | 6 | //! The repos service reads it, as the caller may see it: `log` for a | |
| 7 | 7 | //! branch, `path_log` for a path. The shaping is [`commit`], a plain | |
| 8 | 8 | //! function. | |
| 9 | + | //! | |
| 10 | + | //! Each author is named as git wrote them, and with the g1t account their | |
| 11 | + | //! address belongs to (`author.username`) when it is a confirmed or noreply | |
| 12 | + | //! address of one, as the site shows commits: identity's `email_owners`. | |
| 13 | + | //! The address itself is never returned. | |
| 14 | + | ||
| 15 | + | use std::collections::{BTreeSet, HashMap}; | |
| 9 | 16 | ||
| 17 | + | use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs}; | |
| 10 | 18 | use g1t_contracts::repos::{Commit, LogArgs, PathLog, PathLogArgs, RepoPath}; | |
| 11 | 19 | use g1t_contracts::{Outcome, Viewer}; | |
| 12 | 20 | use serde_json::{Value, json}; | |
| ⋯ | |||
| 21 | 29 | /// How long finding a path's last change may take, in milliseconds. | |
| 22 | 30 | const PATH_BUDGET_MS: u64 = 8_000; | |
| 23 | 31 | ||
| 32 | + | /// g1t's own commit addresses, shown as @g1t: its noreply address and those | |
| 33 | + | /// its agents and merge queue used before (services/repos contributors.rs). | |
| 34 | + | const G1T_EMAILS: [&str; 4] = ["g1t@users.noreply.g1t.sh", "agent@g1t.sh", "queue@g1t.sh", "mergecheck@g1t.sh"]; | |
| 35 | + | /// The most addresses one `email_owners` call takes. | |
| 36 | + | const MAX_ADDRESSES: usize = 200; | |
| 37 | + | ||
| 38 | + | /// Author address, lowercased, to the g1t username it belongs to. | |
| 39 | + | pub type Authors = HashMap<String, String>; | |
| 40 | + | ||
| 41 | + | /// The distinct author addresses of some commits that identity is asked | |
| 42 | + | /// about, lowercased: g1t's own are known already. | |
| 43 | + | pub fn addresses<'a>(commits: impl IntoIterator<Item = &'a Commit>) -> Vec<String> { | |
| 44 | + | commits | |
| 45 | + | .into_iter() | |
| 46 | + | .map(|commit| commit.author.email.trim().to_lowercase()) | |
| 47 | + | .filter(|email| email.contains('@') && !G1T_EMAILS.contains(&email.as_str())) | |
| 48 | + | .collect::<BTreeSet<_>>() | |
| 49 | + | .into_iter() | |
| 50 | + | .take(MAX_ADDRESSES) | |
| 51 | + | .collect() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Who each address belongs to, as usernames: g1t's own addresses are g1t, | |
| 55 | + | /// the rest whatever identity matched (a confirmed or noreply address, | |
| 56 | + | /// never an unconfirmed one). An address nobody owns is left out. | |
| 57 | + | pub fn authors(commits: &[&Commit], owners: &HashMap<String, EmailOwner>) -> Authors { | |
| 58 | + | let mut found = Authors::new(); | |
| 59 | + | for commit in commits { | |
| 60 | + | let email = commit.author.email.trim().to_lowercase(); | |
| 61 | + | if G1T_EMAILS.contains(&email.as_str()) { | |
| 62 | + | found.insert(email, "g1t".to_owned()); | |
| 63 | + | } else if let Some(owner) = owners.get(&email) { | |
| 64 | + | found.insert(email, owner.username.clone()); | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | found | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// The g1t username of a commit's author, when their address is known. | |
| 71 | + | pub fn username<'a>(commit: &Commit, authors: &'a Authors) -> Option<&'a str> { | |
| 72 | + | authors.get(&commit.author.email.trim().to_lowercase()).map(String::as_str) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// Reads who wrote some commits from identity. Without identity, nobody: | |
| 76 | + | /// the commits still say their authors' names. | |
| 77 | + | pub async fn read_authors(services: &Services, commits: &[&Commit]) -> Authors { | |
| 78 | + | let emails = addresses(commits.iter().copied()); | |
| 79 | + | let owners = if emails.is_empty() { | |
| 80 | + | HashMap::new() | |
| 81 | + | } else { | |
| 82 | + | g1t_kit::call::<_, HashMap<String, EmailOwner>>(&services.identity, "email_owners", &EmailOwnersArgs { emails }) | |
| 83 | + | .await | |
| 84 | + | .unwrap_or_default() | |
| 85 | + | }; | |
| 86 | + | authors(commits, &owners) | |
| 87 | + | } | |
| 88 | + | ||
| 24 | 89 | 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), 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." | |
| 90 | + | "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` (`name` as git wrote it, and `username`, the g1t account the author's address belongs to, when one does), `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 | 91 | } | |
| 27 | 92 | ||
| 28 | 93 | pub fn input() -> Value { | |
| ⋯ | |||
| 39 | 104 | } | |
| 40 | 105 | ||
| 41 | 106 | /// One commit as an answer shows it. | |
| 42 | − | pub fn commit(site: &str, repo: &RepoPath, commit: &Commit) -> Value { | |
| 107 | + | pub fn commit(site: &str, repo: &RepoPath, commit: &Commit, authors: &Authors) -> Value { | |
| 43 | 108 | let site = site.trim_end_matches('/'); | |
| 44 | 109 | let total = commit.message.chars().count(); | |
| 45 | 110 | let message = if total > MAX_MESSAGE_CHARS { | |
| ⋯ | |||
| 50 | 115 | json!({ | |
| 51 | 116 | "sha": commit.hash, | |
| 52 | 117 | "message": message, | |
| 53 | − | "author": { "name": commit.author.name }, | |
| 118 | + | "author": author(commit, authors), | |
| 54 | 119 | "authored_at": commit.authored_at, | |
| 55 | 120 | "parents": commit.parents, | |
| 56 | 121 | "html_url": format!("{site}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash), | |
| 57 | 122 | }) | |
| 58 | 123 | } | |
| 59 | 124 | ||
| 125 | + | /// A commit's author as an answer shows them: the name git wrote, and their | |
| 126 | + | /// username when it is known. Never the address. | |
| 127 | + | fn author(commit: &Commit, authors: &Authors) -> Value { | |
| 128 | + | match username(commit, authors) { | |
| 129 | + | Some(username) => json!({ "name": commit.author.name, "username": username }), | |
| 130 | + | None => json!({ "name": commit.author.name }), | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 60 | 134 | fn limit(input: &Value) -> u32 { | |
| 61 | 135 | let asked = match input.get("limit") { | |
| 62 | 136 | Some(Value::String(text)) => text.trim().parse::<u64>().ok(), | |
| ⋯ | |||
| 78 | 152 | &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, limit: limit(input) }, | |
| 79 | 153 | ) | |
| 80 | 154 | .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 | − | }); | |
| 155 | + | let commits = match log { | |
| 156 | + | Outcome::Ok(commits) => commits, | |
| 157 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 158 | + | }; | |
| 159 | + | let authors = read_authors(services, &commits.iter().collect::<Vec<_>>()).await; | |
| 160 | + | return Ok(Outcome::Ok(json!({ | |
| 161 | + | "repo": full, | |
| 162 | + | "commits": commits.iter().map(|found| commit(&site, repo, found, &authors)).collect::<Vec<_>>(), | |
| 163 | + | }))); | |
| 88 | 164 | } | |
| 89 | 165 | // A path's own history: the commits that changed it, newest first. | |
| 90 | 166 | let history: Outcome<PathLog> = g1t_kit::call( | |
| ⋯ | |||
| 104 | 180 | Outcome::Ok(history) => history, | |
| 105 | 181 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 106 | 182 | }; | |
| 183 | + | let authors = read_authors(services, &history.commits.iter().collect::<Vec<_>>()).await; | |
| 107 | 184 | let last_changed = match history.commits.first() { | |
| 108 | − | Some(first) => commit(&site, repo, first), | |
| 185 | + | Some(first) => commit(&site, repo, first, &authors), | |
| 109 | 186 | None if history.complete => json!(format!("{path} is not in this repository at this ref.")), | |
| 110 | 187 | None => json!("Not found in time: its history is long. Ask with a smaller limit, or read the branch's commits."), | |
| 111 | 188 | }; | |
| 112 | 189 | Ok(Outcome::Ok(json!({ | |
| 113 | 190 | "repo": full, | |
| 114 | 191 | "path": path, | |
| 115 | − | "commits": history.commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(), | |
| 192 | + | "commits": history.commits.iter().map(|found| commit(&site, repo, found, &authors)).collect::<Vec<_>>(), | |
| 116 | 193 | "last_changed": last_changed, | |
| 117 | 194 | "complete": history.complete, | |
| 118 | 195 | }))) | |
| ⋯ | |||
| 134 | 211 | authored_at: "2026-10-13T01:00:00Z".into(), | |
| 135 | 212 | }; | |
| 136 | 213 | let repo = RepoPath { namespace: "acme".into(), name: "web".into() }; | |
| 137 | − | let shown = commit("https://g1t.sh/", &repo, &found); | |
| 214 | + | let shown = commit("https://g1t.sh/", &repo, &found, &Authors::new()); | |
| 138 | 215 | assert_eq!(shown["sha"], "abc123"); | |
| 139 | 216 | assert_eq!(shown["message"], "The runner retries installs that break off."); | |
| 140 | 217 | assert_eq!(shown["author"], json!({ "name": "Chase Pierce" })); | |
| ⋯ | |||
| 143 | 220 | assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT); | |
| 144 | 221 | assert_eq!(limit(&json!({})), DEFAULT_LIMIT); | |
| 145 | 222 | } | |
| 223 | + | ||
| 224 | + | fn by(name: &str, email: &str) -> Commit { | |
| 225 | + | Commit { | |
| 226 | + | hash: format!("{}1", name.len()), | |
| 227 | + | tree_hash: "t".into(), | |
| 228 | + | message: "m".into(), | |
| 229 | + | author: Signature { name: name.into(), email: email.into() }, | |
| 230 | + | parents: Vec::new(), | |
| 231 | + | authored_at: "2026-10-14T01:00:00Z".into(), | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[test] | |
| 236 | + | fn an_author_is_named_by_the_account_their_address_belongs_to() { | |
| 237 | + | let known = by("Chase Pierce", " Syntaqx@Gmail.com "); | |
| 238 | + | let stranger = by("Someone Else", "someone@example.com"); | |
| 239 | + | let ours = by("g1t", "g1t@users.noreply.g1t.sh"); | |
| 240 | + | let unsigned = by("Nobody", ""); | |
| 241 | + | let all = [&known, &stranger, &ours, &unsigned]; | |
| 242 | + | // Asked about: each real address once, lowercased; never g1t's own. | |
| 243 | + | assert_eq!(addresses(all), vec!["someone@example.com".to_owned(), "syntaqx@gmail.com".to_owned()]); | |
| 244 | + | let owners = HashMap::from([( | |
| 245 | + | "syntaqx@gmail.com".to_owned(), | |
| 246 | + | EmailOwner { id: "usr_1".into(), username: "syntaqx".into(), avatar: None }, | |
| 247 | + | )]); | |
| 248 | + | let found = authors(&all, &owners); | |
| 249 | + | let repo = RepoPath { namespace: "flagon-io".into(), name: "g1t".into() }; | |
| 250 | + | // A known address: the git name and the username, never the address. | |
| 251 | + | let shown = commit("https://g1t.sh", &repo, &known, &found); | |
| 252 | + | assert_eq!(shown["author"], json!({ "name": "Chase Pierce", "username": "syntaqx" })); | |
| 253 | + | assert!(!shown.to_string().to_lowercase().contains("gmail"), "no address"); | |
| 254 | + | // Nobody on g1t: the name alone, and no username made up from it. | |
| 255 | + | assert_eq!(commit("https://g1t.sh", &repo, &stranger, &found)["author"], json!({ "name": "Someone Else" })); | |
| 256 | + | assert_eq!(commit("https://g1t.sh", &repo, &unsigned, &found)["author"], json!({ "name": "Nobody" })); | |
| 257 | + | // g1t's own commits are @g1t's. | |
| 258 | + | assert_eq!(commit("https://g1t.sh", &repo, &ours, &found)["author"]["username"], "g1t"); | |
| 259 | + | } | |
| 146 | 260 | } | |
| 148 | 148 | "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp), | |
| 149 | 149 | "claude_code_plugin": { | |
| 150 | 150 | "install": [ | |
| 151 | − | "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git", | |
| 151 | + | "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git", | |
| 152 | 152 | "claude plugin install g1t@g1t", | |
| 153 | 153 | ], | |
| 154 | 154 | "note": "The plugin adds this server (listed in /mcp as plugin:g1t:g1t) and records your sessions on g1t. With it installed, skip `connect`, or each tool is listed twice.", |
| 1647 | 1647 | "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." | |
| 1648 | 1648 | } | |
| 1649 | 1649 | Op::Why => { | |
| 1650 | − | "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." | |
| 1650 | + | "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; its author named as git wrote them, with `username`, the g1t account their address belongs to, when one does, and never the address), 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." | |
| 1651 | 1651 | } | |
| 1652 | 1652 | Op::MarkPullRequestReady => { | |
| 1653 | 1653 | "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why." | |
| ⋯ | |||
| 1737 | 1737 | "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." | |
| 1738 | 1738 | } | |
| 1739 | 1739 | Op::ListWorkflowRuns => { | |
| 1740 | − | "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." | |
| 1740 | + | "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, a status or conclusion (`status`: failure for the failed ones, in_progress for those still going), or created between `since` and `until` (RFC 3339; today's deploys are `workflow` deploy.yml, the default branch and `since` the start of the day). A run whose `status` is not completed has no result yet." | |
| 1741 | 1741 | } | |
| 1742 | 1742 | Op::GetWorkflowRun => { | |
| 1743 | 1743 | "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." | |
| ⋯ | |||
| 2977 | 2977 | "pull": { "type": "integer", "description": "A pull request's number." }, | |
| 2978 | 2978 | "sha": { "type": "string", "description": "A commit." }, | |
| 2979 | 2979 | "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." }, | |
| 2980 | + | "since": { "type": "string", "description": "RFC 3339: only runs created at or after it." }, | |
| 2981 | + | "until": { "type": "string", "description": "RFC 3339: only runs created before it." }, | |
| 2980 | 2982 | "limit": { "type": "integer", "description": "At most 100; 50 if not given." }, | |
| 2981 | 2983 | }), | |
| 2982 | 2984 | &["repo"], | |
| ⋯ | |||
| 5321 | 5323 | "pull": integer(input, "pull"), | |
| 5322 | 5324 | "sha": optional_text(input, "sha"), | |
| 5323 | 5325 | "status": optional_text(input, "status"), | |
| 5326 | + | "since": optional_text(input, "since"), | |
| 5327 | + | "until": optional_text(input, "until"), | |
| 5324 | 5328 | "limit": integer(input, "limit"), | |
| 5325 | 5329 | }), | |
| 5326 | 5330 | ) | |
| 3662 | 3662 | "message": "Read retry_limit, as #12 names it", | |
| 3663 | 3663 | "author": { | |
| 3664 | 3664 | "name": "Ana Lima", | |
| 3665 | − | "email": "ana@example.com" | |
| 3665 | + | "username": "ana" | |
| 3666 | 3666 | }, | |
| 3667 | 3667 | "parents": [ | |
| 3668 | 3668 | "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b" | |
| ⋯ | |||
| 7580 | 7580 | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 7581 | 7581 | }, | |
| 7582 | 7582 | "list_commits": { | |
| 7583 | − | "notes": "With `path`, `last_changed` is the commit that last changed that file or directory.", | |
| 7583 | + | "notes": "With `path`, `last_changed` is the commit that last changed that file or directory. `author.username` is the g1t account the author's address belongs to, when one does; the address is never returned.", | |
| 7584 | 7584 | "params": { | |
| 7585 | 7585 | "owner": "flagon-io", | |
| 7586 | 7586 | "name": "g1t" | |
| ⋯ | |||
| 7595 | 7595 | { | |
| 7596 | 7596 | "sha": "85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5", | |
| 7597 | 7597 | "message": "The runner's broker test reads the relayed answer's body as chunks.", | |
| 7598 | − | "author": { "name": "Chase Pierce" }, | |
| 7598 | + | "author": { "name": "Chase Pierce", "username": "syntaqx" }, | |
| 7599 | 7599 | "authored_at": "2026-10-12T22:04:11Z", | |
| 7600 | 7600 | "parents": ["75c6704f2b1a3c4d5e6f708192a3b4c5d6e7f809"], | |
| 7601 | 7601 | "html_url": "https://g1t.sh/flagon-io/g1t/commit/85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5" | |
| ⋯ | |||
| 7605 | 7605 | "last_changed": { | |
| 7606 | 7606 | "sha": "5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e", | |
| 7607 | 7607 | "message": "Installs in cloud jobs retry when npm's download breaks off.", | |
| 7608 | − | "author": { "name": "Chase Pierce" }, | |
| 7608 | + | "author": { "name": "Chase Pierce", "username": "syntaqx" }, | |
| 7609 | 7609 | "authored_at": "2026-10-13T01:12:40Z", | |
| 7610 | 7610 | "parents": ["0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3"], | |
| 7611 | 7611 | "html_url": "https://g1t.sh/flagon-io/g1t/commit/5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e" | |
| ⋯ | |||
| 7633 | 7633 | } | |
| 7634 | 7634 | }, | |
| 7635 | 7635 | "get_workspace_activity": { | |
| 7636 | − | "notes": "What shipped today across a workspace: `since` at midnight UTC and `kinds=merged,releases,deployments,pushes`. Each event links to its page on g1t (`url`). Ask g1t reads this to answer questions about a workspace.", | |
| 7636 | + | "notes": "What shipped today across a workspace: `since` at the start of the day and `kinds=merged,releases,deployments,pushes,runs`. Each event links to its page on g1t (`url`). `landed` is what reached default branches, each commit with `production` where its repository deploys. Ask g1t reads this to answer questions about a workspace.", | |
| 7637 | 7637 | "params": { | |
| 7638 | 7638 | "workspace": "flagon-io" | |
| 7639 | 7639 | }, | |
| ⋯ | |||
| 7652 | 7652 | "production_deployments": 1, | |
| 7653 | 7653 | "failed_deployments": 0, | |
| 7654 | 7654 | "default_branch_pushes": 0, | |
| 7655 | − | "failed_runs": 0 | |
| 7655 | + | "failed_runs": 0, | |
| 7656 | + | "shipped": { "live": 1, "deploying": 0, "failed": 0, "not_deployed": 0 } | |
| 7656 | 7657 | }, | |
| 7658 | + | "landed": [ | |
| 7659 | + | { | |
| 7660 | + | "repo": "flagon-io/g1t", | |
| 7661 | + | "commit": "75c6704f2b1a", | |
| 7662 | + | "subject": "Ask g1t answers with the workspace's own AI provider", | |
| 7663 | + | "author": "Chase Pierce", | |
| 7664 | + | "time": "2026-10-12T17:58:40Z", | |
| 7665 | + | "url": "https://g1t.sh/flagon-io/g1t/commit/75c6704f2b1a3c4d5e6f708192a3b4c5d6e7f809", | |
| 7666 | + | "username": "syntaqx", | |
| 7667 | + | "production": "live", | |
| 7668 | + | "deploy": { "name": "g1t's production deployment", "url": "https://g1t.sh/flagon-io/g1t/deployments/dpl_01kkp3m8w2f6t9qh4c7d1r5n0x" } | |
| 7669 | + | } | |
| 7670 | + | ], | |
| 7657 | 7671 | "events": [ | |
| 7658 | 7672 | { | |
| 7659 | 7673 | "kind": "deployment_succeeded", | |
| 775 | 775 | "GET", | |
| 776 | 776 | "/repos/:owner/:name/actions/workflows/:workflow/runs", | |
| 777 | 777 | Op::ListWorkflowRuns, | |
| 778 | − | &[("branch", "branch"), ("event", "event"), ("status", "status"), ("head_sha", "sha"), ("per_page", "limit")], | |
| 778 | + | &[("branch", "branch"), ("event", "event"), ("status", "status"), ("head_sha", "sha"), ("since", "since"), ("until", "until"), ("per_page", "limit")], | |
| 779 | 779 | ), | |
| 780 | 780 | route( | |
| 781 | 781 | "POST", | |
| ⋯ | |||
| 805 | 805 | "GET", | |
| 806 | 806 | "/repos/:owner/:name/actions/runs", | |
| 807 | 807 | Op::ListWorkflowRuns, | |
| 808 | − | &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("status", "status"), ("per_page", "limit")], | |
| 808 | + | &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("status", "status"), ("since", "since"), ("until", "until"), ("per_page", "limit")], | |
| 809 | 809 | ), | |
| 810 | 810 | route( | |
| 811 | 811 | "GET", | |
| 7 | 7 | //! request's issue, its decision records, and the session entries recorded | |
| 8 | 8 | //! around the commit. | |
| 9 | 9 | ||
| 10 | − | use g1t_contracts::decisions::{Decision, ListDecisionsArgs, Why, WhyIssue, WhyPull}; | |
| 10 | + | use g1t_contracts::decisions::{Decision, ListDecisionsArgs, Why, WhyCommit, WhyIssue, WhyPull}; | |
| 11 | 11 | use g1t_contracts::repos::{Blame, BlameArgs, Commit, LogArgs, RepoPath}; | |
| 12 | 12 | use g1t_contracts::work::{IssueDetail, ListPullsArgs, Pull, PullStatus, SessionEntry, SessionEntryKind, State, ViewArgs}; | |
| 13 | 13 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| ⋯ | |||
| 143 | 143 | Outcome::Ok(blame) => blame, | |
| 144 | 144 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 145 | 145 | }; | |
| 146 | − | let Some(commit) = commit_of_line(&blame, line).cloned() else { | |
| 146 | + | let Some(found) = commit_of_line(&blame, line).cloned() else { | |
| 147 | 147 | let lines = blame.ranges.last().map_or(0, |range| range.end); | |
| 148 | 148 | return Ok(Outcome::fail( | |
| 149 | 149 | FailureCode::Invalid, | |
| ⋯ | |||
| 168 | 168 | Outcome::Ok(pulls) => pulls, | |
| 169 | 169 | Outcome::Fail(_) => Vec::new(), | |
| 170 | 170 | }; | |
| 171 | + | // Who wrote it, by account: the address itself is never returned. | |
| 172 | + | let authors = crate::commits::read_authors(services, &[&found]).await; | |
| 173 | + | let username = crate::commits::username(&found, &authors).map(str::to_owned); | |
| 174 | + | let commit = WhyCommit::of(found, username); | |
| 171 | 175 | let mut pull = pull_for_commit(&merged, &[], &commit.hash).cloned(); | |
| 172 | 176 | if pull.is_none() && !merged.is_empty() { | |
| 173 | 177 | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 21 | 21 | | `apps/status` | TypeScript | edge | `status.g1t.sh`: probes every minute and keeps incidents | D1 `g1t-status` | `billing`, `repos` | | |
| 22 | 22 | | `apps/sudo` | TypeScript | front | g1t.sh's staff console, behind Cloudflare Access | Nothing | `actions`, `billing`, `events`, `identity`, `projects`, `status`, `telemetry` | | |
| 23 | 23 | | `apps/docs` | Astro | front | `docs.g1t.sh`, these pages | Nothing | none | | |
| 24 | − | | `services/identity` | Rust | core | Accounts, sessions, workspaces, members, teams, repository roles, tokens, OAuth, device sign-in, 2FA | D1 `g1t`, the avatars KV | `billing`, `events`, `projects`, `repos` | | |
| 24 | + | | `services/identity` | Rust | core | Accounts, sessions, workspaces, members, teams, repository roles, tokens, OAuth, device sign-in, 2FA | D1 `g1t`, the avatars KV | `artifacts`, `billing`, `chat`, `events`, `notify`, `projects`, `repos` | | |
| 25 | 25 | | `services/repos` | Rust | core | Repositories and git: smart HTTP, pre-receive checks, caches, forks, landing merges, releases, backups | D1 `g1t-repos`, the git store, the git cache KV, the packs and backups buckets | `billing`, `events`, `identity`, `security`, `work` | | |
| 26 | 26 | | `services/work` | Rust | core | Issues, pull requests, comments, reviews, labels, milestones, rulesets, checks, statuses, CODEOWNERS, the merge queue | D1 `g1t-work` | `actions`, `events`, `identity`, `repos` | | |
| 27 | 27 | | `services/events` | Rust | core | The event bus and log, the audit log, the inbox | D1 `g1t-events`, the `g1t-events` queue and its subscriber queues | `billing`, `identity`, `notify`, `repos`, `work` | |
| 1811 | 1811 | even with `contents: write`. A push that does is declined, naming the file, | |
| 1812 | 1812 | so a workflow cannot rewrite the workflows that run with its repository's | |
| 1813 | 1813 | secrets. To change workflows from a job, push with a | |
| 1814 | − | [personal access token](/guides/authentication/#workflow-files) that has | |
| 1815 | − | Workflow files: write, kept as a secret. | |
| 1814 | + | [workspace access token](/guides/workspaces/#workspace-access-tokens) that | |
| 1815 | + | has [Workflow files: write](/guides/authentication/#workflow-files), kept | |
| 1816 | + | as a secret. | |
| 1816 | 1817 | ||
| 1817 | 1818 | The job's token is the repository's workspace acting with the Write role | |
| 1818 | 1819 | at most, never Admin: it cannot manage webhooks, secrets, deploy keys or who | |
| ⋯ | |||
| 2025 | 2026 | | --- | --- | | |
| 2026 | 2027 | | `list` | `GET /repos/{owner}/{repo}/actions/workflows` | | |
| 2027 | 2028 | | `get` | `GET /repos/{owner}/{repo}/actions/workflows/{workflow}`, with `ref`: the workflow, with `source`, its file on that branch or tag | | |
| 2028 | − | | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` | | |
| 2029 | + | | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha`, `status`, and `since` and `until` (RFC 3339) for when they were created | | |
| 2029 | 2030 | | `get_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` | | |
| 2030 | 2031 | | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=`, or `?format=text` for the whole log as plain text | | |
| 2031 | 2032 | | `get_run` with `attempt` | `GET /repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}` | | |
| 10 | 10 | ||
| 11 | 11 | Ask answers with **your workspace's own AI provider**, on your workspace's | |
| 12 | 12 | own key: one of [your AI providers](/guides/integrations/#your-ai-providers), | |
| 13 | − | and the model an owner chose for it. g1t hosts no model for Ask and bills | |
| 13 | + | and **Auto** or the model an owner chose for it. g1t hosts no model for Ask and bills | |
| 14 | 14 | nothing for it; what an answer costs is on your provider's bill. Your key | |
| 15 | 15 | stays sealed in g1t: it is used to call the provider and is never sent to | |
| 16 | 16 | your browser. | |
| ⋯ | |||
| 24 | 24 | 2. Connect a provider if there is none yet: Anthropic, OpenAI, Gemini or | |
| 25 | 25 | any of the others, or your own Anthropic- or OpenAI-compatible | |
| 26 | 26 | endpoint. | |
| 27 | − | 3. Under **Ask g1t**, choose the provider and one of the models it offers, | |
| 28 | − | then **Use for Ask**. For Azure OpenAI, give the deployment's name; for | |
| 29 | − | an endpoint that does not list its models, the model's name. | |
| 27 | + | 3. Under **Ask g1t**, choose the provider and its model, then **Use for | |
| 28 | + | Ask**. The model starts at **Auto** (see [Auto](#auto)); choose one of | |
| 29 | + | the models the provider offers to use that one for every answer. For | |
| 30 | + | Azure OpenAI, give the deployment's name; for an endpoint that does not | |
| 31 | + | list its models, the model's name. | |
| 30 | 32 | ||
| 31 | 33 | Every member can then ask. Until a provider is chosen, Ask says so: owners | |
| 32 | 34 | get a link to set it up, and members are told to ask an owner. A message | |
| ⋯ | |||
| 38 | 40 | Anthropic-compatible endpoints through Anthropic's Messages API, and the | |
| 39 | 41 | rest through OpenAI's Chat Completions API. | |
| 40 | 42 | ||
| 43 | + | ### Auto | |
| 44 | + | ||
| 45 | + | **Auto**, the default, picks one of the provider's models for each | |
| 46 | + | question, by a fixed rule and with no extra call to the provider: | |
| 47 | + | ||
| 48 | + | | The question | Auto answers with | | |
| 49 | + | | --- | --- | | |
| 50 | + | | A lookup: who has an issue, a link, how many pull requests are open | The provider's newest fast model: Haiku, a `mini` or `flash` model, an `instant` one | | |
| 51 | + | | One that needs several reads or reasoning: why or how something happened, what shipped, a day or a week summed up, a failure to diagnose, work to plan; a long question, several at once, or a long conversation | Its newest standard model: Sonnet, GPT, Gemini Pro, a `versatile` or `large` one | | |
| 52 | + | ||
| 53 | + | Auto tells models apart by the words in their names, from the list the | |
| 54 | + | provider gave when it was last tested, so it works the same for every | |
| 55 | + | provider. The largest and most expensive models, such as Opus or OpenAI's | |
| 56 | + | `pro` models, are used only when the provider offers nothing else: every | |
| 57 | + | answer is on your workspace's key. When a tier is missing, Auto uses the | |
| 58 | + | nearest one. A provider that lists no models (an endpoint, an Azure | |
| 59 | + | deployment) answers with its own model. If Auto has nothing to choose | |
| 60 | + | from, Ask says so: test the provider to list its models again, or choose | |
| 61 | + | one. | |
| 62 | + | ||
| 63 | + | Each answer names the model that wrote it, such as **Anthropic · Auto · | |
| 64 | + | claude-sonnet-5-5**. Workspaces that chose Ask's model before Auto existed | |
| 65 | + | and kept the one the picker started at moved to Auto; a model an owner | |
| 66 | + | picked stays picked. | |
| 67 | + | ||
| 41 | 68 | ## What it can read | |
| 42 | 69 | ||
| 43 | 70 | Ask reads g1t to answer: whatever you can read, and nothing more. It uses | |
| ⋯ | |||
| 78 | 105 | (`activity` on the MCP server's `workspace` tool, or | |
| 79 | 106 | [`GET /workspaces/{workspace}/activity`](/reference/api/workspaces/get-workspace-activity/)). | |
| 80 | 107 | ||
| 108 | + | ### What Ask means by shipped | |
| 109 | + | ||
| 110 | + | A change ships when it reaches a repository's default branch, pushed or | |
| 111 | + | merged from a pull request, and then production. Production is a | |
| 112 | + | successful deployment: | |
| 113 | + | ||
| 114 | + | - a project's production deployment on g1t.page, or | |
| 115 | + | - for a repository that deploys itself, a successful run of its deploy | |
| 116 | + | workflow on the default branch: a workflow whose name or file says | |
| 117 | + | deploy, such as `.g1t/workflows/deploy.yml`, and not one for previews. | |
| 118 | + | ||
| 119 | + | Releases and tags are one more signal, not what shipping means. So "What | |
| 120 | + | shipped today?" is answered with the commits that reached default branches | |
| 121 | + | since the start of your day (in your profile's time zone), grouped by what | |
| 122 | + | changed, and for each: **live** (with the deploy that carried it), **still | |
| 123 | + | deploying**, **failed to deploy** (with the run, linked), or **not | |
| 124 | + | deployed**. A deploy carries the commit it ran for and every commit before | |
| 125 | + | it on the branch. | |
| 126 | + | ||
| 127 | + | People are named by their g1t username. A commit's author is matched to | |
| 128 | + | the account their address belongs to (a confirmed address or the account's | |
| 129 | + | noreply address, as commits on g1t.sh show them); a commit by an address | |
| 130 | + | no account has is named as git recorded it, without an @. | |
| 131 | + | ||
| 81 | 132 | Answers link to what they name on g1t, and every number, title and link | |
| 82 | 133 | comes from what Ask read; when something does not exist, it says so. | |
| 83 | 134 | Answers come from a model and can still be wrong: follow the links to | |
| ⋯ | |||
| 138 | 189 | - **Answer again**, under g1t's latest answer, has it write that answer | |
| 139 | 190 | anew. | |
| 140 | 191 | - **Copy** copies an answer as Markdown. Each answer shows the provider | |
| 141 | − | and model that wrote it. | |
| 192 | + | and model that wrote it, with **Auto** when Auto chose the model. | |
| 142 | 193 | ||
| 143 | 194 | Answers are Markdown: code is highlighted with its language and a Copy | |
| 144 | 195 | button, tables scroll within the answer, and issues, pull requests and | |
| 63 | 63 | operation's name too, such as `delete_repo`. See | |
| 64 | 64 | [managing a repository](/guides/managing-repositories/). | |
| 65 | 65 | ||
| 66 | − | Refusals are recorded too, with the rule that refused them. Entries are | |
| 67 | − | only ever added: nothing edits or removes one. | |
| 66 | + | Refusals are recorded too, with the rule that refused them. A push | |
| 67 | + | refused before it sends anything, such as one with a token that does not | |
| 68 | + | reach the repository or may not push, is recorded as a denied `git.push`. | |
| 69 | + | Entries are only ever added: nothing edits or removes one. | |
| 68 | 70 | ||
| 69 | 71 | ## What an entry says | |
| 70 | 72 | ||
| 71 | 73 | | Field | What it is | | |
| 72 | 74 | | --- | --- | | |
| 73 | 75 | | Time | When it happened, to the millisecond. | | |
| 74 | − | | Actor | Who did it: a person or a workspace token. | | |
| 76 | + | | Actor | Who did it: a person, or the workspace for its own tokens and deploy keys. | | |
| 75 | 77 | | Run | The workflow run whose job's token did it, as `workflow_job`. | | |
| 76 | 78 | | Credential | The id of the token used: on the API, the MCP server or git, or on g1t.sh by a token [used on the website](/guides/authentication/#use-a-token-on-the-website). Empty for a person signed in on g1t.sh. | | |
| 77 | 79 | | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. | | |
| 78 | 80 | | Target | The repository, the issue or pull request number, and for git the refs it moved. | | |
| 79 | 81 | | Outcome | `allowed` or `denied`. | | |
| 80 | − | | Rule | What decided it: a refusal rule, such as `scope:repository`, or `token:repository` for a workflow job's token used on another repository; or, for people, their own access. A refusal by the repository's own rules is `service` (or `repository` for git). | | |
| 82 | + | | Rule | What decided it: a refusal rule, such as `scope:repository`, or `token:repository` for a workflow job's token used on another repository; or, for people, their own access. A push allowed through is `person`, `workspace-token`, `deploy-key` or `agent-token`, after what pushed. A refusal by the repository's own rules is `service` (or `repository` for git). | | |
| 81 | 83 | | Result | `ok`, or the reason it failed. | | |
| 82 | 84 | | Request id | The request's id, the same one Cloudflare logs it under. | | |
| 83 | 85 |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.