Ask names people by their g1t username, knows what shipped means, and picks its model by itself. Asked "What shipped today?", Ask made "@chasepierce" out of a commit's git author name, corrected itself mid-answer, and said nothing had reached production though the repository's own Deploy run had: commit listings carried only the git name, the prompt never said which field is a username, and activity counted only g1t.page deployments as production. Commits from list_commits now say `author: { name, username }`, the username from identity's address owners (a confirmed or noreply address; g1t's own are g1t), never the address, and activity's landed commits carry `username` too; the prompt mentions only usernames tools returned, never turns a git name or address into one, and answers once, without "wait" or corrections. Shipping, to Ask, is reaching the default branch and then production: a successful run of a repository's deploy workflow on its default branch (a workflow whose name or file says deploy, not preview) now counts as a production deployment beside g1t.page's, each landed commit says whether it is live (and by which deploy), deploying, failed or not deployed (a deploy carries everything before it on the branch), summary.shipped counts them, and list_runs takes since and until; releases and tags are one more signal, and today is the start of your day in your time zone. Ask's model is Auto by default (migration 0010 turns it on for every Ask route nobody chose a model for: the old picker started on the provider's first model, Haiku for Anthropic). Auto sends each question to a model of the workspace's provider without asking another model: a short lookup to a fast one, a question about why, how, what shipped, changes, failures, a time range, or a long conversation to a standard one; tiers come from the models' names, so it works for every provider, the largest only when nothing else is there, and an Azure deployment or an endpoint without a list keeps its own model. The answer says which ("Anthropic · Auto · claude-sonnet-5-5"); an explicit pick still wins. The Ask guide has Auto and what Ask means by shipped. Checked on this change alone (a worktree of the commit before it): cargo test for g1t-integrations (70, new: Auto's routing for each provider, prompt rules), g1t-api (204, new: commit authors with and without an account, shipped states), g1t-actions-service (108), g1t-contracts (306) and the CLI (149); cargo check --workspace; wasm32 builds of api, integrations and actions; the site's typecheck (tsc -b --force), its Ask tests and a production build; chat's tests and typecheck; the deploy tool's tests.
28 files+665−5912 files not counted0/28 viewed
| 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 | } | |
| 1586 | 1586 | "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." | |
| 1587 | 1587 | } | |
| 1588 | 1588 | Op::ListWorkflowRuns => { | |
| 1589 | − | "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." | |
| 1589 | + | "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." | |
| 1590 | 1590 | } | |
| 1591 | 1591 | Op::GetWorkflowRun => { | |
| 1592 | 1592 | "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." | |
| ⋯ | |||
| 2627 | 2627 | "pull": { "type": "integer", "description": "A pull request's number." }, | |
| 2628 | 2628 | "sha": { "type": "string", "description": "A commit." }, | |
| 2629 | 2629 | "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." }, | |
| 2630 | + | "since": { "type": "string", "description": "RFC 3339: only runs created at or after it." }, | |
| 2631 | + | "until": { "type": "string", "description": "RFC 3339: only runs created before it." }, | |
| 2630 | 2632 | "limit": { "type": "integer", "description": "At most 100; 50 if not given." }, | |
| 2631 | 2633 | }), | |
| 2632 | 2634 | &["repo"], | |
| ⋯ | |||
| 4726 | 4728 | "pull": integer(input, "pull"), | |
| 4727 | 4729 | "sha": optional_text(input, "sha"), | |
| 4728 | 4730 | "status": optional_text(input, "status"), | |
| 4731 | + | "since": optional_text(input, "since"), | |
| 4732 | + | "until": optional_text(input, "until"), | |
| 4729 | 4733 | "limit": integer(input, "limit"), | |
| 4730 | 4734 | }), | |
| 4731 | 4735 | ) | |
| 7061 | 7061 | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 7062 | 7062 | }, | |
| 7063 | 7063 | "list_commits": { | |
| 7064 | − | "notes": "With `path`, `last_changed` is the commit that last changed that file or directory.", | |
| 7064 | + | "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.", | |
| 7065 | 7065 | "params": { | |
| 7066 | 7066 | "owner": "flagon-io", | |
| 7067 | 7067 | "name": "g1t" | |
| ⋯ | |||
| 7076 | 7076 | { | |
| 7077 | 7077 | "sha": "85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5", | |
| 7078 | 7078 | "message": "The runner's broker test reads the relayed answer's body as chunks.", | |
| 7079 | − | "author": { "name": "Chase Pierce" }, | |
| 7079 | + | "author": { "name": "Chase Pierce", "username": "syntaqx" }, | |
| 7080 | 7080 | "authored_at": "2026-10-12T22:04:11Z", | |
| 7081 | 7081 | "parents": ["75c6704f2b1a3c4d5e6f708192a3b4c5d6e7f809"], | |
| 7082 | 7082 | "html_url": "https://g1t.sh/flagon-io/g1t/commit/85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5" | |
| ⋯ | |||
| 7086 | 7086 | "last_changed": { | |
| 7087 | 7087 | "sha": "5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e", | |
| 7088 | 7088 | "message": "Installs in cloud jobs retry when npm's download breaks off.", | |
| 7089 | − | "author": { "name": "Chase Pierce" }, | |
| 7089 | + | "author": { "name": "Chase Pierce", "username": "syntaqx" }, | |
| 7090 | 7090 | "authored_at": "2026-10-13T01:12:40Z", | |
| 7091 | 7091 | "parents": ["0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3"], | |
| 7092 | 7092 | "html_url": "https://g1t.sh/flagon-io/g1t/commit/5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e" | |
| ⋯ | |||
| 7114 | 7114 | } | |
| 7115 | 7115 | }, | |
| 7116 | 7116 | "get_workspace_activity": { | |
| 7117 | − | "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.", | |
| 7117 | + | "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.", | |
| 7118 | 7118 | "params": { | |
| 7119 | 7119 | "workspace": "flagon-io" | |
| 7120 | 7120 | }, | |
| ⋯ | |||
| 7133 | 7133 | "production_deployments": 1, | |
| 7134 | 7134 | "failed_deployments": 0, | |
| 7135 | 7135 | "default_branch_pushes": 0, | |
| 7136 | − | "failed_runs": 0 | |
| 7136 | + | "failed_runs": 0, | |
| 7137 | + | "shipped": { "live": 1, "deploying": 0, "failed": 0, "not_deployed": 0 } | |
| 7137 | 7138 | }, | |
| 7139 | + | "landed": [ | |
| 7140 | + | { | |
| 7141 | + | "repo": "flagon-io/g1t", | |
| 7142 | + | "commit": "75c6704f2b1a", | |
| 7143 | + | "subject": "Ask g1t answers with the workspace's own AI provider", | |
| 7144 | + | "author": "Chase Pierce", | |
| 7145 | + | "time": "2026-10-12T17:58:40Z", | |
| 7146 | + | "url": "https://g1t.sh/flagon-io/g1t/commit/75c6704f2b1a3c4d5e6f708192a3b4c5d6e7f809", | |
| 7147 | + | "username": "syntaqx", | |
| 7148 | + | "production": "live", | |
| 7149 | + | "deploy": { "name": "g1t's production deployment", "url": "https://g1t.sh/flagon-io/g1t/deployments/dpl_01kkp3m8w2f6t9qh4c7d1r5n0x" } | |
| 7150 | + | } | |
| 7151 | + | ], | |
| 7138 | 7152 | "events": [ | |
| 7139 | 7153 | { | |
| 7140 | 7154 | "kind": "deployment_succeeded", | |
| 769 | 769 | "GET", | |
| 770 | 770 | "/repos/:owner/:name/actions/workflows/:workflow/runs", | |
| 771 | 771 | Op::ListWorkflowRuns, | |
| 772 | − | &[("branch", "branch"), ("event", "event"), ("status", "status"), ("head_sha", "sha"), ("per_page", "limit")], | |
| 772 | + | &[("branch", "branch"), ("event", "event"), ("status", "status"), ("head_sha", "sha"), ("since", "since"), ("until", "until"), ("per_page", "limit")], | |
| 773 | 773 | ), | |
| 774 | 774 | route( | |
| 775 | 775 | "POST", | |
| ⋯ | |||
| 799 | 799 | "GET", | |
| 800 | 800 | "/repos/:owner/:name/actions/runs", | |
| 801 | 801 | Op::ListWorkflowRuns, | |
| 802 | − | &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("status", "status"), ("per_page", "limit")], | |
| 802 | + | &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("status", "status"), ("since", "since"), ("until", "until"), ("per_page", "limit")], | |
| 803 | 803 | ), | |
| 804 | 804 | route( | |
| 805 | 805 | "GET", | |
| 1989 | 1989 | | --- | --- | | |
| 1990 | 1990 | | `list` | `GET /repos/{owner}/{repo}/actions/workflows` | | |
| 1991 | 1991 | | `get` | `GET /repos/{owner}/{repo}/actions/workflows/{workflow}`, with `ref`: the workflow, with `source`, its file on that branch or tag | | |
| 1992 | − | | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` | | |
| 1992 | + | | `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 | | |
| 1993 | 1993 | | `get_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` | | |
| 1994 | 1994 | | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=`, or `?format=text` for the whole log as plain text | | |
| 1995 | 1995 | | `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 | |
| 117 | 117 | page shows only their last four characters. | |
| 118 | 118 | ||
| 119 | 119 | **Ask**, in the top bar, answers with one of these providers: an owner | |
| 120 | − | chooses which, and its model, under **Ask g1t** on this page. See | |
| 120 | + | chooses which, and its model (**Auto**, the default, picks one for each question), under **Ask g1t** on this page. See | |
| 121 | 121 | [Ask g1t](/guides/ask/). | |
| 122 | 122 | ||
| 123 | 123 | With an **Anthropic** connection, members can also start a session on an |
| 249 | 249 | | --- | --- | --- | --- | | |
| 250 | 250 | | [`list`](/reference/api/repositories/list-repos/) | Repositories you can see, optionally filtered by `query`. | None | `repo:read` | | |
| 251 | 251 | | [`get`](/reference/api/repositories/get-repo/) | One repository's details. | `repo` | `repo:read` | | |
| 252 | − | | [`commits`](/reference/api/repositories/list-commits/) | A branch's commits, newest first, each with its `sha`, `message`, `author`, `authored_at` and `html_url`; `ref` names another branch, tag or commit, `limit` is 30 unless you say (at most 100). With `path`, only the commits that changed that file or directory (its history), newest first, with `last_changed` (the newest) and `complete` (false when a long history was not walked to its start in time). | `repo` | `code:read` | | |
| 252 | + | | [`commits`](/reference/api/repositories/list-commits/) | A branch's commits, newest first, each with its `sha`, `message`, `author` (`name` as git recorded it, and `username` when the author's address is a g1t account's; never the address), `authored_at` and `html_url`; `ref` names another branch, tag or commit, `limit` is 30 unless you say (at most 100). With `path`, only the commits that changed that file or directory (its history), newest first, with `last_changed` (the newest) and `complete` (false when a long history was not walked to its start in time). | `repo` | `code:read` | | |
| 253 | 253 | | [`contents`](/reference/api/repositories/get-contents/) | What it holds at `path` (the root when left out) at `ref` (the default branch when left out): a file's `text` (null for a binary one; at most 200,000 characters), or a directory's `entries` and its README. | `repo` | `code:read` | | |
| 254 | 254 | | [`tarball`](/reference/api/repositories/download-tarball-archive/) | A link to a branch, tag or commit (`ref`, the default branch when left out) as a `.tar.gz` that needs no token, good for 10 minutes, as `url`, with the `commit` it is of. | `repo` | `code:read` | | |
| 255 | 255 | | [`zipball`](/reference/api/repositories/download-zipball-archive/) | The same as a `.zip`. | `repo` | `code:read` | | |
| ⋯ | |||
| 415 | 415 | | --- | --- | --- | --- | | |
| 416 | 416 | | [`list`](/reference/api/actions/list-workflows/) | The workflows, with their events, state, problems, notes on what runs differently, manual-run inputs and last run. | `repo` | `workflows:read` | | |
| 417 | 417 | | [`get`](/reference/api/actions/get-workflow/) | One workflow by its id, file name, path or name, as `list` gives it, with `source`, its file on `ref` (the default branch unless given). | `repo`, `workflow` | `workflows:read` | | |
| 418 | − | | [`list_runs`](/reference/api/actions/list-runs-of-workflow/) | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull`, `sha`, or `status`: a status (`queued`, `in_progress`, `completed`) or a conclusion (`failure` for the failed runs, `success`, `cancelled`). | `repo` | `workflows:read` | | |
| 418 | + | | [`list_runs`](/reference/api/actions/list-runs-of-workflow/) | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull`, `sha`, or `status`: a status (`queued`, `in_progress`, `completed`) or a conclusion (`failure` for the failed runs, `success`, `cancelled`); `since` and `until` (RFC 3339) bound when they were created. | `repo` | `workflows:read` | | |
| 419 | 419 | | [`get_run`](/reference/api/actions/get-workflow-run/) | A run with its jobs, their steps and annotations, and its `attempts`; `attempt` reads an earlier attempt, with the jobs it had then. | `repo`, `id` | `workflows:read` | | |
| 420 | 420 | | [`job_logs`](/reference/api/actions/get-job-logs/) | A job's log after `after`, or with `tail` its last chunks (at most 500), where a failed job says why; `done` says if more will come. | `repo`, `job` | `workflows:read` | | |
| 421 | 421 | | [`dispatch`](/reference/api/actions/dispatch-workflow/) | Run a `workflow_dispatch` workflow on `ref` with `inputs`. Write role. | `repo`, `workflow` | `workflows:write` | | |
| ⋯ | |||
| 693 | 693 | | [`update_integration`](/reference/api/integrations/update-integration/) | Change one: its `name`, its `config` (replaced whole) or its `secret` (write-only, never returned). Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 694 | 694 | | [`disconnect_integration`](/reference/api/integrations/disconnect-integration/) | Remove it and its secrets. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 695 | 695 | | [`test_integration`](/reference/api/integrations/test-integration/) | Check its credentials against the system it connects to. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 696 | − | | [`activity`](/reference/api/workspaces/get-workspace-activity/) | What happened across its repositories you can read, newest first: pull requests merged, opened and closed, issues, releases, deployments, pushes (`default_branch` marks what landed without a pull request), reviews and packages, each with its `repo`, `actor`, `number`, `title` and `url` where it has them, and a `summary` of what shipped. `since` and `until` (RFC 3339; the last 24 hours by default, at most 31 days), `kinds` and `limit` (at most 200) narrow it; `complete` is false when more happened than was read. | `workspace` | `repo:read` | | |
| 696 | + | | [`activity`](/reference/api/workspaces/get-workspace-activity/) | What happened across its repositories you can read, newest first: pull requests merged, opened and closed, issues, releases, deployments, pushes (`default_branch` marks what landed without a pull request), reviews and packages, each with its `repo`, `actor`, `number`, `title` and `url` where it has them, and a `summary` of what shipped. `landed` lists the commits that reached default branches, with each author's `username` when known and `production` (`live`, `deploying`, `failed` or `not_deployed`, with the `deploy` that decided it) where the repository deploys: a project's production deployment, or a successful run of its deploy workflow on the default branch. `since` and `until` (RFC 3339; the last 24 hours by default, at most 31 days), `kinds` and `limit` (at most 200) narrow it; `complete` is false when more happened than was read. | `workspace` | `repo:read` | | |
| 697 | 697 | | [`list_projects`](/reference/api/projects/list-projects/) | Its projects you can see, by name: each with its `kind` and `kind_reason`, where it `runs` and its `production_url`, its repository and `root_dir`, and its `links`. | `workspace` | `repo:read` | | |
| 698 | 698 | | [`get_project`](/reference/api/projects/get-project/) | One project, with what a person set (`setting`) and what detection decides (`detected`). | `workspace`, `project` | `repo:read` | | |
| 699 | 699 | | [`update_project`](/reference/api/projects/update-project/) | Change its `name`, `description`, `root_dir`, `kind`, `runs`, `production_url`, `homepage`, `docs_url` or `links`; only what you give changes. `auto` leaves `kind` or `runs` to detection, null clears a link or goes back to the repository's, and `links` replaces its other links (at most 10). Maintain role or higher. | `workspace`, `project` | `repo:write` | | |
Binary or large file; its contents are not shown.
| 5 | 5 | import { type AskSetup, type ChatLiveEvent, type ChatMessage, type Result } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { askPage, askWhere } from "../lib/ask"; | |
| 8 | + | import { modelLabel } from "../lib/ask-model"; | |
| 8 | 9 | import { browserTimeZone } from "../lib/time-zone"; | |
| 9 | 10 | import { anyAnswering, applyLive, fromG1t, isClearCommand, suggestions } from "../lib/g1t-chat"; | |
| 10 | 11 | import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation"; | |
| ⋯ | |||
| 353 | 354 | <Skeleton className="h-4 w-40" /> | |
| 354 | 355 | ) : ready ? ( | |
| 355 | 356 | <span className="min-w-0 truncate"> | |
| 356 | − | Answers with {setup.connection_name} · <span className="font-mono">{setup.model}</span> | |
| 357 | + | Answers with {setup.connection_name} · <span className="font-mono">{modelLabel(setup)}</span> | |
| 357 | 358 | </span> | |
| 358 | 359 | ) : ( | |
| 359 | 360 | <span>Not set up yet</span> | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { autoOffered, defaultModel, modelLabel, modelOptions } from "./ask-model.ts"; | |
| 5 | + | ||
| 6 | + | const anthropic = { | |
| 7 | + | id: "con_1", | |
| 8 | + | provider: "anthropic" as const, | |
| 9 | + | models: ["claude-haiku-5-5", "claude-opus-5-5", "claude-sonnet-5-5"], | |
| 10 | + | config: {}, | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | test("Auto is the picker's first option and its default", () => { | |
| 14 | + | assert.deepEqual( | |
| 15 | + | modelOptions(anthropic).map((option) => option.value), | |
| 16 | + | ["auto", "claude-haiku-5-5", "claude-opus-5-5", "claude-sonnet-5-5"], | |
| 17 | + | ); | |
| 18 | + | assert.equal(modelOptions(anthropic)[0]!.label, "Auto"); | |
| 19 | + | // Nothing chosen yet, or another connection than Ask's: Auto. | |
| 20 | + | assert.equal(defaultModel(anthropic, null), "auto"); | |
| 21 | + | assert.equal(defaultModel(anthropic, { connection_id: "con_2", model: "gpt-5", auto: false }), "auto"); | |
| 22 | + | // Ask's own connection on Auto, as every route that never picked is. | |
| 23 | + | assert.equal(defaultModel(anthropic, { connection_id: "con_1", model: null, auto: true }), "auto"); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("an explicit pick stays picked", () => { | |
| 27 | + | assert.equal(defaultModel(anthropic, { connection_id: "con_1", model: "claude-opus-5-5", auto: false }), "claude-opus-5-5"); | |
| 28 | + | // A pick the provider no longer lists falls back to Auto. | |
| 29 | + | assert.equal(defaultModel(anthropic, { connection_id: "con_1", model: "claude-2", auto: false }), "auto"); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("a connection that lists no models, or an Azure deployment, has no Auto", () => { | |
| 33 | + | const endpoint = { id: "con_3", provider: "openai_endpoint" as const, models: [], config: { model: "qwen3-coder" } }; | |
| 34 | + | assert.equal(autoOffered(endpoint), false); | |
| 35 | + | assert.deepEqual(modelOptions(endpoint), []); | |
| 36 | + | assert.equal(defaultModel(endpoint, null), "qwen3-coder"); | |
| 37 | + | const azure = { id: "con_4", provider: "azure_openai" as const, models: ["gpt-5"], config: { model: "prod-gpt" } }; | |
| 38 | + | assert.equal(autoOffered(azure), false); | |
| 39 | + | assert.equal(defaultModel(azure, { connection_id: "con_4", model: "prod-gpt", auto: false }), "prod-gpt"); | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | test("the chosen model reads as Auto, or by its name", () => { | |
| 43 | + | assert.equal(modelLabel({ model: null, auto: true }), "Auto"); | |
| 44 | + | assert.equal(modelLabel({ model: "claude-sonnet-5-5", auto: false }), "claude-sonnet-5-5"); | |
| 45 | + | assert.equal(modelLabel(null), ""); | |
| 46 | + | }); |
| 1 | + | /** | |
| 2 | + | * Ask's model picker (Integrations → Your AI providers → Ask g1t): Auto | |
| 3 | + | * first and chosen by default, then each model the provider listed. Pure, | |
| 4 | + | * so it is tested on its own (ask-model.test.ts). | |
| 5 | + | */ | |
| 6 | + | import type { ASK_AUTO_MODEL as AutoModel, AskSetup, Connection } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | /** `ASK_AUTO_MODEL`: the model name that chooses Auto. */ | |
| 9 | + | const ASK_AUTO_MODEL: typeof AutoModel = "auto"; | |
| 10 | + | ||
| 11 | + | export type ModelOption = { value: string; label: string; description?: string }; | |
| 12 | + | ||
| 13 | + | /** Whether Auto can choose on a connection: it lists its models, and it is not an Azure deployment. */ | |
| 14 | + | export function autoOffered(connection: Pick<Connection, "provider" | "models"> | null): boolean { | |
| 15 | + | return connection != null && connection.provider !== "azure_openai" && connection.models.length > 0; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /** The picker's options for a connection: Auto, then its models. */ | |
| 19 | + | export function modelOptions(connection: Pick<Connection, "provider" | "models">): ModelOption[] { | |
| 20 | + | const models = connection.models.map((model) => ({ value: model, label: model })); | |
| 21 | + | if (!autoOffered(connection)) return models; | |
| 22 | + | return [{ value: ASK_AUTO_MODEL, label: "Auto", description: "A fast model for lookups, a stronger one when a question needs it" }, ...models]; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * What the picker starts at: for the connection Ask uses, what was chosen | |
| 27 | + | * (Auto unless a model was); for another, Auto where it is offered, else | |
| 28 | + | * the connection's own model or the first it lists. | |
| 29 | + | */ | |
| 30 | + | export function defaultModel( | |
| 31 | + | connection: Pick<Connection, "id" | "provider" | "models" | "config"> | null, | |
| 32 | + | setup: Pick<AskSetup, "connection_id" | "model" | "auto"> | null, | |
| 33 | + | ): string { | |
| 34 | + | if (!connection) return ""; | |
| 35 | + | const offered = autoOffered(connection); | |
| 36 | + | if (setup && connection.id === setup.connection_id) { | |
| 37 | + | if (setup.auto && offered) return ASK_AUTO_MODEL; | |
| 38 | + | if (setup.model && (!offered || connection.models.includes(setup.model))) return setup.model; | |
| 39 | + | } | |
| 40 | + | if (offered) return ASK_AUTO_MODEL; | |
| 41 | + | return connection.config.model ?? connection.models[0] ?? ""; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** How the chosen model reads: "Auto", or the model's name. */ | |
| 45 | + | export function modelLabel(setup: Pick<AskSetup, "model" | "auto"> | null): string { | |
| 46 | + | if (setup?.auto) return setup.model ? `Auto · ${setup.model}` : "Auto"; | |
| 47 | + | return setup?.model ?? ""; | |
| 48 | + | } |
| 41 | 41 | list = applyLive(list, { type: "message.streaming", channel_id: "chn_1", id: "msg_2", body: "Tw", answer: { state: "streaming" } }, "chn_1"); | |
| 42 | 42 | assert.equal(list[1]!.body, "Two pull requests."); | |
| 43 | 43 | assert.equal(answerLabel(list[1]!.answer), "Anthropic · m"); | |
| 44 | + | // Auto names the model it chose. | |
| 45 | + | assert.equal(answerLabel({ state: "done", provider_label: "Anthropic", model: "claude-sonnet-5-5", auto: true }), "Anthropic · Auto · claude-sonnet-5-5"); | |
| 46 | + | assert.equal(answerLabel({ state: "thinking" }), null); | |
| 44 | 47 | }); | |
| 45 | 48 | ||
| 46 | 49 | test("other conversations, thread replies and deletions", () => { |
| 20 | 20 | return messages.some((message) => !message.deleted_at && answering(message.answer)); | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | − | /** Who wrote an answer: "Anthropic · claude-…", once the provider said. */ | |
| 23 | + | /** Who wrote an answer: "Anthropic · claude-…", or "Anthropic · Auto · claude-…" when Auto chose it, once the provider said. */ | |
| 24 | 24 | export function answerLabel(answer: ChatAnswer | null | undefined): string | null { | |
| 25 | 25 | if (!answer?.provider_label) return null; | |
| 26 | − | return answer.model ? `${answer.provider_label} · ${answer.model}` : answer.provider_label; | |
| 26 | + | if (!answer.model) return answer.provider_label; | |
| 27 | + | return answer.auto ? `${answer.provider_label} · Auto · ${answer.model}` : `${answer.provider_label} · ${answer.model}`; | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | /** Whether an answer was cut off at its length limit. */ |
| 32 | 32 | import { Card } from "../../components/ui/card"; | |
| 33 | 33 | import { Combobox } from "../../components/ui/combobox"; | |
| 34 | 34 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 35 | + | import { autoOffered, defaultModel as startingModel, modelLabel, modelOptions } from "../../lib/ask-model"; | |
| 35 | 36 | import { integrations, repos } from "../../lib/services.server"; | |
| 36 | 37 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 37 | 38 | ||
| ⋯ | |||
| 461 | 462 | ||
| 462 | 463 | /** | |
| 463 | 464 | * Ask g1t's provider and model: one of the workspace's AI providers, and | |
| 464 | − | * one of the models it offered when last checked (or any name, for one | |
| 465 | − | * that lists none). Owners choose; members see what it is. | |
| 465 | + | * Auto (the default: a model for each question) or one of the models it | |
| 466 | + | * offered when last checked (or any name, for one that lists none). Owners | |
| 467 | + | * choose; members see what it is. | |
| 466 | 468 | */ | |
| 467 | 469 | function AskSetting({ | |
| 468 | 470 | connections, | |
| ⋯ | |||
| 481 | 483 | const [chosen, setChosen] = useState(current?.id ?? connections[0]?.id ?? ""); | |
| 482 | 484 | const connection = connections.find((c) => c.id === chosen) ?? null; | |
| 483 | 485 | const azure = connection?.provider === "azure_openai"; | |
| 484 | − | const defaultModel = | |
| 485 | − | connection && connection.id === current?.id ? (setup?.model ?? "") : (connection?.config.model ?? connection?.models[0] ?? ""); | |
| 486 | + | const defaultModel = startingModel(connection, setup); | |
| 486 | 487 | return ( | |
| 487 | 488 | <Card asChild className="mb-6 scroll-mt-20 p-4"> | |
| 488 | 489 | <section id="ask" aria-labelledby="ask-heading"> | |
| ⋯ | |||
| 496 | 497 | {setup?.connection_id && !setup.problem && ( | |
| 497 | 498 | <p className="mt-3 text-sm"> | |
| 498 | 499 | Answers with <span className="font-medium">{setup.connection_name}</span> ·{" "} | |
| 499 | − | <span className="font-mono text-[0.8125rem]">{setup.model}</span> | |
| 500 | + | <span className="font-mono text-[0.8125rem]">{modelLabel(setup)}</span> | |
| 500 | 501 | </p> | |
| 501 | 502 | )} | |
| 502 | 503 | {setup?.problem && <p className="mt-3 text-sm text-warn">{setup.problem}</p>} | |
| ⋯ | |||
| 523 | 524 | </Select> | |
| 524 | 525 | </Field> | |
| 525 | 526 | <Field label={azure ? "Deployment" : "Model"}> | |
| 526 | − | {connection && connection.models.length > 0 && !azure ? ( | |
| 527 | + | {autoOffered(connection) && connection ? ( | |
| 527 | 528 | <Combobox | |
| 528 | 529 | key={connection.id} | |
| 529 | 530 | name="model" | |
| 530 | − | defaultValue={connection.models.includes(defaultModel) ? defaultModel : (connection.models[0] ?? "")} | |
| 531 | + | defaultValue={defaultModel} | |
| 531 | 532 | aria-label="Model" | |
| 532 | 533 | searchPlaceholder="Find a model" | |
| 533 | 534 | emptyText="It offers no model by that name." | |
| 534 | − | options={connection.models.map((model) => ({ value: model, label: model }))} | |
| 535 | + | options={modelOptions(connection)} | |
| 535 | 536 | className="font-mono text-[0.8125rem]" | |
| 536 | 537 | /> | |
| 537 | 538 | ) : ( | |
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.