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.

g1t.sh
613 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1//! `get_workspace_activity`: what happened across a workspace's
2//! repositories over a span, newest first, as one list a person (or Ask
3//! g1t, or an agent) can read: pull requests merged and opened, issues
4//! opened and closed, releases, deployments and pushes, each with its
5//! repository, who did it, its title where it has one and its address on
6//! the site. "What shipped today?" is one call.
7//!
8//! The repositories are those of the workspace the caller can read, as the
9//! repos service lists them for them; the events service reads their events
10//! as one list (`repo_ids`). Pull requests' titles, which their events do
11//! not carry, come from the work service's newest pull requests of the
12//! repositories that had any; people's names from identity. Everything
13//! after the reads is [`shape`], a plain function, tested on its own.
14
15use std::collections::{BTreeSet, HashMap};
16
17use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS};
18use g1t_contracts::identity::UsernamesArgs;
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.19use g1t_contracts::repos::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath};
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.20use g1t_contracts::time::{parse_rfc3339, rfc3339};
21use g1t_contracts::work::{MAX_PULLS_FOR_REPOS, PullsForReposArgs, RepoPulls};
22use g1t_contracts::{FailureCode, Outcome, Viewer};
23use serde_json::{Value, json};
24use worker::Result;
25
26use crate::operations::Services;
27
28/// The span when none is given: the last day.
29const DEFAULT_SPAN_MS: u64 = 24 * 60 * 60 * 1000;
30/// The longest span one call reads.
31const MAX_SPAN_MS: u64 = 31 * 24 * 60 * 60 * 1000;
32pub const DEFAULT_LIMIT: u32 = 100;
33pub const MAX_LIMIT: u32 = 200;
34
35/// The kinds a caller may ask for, and the events each is.
36pub const KINDS: &[(&str, &[&str])] = &[
37 ("merged", &["pull.merged"]),
38 ("pulls", &["pull.opened", "pull.merged", "pull.closed"]),
39 ("issues", &["issue.opened", "issue.closed"]),
40 ("releases", &["release.published"]),
41 ("deployments", &["deployment.succeeded", "deployment.failed"]),
42 ("pushes", &["git.push"]),
43 ("reviews", &["review.completed"]),
44 ("comments", &["comment.created"]),
45 ("packages", &["package.published"]),
46 ("runs", &["workflow.completed"]),
47];
48
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.49/// The most repositories whose default-branch commits are read, and the
50/// most commits that `landed` lists across them.
51const LANDED_REPOS: usize = 8;
52const LANDED_MAX: usize = 40;
53/// How far back each of those repositories' history is read.
54const LANDED_LOG: u32 = 60;
55
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.56/// What is read when no kinds are given: everything but comments, which
57/// would crowd out the rest on a busy day.
58const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"];
59
60pub fn description() -> &'static str {
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.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."
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.62}
63
64pub fn input() -> Value {
65 json!({
66 "type": "object",
67 "properties": {
68 "workspace": { "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." },
69 "since": { "type": "string", "description": "RFC 3339: the start of the span, inclusive. The last 24 hours when left out; for \"today\", midnight UTC." },
70 "until": { "type": "string", "description": "RFC 3339: the end of the span, exclusive. Now when left out." },
71 "kinds": {
72 "type": "array",
73 "items": { "type": "string", "enum": KINDS.iter().map(|(name, _)| *name).collect::<Vec<_>>() },
74 "description": "What to read: merged (merged pull requests only), pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs (workflow runs that finished). All but comments when left out.",
75 },
76 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "The most events to return, newest first: 100 unless you say, at most 200." },
77 },
78 "required": ["workspace"],
79 })
80}
81
82/// The span asked for, in milliseconds, or why it cannot be read.
83pub fn span(input: &Value, now: u64) -> std::result::Result<(u64, u64), String> {
84 let at = |key: &str| -> std::result::Result<Option<u64>, String> {
85 match input.get(key).and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()) {
86 None => Ok(None),
87 Some(text) => parse_rfc3339(text).map(Some).ok_or_else(|| format!("{key} must be an RFC 3339 time, such as 2026-10-12T00:00:00Z.")),
88 }
89 };
90 let until = at("until")?.unwrap_or(now).min(now);
91 let since = at("since")?.unwrap_or(until.saturating_sub(DEFAULT_SPAN_MS));
92 if since >= until {
93 return Err("since must be before until.".to_owned());
94 }
95 if until - since > MAX_SPAN_MS {
96 return Err("A span can be at most 31 days. Narrow since and until.".to_owned());
97 }
98 Ok((since, until))
99}
100
101/// The event types the `kinds` asked for read, or the unknown kind.
102pub fn event_types(input: &Value) -> std::result::Result<Vec<&'static str>, String> {
103 // An array, or over REST a comma-separated list.
104 let asked: Vec<String> = match input.get("kinds") {
105 Some(Value::Array(kinds)) => kinds.iter().filter_map(Value::as_str).map(str::to_owned).collect(),
106 Some(Value::String(kinds)) => kinds.split(',').map(str::to_owned).collect(),
107 _ => Vec::new(),
108 }
109 .into_iter()
110 .map(|kind| kind.trim().to_lowercase())
111 .filter(|kind| !kind.is_empty())
112 .collect();
113 let names: Vec<&str> = if asked.is_empty() { DEFAULT_KINDS.to_vec() } else { asked.iter().map(String::as_str).collect() };
114 let mut types = BTreeSet::new();
115 for name in names {
116 let Some((_, kinds)) = KINDS.iter().find(|(kind, _)| *kind == name) else {
117 let known: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect();
118 return Err(format!("There is no kind {name}. Kinds: {}.", known.join(", ")));
119 };
120 types.extend(kinds.iter().copied());
121 }
122 Ok(types.into_iter().collect())
123}
124
125/// The most events asked for: a number, or over REST its text.
126pub fn limit(input: &Value) -> u32 {
127 let asked = match input.get("limit") {
128 Some(Value::String(text)) => text.trim().parse::<u64>().ok(),
129 Some(value) => value.as_u64(),
130 None => None,
131 };
132 asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32)
133}
134
135fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> {
136 data.get(key).and_then(Value::as_str).filter(|text| !text.is_empty())
137}
138
139/// What the reads found, shaped into the answer.
140pub struct Found<'a> {
141 pub workspace: &'a str,
142 pub site: &'a str,
143 pub since: u64,
144 pub until: u64,
145 pub repos: &'a [Repo],
146 /// Newest first, as the events service lists them.
147 pub events: &'a [Event],
148 /// Whether the events service had more than was read.
149 pub more: bool,
150 /// `(repo id, number)` to a pull request's title.
151 pub pull_titles: &'a HashMap<(String, u32), String>,
152 /// Account id to username.
153 pub usernames: &'a HashMap<String, String>,
154}
155
156pub fn shape(found: &Found) -> Value {
157 let site = found.site.trim_end_matches('/');
158 let by_id: HashMap<&str, &Repo> = found.repos.iter().map(|repo| (repo.id.as_str(), repo)).collect();
159 let mut events = Vec::new();
160 let (mut merged, mut releases, mut production, mut landed, mut failed) = (0u32, 0u32, 0u32, 0u32, 0u32);
161 let mut runs_failed = 0u32;
162 for event in found.events {
163 let Some(repo) = event.repo_id.as_deref().and_then(|id| by_id.get(id)) else {
164 continue;
165 };
166 let at = parse_rfc3339(&event.time).unwrap_or_default();
167 if at < found.since || at >= found.until {
168 continue;
169 }
170 let full = format!("{}/{}", repo.namespace, repo.name);
171 let page = format!("{site}/{full}");
172 let data = &event.data;
173 let number = data.get("number").and_then(Value::as_u64).map(|n| n as u32);
174 let actor = event
175 .actor
176 .as_deref()
177 .and_then(|id| found.usernames.get(id).cloned())
178 .or_else(|| data.get("author").and_then(|author| text(author, "username")).map(str::to_owned))
179 .or_else(|| text(data, "triggeredBy").map(str::to_owned));
180 let mut shown = json!({ "kind": event.kind.replace('.', "_"), "time": event.time, "repo": full });
181 if let Some(actor) = actor {
182 shown["actor"] = json!(actor);
183 }
184 match event.kind.as_str() {
185 "pull.opened" | "pull.merged" | "pull.closed" => {
186 let Some(number) = number else { continue };
187 shown["number"] = json!(number);
188 if let Some(title) = found.pull_titles.get(&(repo.id.clone(), number)) {
189 shown["title"] = json!(title);
190 }
191 shown["url"] = json!(format!("{page}/pull/{number}"));
192 if let Some(issue) = data.get("issue").and_then(Value::as_u64) {
193 shown["issue"] = json!(issue);
194 }
195 if event.kind == "pull.merged" {
196 merged += 1;
197 }
198 }
199 "issue.opened" | "issue.closed" => {
200 let Some(number) = number else { continue };
201 shown["number"] = json!(number);
202 if let Some(title) = text(data, "title") {
203 shown["title"] = json!(title);
204 }
205 if let Some(reason) = text(data, "reason") {
206 shown["reason"] = json!(reason);
207 }
208 if let Some(pull) = data.get("resolvedBy").and_then(Value::as_u64) {
209 shown["resolved_by"] = json!(pull);
210 }
211 shown["url"] = json!(format!("{page}/issues/{number}"));
212 }
213 "release.published" => {
214 let tag = text(data, "tagName").unwrap_or_default();
215 shown["tag"] = json!(tag);
216 if let Some(name) = data.get("release").and_then(|release| text(release, "name")) {
217 shown["title"] = json!(name);
218 }
219 shown["url"] = json!(format!("{page}/releases/tag/{tag}"));
220 releases += 1;
221 }
222 "deployment.succeeded" | "deployment.failed" => {
223 let kind = text(data, "kind").unwrap_or("preview");
224 shown["environment"] = json!(kind);
225 if let Some(project) = text(data, "project") {
226 shown["project"] = json!(project);
227 }
228 if let Some(branch) = text(data, "branch") {
229 shown["branch"] = json!(branch);
230 }
231 if let Some(commit) = text(data, "commit") {
232 shown["commit"] = json!(commit.chars().take(12).collect::<String>());
233 }
234 if let Some(error) = text(data, "error") {
235 shown["error"] = json!(error.chars().take(300).collect::<String>());
236 }
237 shown["url"] = json!(match text(data, "path") {
238 Some(path) => format!("{site}{path}"),
239 None => format!("{page}/deployments"),
240 });
241 if event.kind == "deployment.succeeded" && kind == "production" {
242 production += 1;
243 }
244 if event.kind == "deployment.failed" {
245 failed += 1;
246 }
247 }
248 "git.push" => {
249 // Branches only: a tag's push is its release.
250 let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/")) else { continue };
251 let default_branch = data.get("defaultBranch").and_then(Value::as_bool) == Some(true);
252 shown["branch"] = json!(branch);
253 shown["default_branch"] = json!(default_branch);
254 if let Some(commits) = data.get("commits").and_then(Value::as_u64) {
255 shown["commits"] = json!(commits);
256 }
257 if let Some(after) = text(data, "after") {
258 shown["commit"] = json!(after.chars().take(12).collect::<String>());
259 shown["url"] = json!(format!("{page}/commit/{after}"));
260 }
261 if default_branch {
262 landed += 1;
263 }
264 }
265 "review.completed" | "comment.created" => {
266 if let Some(number) = number.or_else(|| data.get("issue").and_then(Value::as_u64).map(|n| n as u32)) {
267 shown["number"] = json!(number);
268 shown["url"] = json!(format!("{page}/issues/{number}"));
269 }
270 if let Some(verdict) = text(data, "verdict") {
271 shown["verdict"] = json!(verdict);
272 }
273 }
274 "workflow.completed" => {
275 let conclusion = text(data, "conclusion").unwrap_or_default();
276 if let Some(workflow) = text(data, "workflow") {
277 shown["workflow"] = json!(workflow);
278 }
279 if let Some(run_number) = data.get("number").and_then(Value::as_u64) {
280 shown["run_number"] = json!(run_number);
281 }
282 shown["conclusion"] = json!(conclusion);
283 if let Some(branch) = text(data, "ref").map(|r| r.strip_prefix("refs/heads/").unwrap_or(r)) {
284 shown["branch"] = json!(branch);
285 }
286 if let Some(sha) = text(data, "sha") {
287 shown["commit"] = json!(sha.chars().take(12).collect::<String>());
288 }
289 if let Some(pull) = data.get("pull").and_then(Value::as_u64) {
290 shown["pull"] = json!(pull);
291 }
292 if let Some(run) = text(data, "runId") {
293 shown["run_id"] = json!(run);
294 shown["url"] = json!(format!("{page}/actions/runs/{run}"));
295 }
296 if conclusion == "failure" {
297 runs_failed += 1;
298 }
299 }
300 "package.published" => {
301 for key in ["ecosystem", "name", "version"] {
302 if let Some(value) = text(data, key) {
303 shown[key] = json!(value);
304 }
305 }
306 }
307 _ => continue,
308 }
309 events.push(shown);
310 }
311 json!({
312 "workspace": found.workspace,
313 "since": rfc3339(found.since),
314 "until": rfc3339(found.until),
315 "repositories": found.repos.len(),
316 "summary": {
317 "merged_pull_requests": merged,
318 "releases": releases,
319 "production_deployments": production,
320 "failed_deployments": failed,
321 "default_branch_pushes": landed,
322 "failed_runs": runs_failed,
323 },
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.324 // What landed on default branches in the span, one line a commit
325 // (`run` fills it in): what shipped where nothing was merged.
326 "landed": [],
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.327 "events": events,
328 "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS,
329 })
330}
331
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.332/// One repository's default-branch commits made in the span, newest first,
333/// each as one line: its short sha, the first line of its message, who
334/// wrote it, when, and its page.
335pub fn landed(site: &str, repo: &str, commits: &[Commit], since: u64, until: u64) -> Vec<Value> {
336 let site = site.trim_end_matches('/');
337 commits
338 .iter()
339 .filter(|commit| parse_rfc3339(&commit.authored_at).is_some_and(|at| at >= since && at < until))
340 .map(|commit| {
341 let subject: String = commit.message.lines().next().unwrap_or_default().trim().chars().take(160).collect();
342 json!({
343 "repo": repo,
344 "commit": commit.hash.chars().take(12).collect::<String>(),
345 "subject": subject,
346 "author": commit.author.name,
347 "time": commit.authored_at,
348 "url": format!("{site}/{repo}/commit/{}", commit.hash),
349 })
350 })
351 .collect()
352}
353
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.354pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
355 let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase();
356 if workspace.is_empty() {
357 return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug."));
358 }
359 let (since, until) = match span(input, g1t_kit::now_ms()) {
360 Ok(span) => span,
361 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
362 };
363 let types = match event_types(input) {
364 Ok(types) => types,
365 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
366 };
367 let limit = limit(input);
368 // The workspace's repositories as the caller may read them; a
369 // workspace they cannot see has none.
370 let repos: Vec<Repo> = g1t_kit::call(
371 &services.repos,
372 "list",
373 &ListReposArgs { viewer: viewer.clone(), query: None, namespace: Some(workspace.clone()), member_only: false },
374 )
375 .await?;
376 let repos: Vec<Repo> = repos.into_iter().filter(|repo| repo.namespace.eq_ignore_ascii_case(&workspace)).collect();
377 let site = services.addresses.site.clone();
378 let none = (HashMap::new(), HashMap::new());
379 if repos.is_empty() {
380 return Ok(Outcome::Ok(shape(&Found {
381 workspace: &workspace,
382 site: &site,
383 since,
384 until,
385 repos: &repos,
386 events: &[],
387 more: false,
388 pull_titles: &none.0,
389 usernames: &none.1,
390 })));
391 }
392 let events: Vec<Event> = g1t_kit::call(
393 &services.events,
394 "list",
395 &ListEventsArgs {
396 repo_ids: repos.iter().take(MAX_LIST_REPOS).map(|repo| repo.id.clone()).collect(),
397 types: types.iter().map(|kind| (*kind).to_owned()).collect(),
398 since: Some(rfc3339(since)),
399 // One past the limit says whether there was more.
400 limit: Some(limit + 1),
401 ..ListEventsArgs::default()
402 },
403 )
404 .await?;
405 let more = events.len() > limit as usize;
406 let events: Vec<Event> = events.into_iter().take(limit as usize).collect();
407 // Titles of the pull requests named, and the names of who did what.
408 let pull_repos: Vec<String> = events
409 .iter()
410 .filter(|event| event.kind.starts_with("pull."))
411 .filter_map(|event| event.repo_id.clone())
412 .collect::<BTreeSet<_>>()
413 .into_iter()
414 .take(MAX_PULLS_FOR_REPOS)
415 .collect();
416 let actors: Vec<String> = events.iter().filter_map(|event| event.actor.clone()).collect::<BTreeSet<_>>().into_iter().collect();
417 let pulls = async {
418 if pull_repos.is_empty() {
419 return Ok(Vec::new());
420 }
421 g1t_kit::call::<_, Vec<RepoPulls>>(
422 &services.work,
423 "pulls_for_repos",
424 &PullsForReposArgs { repo_ids: pull_repos, viewer: viewer.clone(), limit: 100 },
425 )
426 .await
427 };
428 let names = async {
429 if actors.is_empty() {
430 return Ok(HashMap::new());
431 }
432 g1t_kit::call::<_, HashMap<String, String>>(&services.identity, "usernames", &UsernamesArgs { ids: actors }).await
433 };
434 let (pulls, names) = futures_util::future::join(pulls, names).await;
435 // Without titles or names the events still say what happened.
436 let mut pull_titles = HashMap::new();
437 for repo in pulls.unwrap_or_default() {
438 for pull in repo.open.into_iter().chain(repo.closed) {
439 pull_titles.insert((repo.repo_id.clone(), pull.number), pull.title);
440 }
441 }
442 let usernames = names.unwrap_or_default();
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.443 let mut answer = shape(&Found {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.444 workspace: &workspace,
445 site: &site,
446 since,
447 until,
448 repos: &repos,
449 events: &events,
450 more,
451 pull_titles: &pull_titles,
452 usernames: &usernames,
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.453 });
454 // The repositories whose default branch was pushed to, busiest first:
455 // their commits say what landed.
456 let mut pushed: Vec<(String, usize)> = Vec::new();
457 for event in events.iter().filter(|event| event.kind == "git.push" && event.data.get("defaultBranch").and_then(Value::as_bool) == Some(true)) {
458 let Some(id) = event.repo_id.as_deref() else { continue };
459 match pushed.iter_mut().find(|(known, _)| known == id) {
460 Some((_, count)) => *count += 1,
461 None => pushed.push((id.to_owned(), 1)),
462 }
463 }
464 pushed.sort_by(|a, b| b.1.cmp(&a.1));
465 let by_id: HashMap<&str, &Repo> = repos.iter().map(|repo| (repo.id.as_str(), repo)).collect();
466 let logs = pushed.iter().take(LANDED_REPOS).filter_map(|(id, _)| by_id.get(id.as_str()).copied()).map(|repo| async move {
467 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)
471 });
472 let mut lines = Vec::new();
473 for (repo, log) in futures_util::future::join_all(logs).await {
474 // 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));
477 }
478 }
479 lines.truncate(LANDED_MAX);
480 answer["landed"] = json!(lines);
481 Ok(Outcome::Ok(answer))
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 fn repo(id: &str, name: &str) -> Repo {
489 serde_json::from_value(json!({
490 "id": id, "namespace": "acme", "name": name, "description": null, "isPrivate": false,
491 "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-01-01T00:00:00Z",
492 }))
493 .unwrap()
494 }
495
496 fn event(kind: &str, repo: &str, time: &str, actor: &str, data: Value) -> Event {
497 Event {
498 id: format!("evt_{time}"),
499 kind: kind.into(),
500 source: "test".into(),
501 time: time.into(),
502 repo_id: Some(repo.into()),
503 actor: Some(actor.into()),
504 data,
505 }
506 }
507
508 #[test]
509 fn the_span_is_the_last_day_unless_given() {
510 let now = parse_rfc3339("2026-10-12T18:00:00Z").unwrap();
511 let (since, until) = span(&json!({}), now).unwrap();
512 assert_eq!(until, now);
513 assert_eq!(until - since, DEFAULT_SPAN_MS);
514 let (since, _) = span(&json!({ "since": "2026-10-12T00:00:00Z" }), now).unwrap();
515 assert_eq!(since, parse_rfc3339("2026-10-12T00:00:00Z").unwrap());
516 assert!(span(&json!({ "since": "yesterday" }), now).unwrap_err().contains("RFC 3339"));
517 assert!(span(&json!({ "since": "2026-10-13T00:00:00Z" }), now).is_err());
518 assert!(span(&json!({ "since": "2026-08-01T00:00:00Z" }), now).unwrap_err().contains("31 days"));
519 }
520
521 #[test]
522 fn kinds_name_their_events() {
523 assert_eq!(event_types(&json!({ "kinds": ["merged"] })).unwrap(), vec!["pull.merged"]);
524 let all = event_types(&json!({})).unwrap();
525 assert!(all.contains(&"pull.merged") && all.contains(&"git.push") && all.contains(&"release.published"));
526 assert!(!all.contains(&"comment.created"));
527 assert!(event_types(&json!({ "kinds": ["shipped"] })).unwrap_err().contains("Kinds: merged"));
528 // Over REST: one comma-separated text.
529 assert_eq!(event_types(&json!({ "kinds": "merged, releases" })).unwrap(), vec!["pull.merged", "release.published"]);
530 assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT);
531 assert_eq!(limit(&json!({ "limit": 5 })), 5);
532 assert_eq!(limit(&json!({})), DEFAULT_LIMIT);
533 }
534
535 #[test]
Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.536 fn what_landed_is_one_line_a_commit_made_in_the_span() {
537 use g1t_contracts::repos::Signature;
538 let commit = |hash: &str, message: &str, at: &str| Commit {
539 hash: hash.to_owned(),
540 tree_hash: String::new(),
541 message: message.to_owned(),
542 author: Signature { name: "Chase".into(), email: String::new() },
543 parents: Vec::new(),
544 authored_at: at.to_owned(),
545 };
546 let commits = [
547 commit("aaaaaaaaaaaaaaaa", "Ask reads g1t
548
549The long why.", "2026-10-13T05:00:00Z"),
550 commit("bbbbbbbbbbbbbbbb", "Yesterday's change", "2026-10-12T05:00:00Z"),
551 ];
552 let since = parse_rfc3339("2026-10-12T06:00:00Z").unwrap();
553 let until = parse_rfc3339("2026-10-13T06:00:00Z").unwrap();
554 let lines = landed("https://g1t.sh/", "acme/web", &commits, since, until);
555 assert_eq!(lines.len(), 1);
556 assert_eq!(lines[0]["subject"], "Ask reads g1t");
557 assert_eq!(lines[0]["commit"], "aaaaaaaaaaaa");
558 assert_eq!(lines[0]["author"], "Chase");
559 assert_eq!(lines[0]["url"], "https://g1t.sh/acme/web/commit/aaaaaaaaaaaaaaaa");
560 }
561
562 #[test]
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.563 fn what_shipped_reads_as_one_list_with_links_and_names() {
564 let repos = [repo("rep_1", "web"), repo("rep_2", "api")];
565 let events = [
566 event("deployment.succeeded", "rep_1", "2026-10-12T15:00:00Z", "usr_1", json!({ "kind": "production", "project": "web", "commit": "abcdef1234567890", "path": "/acme/web/deployments/dpl_1", "triggeredBy": "ana" })),
567 event("release.published", "rep_2", "2026-10-12T14:00:00Z", "usr_2", json!({ "tagName": "v1.2.0", "release": { "name": "Faster pushes" } })),
568 event("pull.merged", "rep_1", "2026-10-12T13:00:00Z", "usr_1", json!({ "number": 7, "issue": 3 })),
569 event("git.push", "rep_2", "2026-10-12T12:00:00Z", "usr_2", json!({ "ref": "refs/heads/main", "after": "0123456789abcdef", "defaultBranch": true, "commits": 2 })),
570 event("git.push", "rep_2", "2026-10-12T11:00:00Z", "usr_2", json!({ "ref": "refs/tags/v1.2.0", "after": "0123" })),
571 event("workflow.completed", "rep_1", "2026-10-12T11:30:00Z", "usr_1", json!({ "runId": "run_257", "workflow": "Deploy", "number": 257, "conclusion": "failure", "ref": "refs/heads/main", "sha": "fedcba9876543210" })),
572 event("issue.closed", "rep_1", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 3, "title": "Login fails", "reason": "completed", "resolvedBy": 7 })),
573 // Outside the span, and a repository the caller cannot read.
574 event("pull.merged", "rep_1", "2026-10-11T10:00:00Z", "usr_1", json!({ "number": 6 })),
575 event("pull.merged", "rep_9", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 1 })),
576 ];
577 let titles = HashMap::from([(("rep_1".to_owned(), 7), "Fix login".to_owned())]);
578 let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned()), ("usr_2".to_owned(), "bo".to_owned())]);
579 let shown = shape(&Found {
580 workspace: "acme",
581 site: "https://g1t.sh/",
582 since: parse_rfc3339("2026-10-12T00:00:00Z").unwrap(),
583 until: parse_rfc3339("2026-10-12T18:00:00Z").unwrap(),
584 repos: &repos,
585 events: &events,
586 more: false,
587 pull_titles: &titles,
588 usernames: &names,
589 });
590 assert_eq!(
591 shown["summary"],
592 json!({ "merged_pull_requests": 1, "releases": 1, "production_deployments": 1, "failed_deployments": 0, "default_branch_pushes": 1, "failed_runs": 1 })
593 );
594 let listed = shown["events"].as_array().unwrap();
595 assert_eq!(listed.len(), 6, "{listed:#?}");
596 assert_eq!(listed[0]["url"], "https://g1t.sh/acme/web/deployments/dpl_1");
597 assert_eq!(listed[0]["commit"], "abcdef123456");
598 assert_eq!(listed[1]["title"], "Faster pushes");
599 assert_eq!(listed[1]["url"], "https://g1t.sh/acme/api/releases/tag/v1.2.0");
600 assert_eq!(
601 listed[2],
602 json!({ "kind": "pull_merged", "time": "2026-10-12T13:00:00Z", "repo": "acme/web", "actor": "ana", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7", "issue": 3 })
603 );
604 assert_eq!(listed[3]["default_branch"], true);
605 assert_eq!(listed[3]["actor"], "bo");
606 assert_eq!(
607 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" })
609 );
610 assert_eq!(listed[5]["resolved_by"], 7);
611 assert_eq!(shown["complete"], true);
612 }
613}