Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Merge branch 'main' into forking

# Conflicts: # apps/docs/src/content/docs/reference/mcp.md

syntaqxcommitted Parentsc82bd53bd8913dBrowse files
109 files+651−5993 files not counted0/109 viewed
+6−0
128128 run: node scripts/deploy.mjs manifest --check
129129 - name: The Claude Code plugin's tests
130130 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
131137
132138 build:
133139 name: Build
+72−0
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
+1−0
1515 | Something a person does on g1t.sh | The guide for it in `apps/docs/src/content/docs/guides/` |
1616 | 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. |
1717 | 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. |
1819 | Settings, limits or prices | The page that names them, and the table it is in |
1920 | A new feature | A section in the guide that owns it, linked from the docs home if it is a new task |
2021 | 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. |
+1−1
13201320
13211321 [[package]]
13221322 name = "g1t"
1323−version = "0.3.2"
1323+version = "0.3.3"
13241324 dependencies = [
13251325 "anstyle-query",
13261326 "base64 0.22.1",
+2−2
3030
3131 # 3. Install g1t's Claude Code plugin. It adds g1t's MCP server and records
3232 # 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
3434 claude plugin install g1t@g1t
3535 ```
3636
143143 | `crates/sshd` | Git over SSH, bridged to Cloudflare Artifacts. Not deployed. | Rust |
144144 | `packages/contracts` | The same interfaces for TypeScript callers. | TypeScript |
145145 | `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 |
147147 | `plugins/other-harnesses` | The AGENTS.md snippet for Codex, Cursor and other harnesses. | Markdown |
148148 | `deploy` | `stack.jsonc`, every deployable part and its resources; `self-host`, the Docker Compose version. | JSON, Docker Compose |
149149
+340−17
1111 //! not carry, come from the work service's newest pull requests of the
1212 //! repositories that had any; people's names from identity. Everything
1313 //! 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.
1422
1523 use std::collections::{BTreeSet, HashMap};
1624
25+use g1t_contracts::actions::{RunsArgs, WorkflowRun};
1726 use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS};
1827 use g1t_contracts::identity::UsernamesArgs;
1928 use g1t_contracts::repos::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath};
2332 use serde_json::{Value, json};
2433 use worker::Result;
2534
35+use crate::commits::{self, Authors};
2636 use crate::operations::Services;
2737
2838 /// The span when none is given: the last day.
5262 const LANDED_MAX: usize = 40;
5363 /// How far back each of those repositories' history is read.
5464 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;
5568
5669 /// What is read when no kinds are given: everything but comments, which
5770 /// would crowd out the rest on a busy day.
5871 const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"];
5972
6073 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."
6275 }
6376
6477 pub fn input() -> Value {
296309 if conclusion == "failure" {
297310 runs_failed += 1;
298311 }
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+ }
299326 }
300327 "package.published" => {
301328 for key in ["ecosystem", "name", "version"] {
320347 "failed_deployments": failed,
321348 "default_branch_pushes": landed,
322349 "failed_runs": runs_failed,
350+ // The landed commits by where they are in production (`run`
351+ // fills it in).
352+ "shipped": shipped(&[]),
323353 },
324354 // What landed on default branches in the span, one line a commit
325355 // (`run` fills it in): what shipped where nothing was merged.
329359 })
330360 }
331361
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+
332492 /// One repository's default-branch commits made in the span, newest first,
333493 /// 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> {
336497 let site = site.trim_end_matches('/');
498+ let states = production(commits, deploys);
337499 commits
338500 .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)| {
341504 let subject: String = commit.message.lines().next().unwrap_or_default().trim().chars().take(160).collect();
342− json!({
505+ let mut line = json!({
343506 "repo": repo,
344507 "commit": commit.hash.chars().take(12).collect::<String>(),
345508 "subject": subject,
346509 "author": commit.author.name,
347510 "time": commit.authored_at,
348511 "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
350523 })
351524 .collect()
352525 }
353526
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+
354538 pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
355539 let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase();
356540 if workspace.is_empty() {
463647 }
464648 pushed.sort_by(|a, b| b.1.cmp(&a.1));
465649 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.
466652 let logs = pushed.iter().take(LANDED_REPOS).filter_map(|(id, _)| by_id.get(id.as_str()).copied()).map(|repo| async move {
467653 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)
471674 });
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 {
474677 // 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+ }
477688 }
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));
478696 }
479697 lines.truncate(LANDED_MAX);
698+ answer["summary"]["shipped"] = shipped(&lines);
480699 answer["landed"] = json!(lines);
481700 Ok(Outcome::Ok(answer))
482701 }
551770 ];
552771 let since = parse_rfc3339("2026-10-12T06:00:00Z").unwrap();
553772 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(), &[]);
555774 assert_eq!(lines.len(), 1);
556775 assert_eq!(lines[0]["subject"], "Ask reads g1t");
557776 assert_eq!(lines[0]["commit"], "aaaaaaaaaaaa");
558777 assert_eq!(lines[0]["author"], "Chase");
559778 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+ }
560809 }
561810
562811 #[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]
563883 fn what_shipped_reads_as_one_list_with_links_and_names() {
564884 let repos = [repo("rep_1", "web"), repo("rep_2", "api")];
565885 let events = [
589909 });
590910 assert_eq!(
591911 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+ })
593916 );
594917 let listed = shown["events"].as_array().unwrap();
595918 assert_eq!(listed.len(), 6, "{listed:#?}");
605928 assert_eq!(listed[3]["actor"], "bo");
606929 assert_eq!(
607930 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 })
609932 );
610933 assert_eq!(listed[5]["resolved_by"], 7);
611934 assert_eq!(shown["complete"], true);
+127−13
66 //! The repos service reads it, as the caller may see it: `log` for a
77 //! branch, `path_log` for a path. The shaping is [`commit`], a plain
88 //! 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};
916
17+use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
1018 use g1t_contracts::repos::{Commit, LogArgs, PathLog, PathLogArgs, RepoPath};
1119 use g1t_contracts::{Outcome, Viewer};
1220 use serde_json::{Value, json};
2129 /// How long finding a path's last change may take, in milliseconds.
2230 const PATH_BUDGET_MS: u64 = 8_000;
2331
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+
2489 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."
2691 }
2792
2893 pub fn input() -> Value {
39104 }
40105
41106 /// 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 {
43108 let site = site.trim_end_matches('/');
44109 let total = commit.message.chars().count();
45110 let message = if total > MAX_MESSAGE_CHARS {
50115 json!({
51116 "sha": commit.hash,
52117 "message": message,
53− "author": { "name": commit.author.name },
118+ "author": author(commit, authors),
54119 "authored_at": commit.authored_at,
55120 "parents": commit.parents,
56121 "html_url": format!("{site}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash),
57122 })
58123 }
59124
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+
60134 fn limit(input: &Value) -> u32 {
61135 let asked = match input.get("limit") {
62136 Some(Value::String(text)) => text.trim().parse::<u64>().ok(),
78152 &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, limit: limit(input) },
79153 )
80154 .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+ })));
88164 }
89165 // A path's own history: the commits that changed it, newest first.
90166 let history: Outcome<PathLog> = g1t_kit::call(
104180 Outcome::Ok(history) => history,
105181 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
106182 };
183+ let authors = read_authors(services, &history.commits.iter().collect::<Vec<_>>()).await;
107184 let last_changed = match history.commits.first() {
108− Some(first) => commit(&site, repo, first),
185+ Some(first) => commit(&site, repo, first, &authors),
109186 None if history.complete => json!(format!("{path} is not in this repository at this ref.")),
110187 None => json!("Not found in time: its history is long. Ask with a smaller limit, or read the branch's commits."),
111188 };
112189 Ok(Outcome::Ok(json!({
113190 "repo": full,
114191 "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<_>>(),
116193 "last_changed": last_changed,
117194 "complete": history.complete,
118195 })))
134211 authored_at: "2026-10-13T01:00:00Z".into(),
135212 };
136213 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());
138215 assert_eq!(shown["sha"], "abc123");
139216 assert_eq!(shown["message"], "The runner retries installs that break off.");
140217 assert_eq!(shown["author"], json!({ "name": "Chase Pierce" }));
143220 assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT);
144221 assert_eq!(limit(&json!({})), DEFAULT_LIMIT);
145222 }
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+ }
146260 }
+1−1
148148 "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp),
149149 "claude_code_plugin": {
150150 "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",
152152 "claude plugin install g1t@g1t",
153153 ],
154154 "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.",
+6−2
16471647 "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."
16481648 }
16491649 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."
16511651 }
16521652 Op::MarkPullRequestReady => {
16531653 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why."
17371737 "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."
17381738 }
17391739 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."
17411741 }
17421742 Op::GetWorkflowRun => {
17431743 "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."
29772977 "pull": { "type": "integer", "description": "A pull request's number." },
29782978 "sha": { "type": "string", "description": "A commit." },
29792979 "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." },
29802982 "limit": { "type": "integer", "description": "At most 100; 50 if not given." },
29812983 }),
29822984 &["repo"],
53215323 "pull": integer(input, "pull"),
53225324 "sha": optional_text(input, "sha"),
53235325 "status": optional_text(input, "status"),
5326+ "since": optional_text(input, "since"),
5327+ "until": optional_text(input, "until"),
53245328 "limit": integer(input, "limit"),
53255329 }),
53265330 )
+20−6
36623662 "message": "Read retry_limit, as #12 names it",
36633663 "author": {
36643664 "name": "Ana Lima",
3665− "email": "ana@example.com"
3665+ "username": "ana"
36663666 },
36673667 "parents": [
36683668 "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b"
75807580 "notes": "Name every pinned project once; anything else is refused with `422 invalid`."
75817581 },
75827582 "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.",
75847584 "params": {
75857585 "owner": "flagon-io",
75867586 "name": "g1t"
75957595 {
75967596 "sha": "85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5",
75977597 "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" },
75997599 "authored_at": "2026-10-12T22:04:11Z",
76007600 "parents": ["75c6704f2b1a3c4d5e6f708192a3b4c5d6e7f809"],
76017601 "html_url": "https://g1t.sh/flagon-io/g1t/commit/85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5"
76057605 "last_changed": {
76067606 "sha": "5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e",
76077607 "message": "Installs in cloud jobs retry when npm's download breaks off.",
7608− "author": { "name": "Chase Pierce" },
7608+ "author": { "name": "Chase Pierce", "username": "syntaqx" },
76097609 "authored_at": "2026-10-13T01:12:40Z",
76107610 "parents": ["0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3"],
76117611 "html_url": "https://g1t.sh/flagon-io/g1t/commit/5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e"
76337633 }
76347634 },
76357635 "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.",
76377637 "params": {
76387638 "workspace": "flagon-io"
76397639 },
76527652 "production_deployments": 1,
76537653 "failed_deployments": 0,
76547654 "default_branch_pushes": 0,
7655− "failed_runs": 0
7655+ "failed_runs": 0,
7656+ "shipped": { "live": 1, "deploying": 0, "failed": 0, "not_deployed": 0 }
76567657 },
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+ ],
76577671 "events": [
76587672 {
76597673 "kind": "deployment_succeeded",
+2−2
775775 "GET",
776776 "/repos/:owner/:name/actions/workflows/:workflow/runs",
777777 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")],
779779 ),
780780 route(
781781 "POST",
805805 "GET",
806806 "/repos/:owner/:name/actions/runs",
807807 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")],
809809 ),
810810 route(
811811 "GET",
+6−2
77 //! request's issue, its decision records, and the session entries recorded
88 //! around the commit.
99
10−use g1t_contracts::decisions::{Decision, ListDecisionsArgs, Why, WhyIssue, WhyPull};
10+use g1t_contracts::decisions::{Decision, ListDecisionsArgs, Why, WhyCommit, WhyIssue, WhyPull};
1111 use g1t_contracts::repos::{Blame, BlameArgs, Commit, LogArgs, RepoPath};
1212 use g1t_contracts::work::{IssueDetail, ListPullsArgs, Pull, PullStatus, SessionEntry, SessionEntryKind, State, ViewArgs};
1313 use g1t_contracts::{FailureCode, Outcome, Viewer};
143143 Outcome::Ok(blame) => blame,
144144 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
145145 };
146− let Some(commit) = commit_of_line(&blame, line).cloned() else {
146+ let Some(found) = commit_of_line(&blame, line).cloned() else {
147147 let lines = blame.ranges.last().map_or(0, |range| range.end);
148148 return Ok(Outcome::fail(
149149 FailureCode::Invalid,
168168 Outcome::Ok(pulls) => pulls,
169169 Outcome::Fail(_) => Vec::new(),
170170 };
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);
171175 let mut pull = pull_for_commit(&merged, &[], &commit.hash).cloned();
172176 if pull.is_none() && !merged.is_empty() {
173177 let log: Outcome<Vec<Commit>> = g1t_kit::call(
+1−1
2121 | `apps/status` | TypeScript | edge | `status.g1t.sh`: probes every minute and keeps incidents | D1 `g1t-status` | `billing`, `repos` |
2222 | `apps/sudo` | TypeScript | front | g1t.sh's staff console, behind Cloudflare Access | Nothing | `actions`, `billing`, `events`, `identity`, `projects`, `status`, `telemetry` |
2323 | `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` |
2525 | `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` |
2626 | `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` |
2727 | `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` |
+4−3
18111811 even with `contents: write`. A push that does is declined, naming the file,
18121812 so a workflow cannot rewrite the workflows that run with its repository's
18131813 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.
18161817
18171818 The job's token is the repository's workspace acting with the Write role
18181819 at most, never Admin: it cannot manage webhooks, secrets, deploy keys or who
20252026 | --- | --- |
20262027 | `list` | `GET /repos/{owner}/{repo}/actions/workflows` |
20272028 | `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 |
20292030 | `get_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` |
20302031 | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=`, or `?format=text` for the whole log as plain text |
20312032 | `get_run` with `attempt` | `GET /repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}` |
+56−5
1010
1111 Ask answers with **your workspace's own AI provider**, on your workspace's
1212 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
1414 nothing for it; what an answer costs is on your provider's bill. Your key
1515 stays sealed in g1t: it is used to call the provider and is never sent to
1616 your browser.
2424 2. Connect a provider if there is none yet: Anthropic, OpenAI, Gemini or
2525 any of the others, or your own Anthropic- or OpenAI-compatible
2626 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.
3032
3133 Every member can then ask. Until a provider is chosen, Ask says so: owners
3234 get a link to set it up, and members are told to ask an owner. A message
3840 Anthropic-compatible endpoints through Anthropic's Messages API, and the
3941 rest through OpenAI's Chat Completions API.
4042
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+
4168 ## What it can read
4269
4370 Ask reads g1t to answer: whatever you can read, and nothing more. It uses
78105 (`activity` on the MCP server's `workspace` tool, or
79106 [`GET /workspaces/{workspace}/activity`](/reference/api/workspaces/get-workspace-activity/)).
80107
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+
81132 Answers link to what they name on g1t, and every number, title and link
82133 comes from what Ask read; when something does not exist, it says so.
83134 Answers come from a model and can still be wrong: follow the links to
138189 - **Answer again**, under g1t's latest answer, has it write that answer
139190 anew.
140191 - **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.
142193
143194 Answers are Markdown: code is highlighted with its language and a Copy
144195 button, tables scroll within the answer, and issues, pull requests and
+6−4
6363 operation's name too, such as `delete_repo`. See
6464 [managing a repository](/guides/managing-repositories/).
6565
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.
6870
6971 ## What an entry says
7072
7173 | Field | What it is |
7274 | --- | --- |
7375 | 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. |
7577 | Run | The workflow run whose job's token did it, as `workflow_job`. |
7678 | 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. |
7779 | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. |
7880 | Target | The repository, the issue or pull request number, and for git the refs it moved. |
7981 | 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). |
8183 | Result | `ok`, or the reason it failed. |
8284 | Request id | The request's id, the same one Cloudflare logs it under. |
8385
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.