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.
14 files+309−680/14 viewed
| 16 | 16 | ||
| 17 | 17 | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS}; | |
| 18 | 18 | use g1t_contracts::identity::UsernamesArgs; | |
| 19 | − | use g1t_contracts::repos::{ListArgs as ListReposArgs, Repo}; | |
| 19 | + | use g1t_contracts::repos::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath}; | |
| 20 | 20 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 21 | 21 | use g1t_contracts::work::{MAX_PULLS_FOR_REPOS, PullsForReposArgs, RepoPulls}; | |
| 22 | 22 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| ⋯ | |||
| 46 | 46 | ("runs", &["workflow.completed"]), | |
| 47 | 47 | ]; | |
| 48 | 48 | ||
| 49 | + | /// The most repositories whose default-branch commits are read, and the | |
| 50 | + | /// most commits that `landed` lists across them. | |
| 51 | + | const LANDED_REPOS: usize = 8; | |
| 52 | + | const LANDED_MAX: usize = 40; | |
| 53 | + | /// How far back each of those repositories' history is read. | |
| 54 | + | const LANDED_LOG: u32 = 60; | |
| 55 | + | ||
| 49 | 56 | /// What is read when no kinds are given: everything but comments, which | |
| 50 | 57 | /// would crowd out the rest on a busy day. | |
| 51 | 58 | const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"]; | |
| 52 | 59 | ||
| 53 | 60 | pub fn description() -> &'static str { | |
| 54 | − | "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. 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." | |
| 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." | |
| 55 | 62 | } | |
| 56 | 63 | ||
| 57 | 64 | pub fn input() -> Value { | |
| ⋯ | |||
| 314 | 321 | "default_branch_pushes": landed, | |
| 315 | 322 | "failed_runs": runs_failed, | |
| 316 | 323 | }, | |
| 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": [], | |
| 317 | 327 | "events": events, | |
| 318 | 328 | "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS, | |
| 319 | 329 | }) | |
| 320 | 330 | } | |
| 321 | 331 | ||
| 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. | |
| 335 | + | pub 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 | + | ||
| 322 | 354 | pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 323 | 355 | let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase(); | |
| 324 | 356 | if workspace.is_empty() { | |
| ⋯ | |||
| 408 | 440 | } | |
| 409 | 441 | } | |
| 410 | 442 | let usernames = names.unwrap_or_default(); | |
| 411 | − | Ok(Outcome::Ok(shape(&Found { | |
| 443 | + | let mut answer = shape(&Found { | |
| 412 | 444 | workspace: &workspace, | |
| 413 | 445 | site: &site, | |
| 414 | 446 | since, | |
| ⋯ | |||
| 418 | 450 | more, | |
| 419 | 451 | pull_titles: &pull_titles, | |
| 420 | 452 | usernames: &usernames, | |
| 421 | − | }))) | |
| 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)) | |
| 422 | 482 | } | |
| 423 | 483 | ||
| 424 | 484 | #[cfg(test)] | |
| ⋯ | |||
| 473 | 533 | } | |
| 474 | 534 | ||
| 475 | 535 | #[test] | |
| 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 | + | ||
| 549 | + | The 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] | |
| 476 | 563 | fn what_shipped_reads_as_one_list_with_links_and_names() { | |
| 477 | 564 | let repos = [repo("rep_1", "web"), repo("rep_2", "api")]; | |
| 478 | 565 | let events = [ | |
Binary or large file; its contents are not shown.
| 4 | 4 | ||
| 5 | 5 | import { type AskSetup, type ChatLiveEvent, type ChatMessage, type Result } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | − | import { askPage, askWhere, browserTimeZone } from "../lib/ask"; | |
| 7 | + | import { askPage, askWhere } from "../lib/ask"; | |
| 8 | + | import { browserTimeZone } from "../lib/time-zone"; | |
| 8 | 9 | import { anyAnswering, applyLive, fromG1t, suggestions } from "../lib/g1t-chat"; | |
| 9 | 10 | import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation"; | |
| 10 | 11 | import { Message, MessageContent } from "./ai-elements/message"; |
| 43 | 43 | import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence"; | |
| 44 | 44 | import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card"; | |
| 45 | 45 | ||
| 46 | − | import { localTime } from "../../lib/time-zone"; | |
| 46 | + | import { browserTimeZone, localTime } from "../../lib/time-zone"; | |
| 47 | 47 | import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile"; | |
| 48 | 48 | import { MessageText, type TextContext } from "./text"; | |
| 49 | 49 | // @g1t's answers and its direct message (lib/g1t-chat.ts, components/chat/answer.tsx). | |
| ⋯ | |||
| 467 | 467 | setMessages(put); | |
| 468 | 468 | } | |
| 469 | 469 | messageSent({ dnd: dndNow() }); | |
| 470 | − | const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root }); | |
| 470 | + | // The browser's time zone rides along: g1t keeps it only on a question to @g1t, for what "today" means. | |
| 471 | + | const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root, page: { time_zone: browserTimeZone() } }); | |
| 471 | 472 | const settle = (list: ShownMessage[]) => | |
| 472 | 473 | saved.ok | |
| 473 | 474 | ? mergeMessages(list, [{ ...saved.value, client_id: clientId }]) | |
| 26 | 26 | return page; | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | − | /** The browser's time zone, sent with a question so "today" is the asker's today. */ | |
| 30 | − | export function browserTimeZone(): string | null { | |
| 31 | − | try { | |
| 32 | − | return Intl.DateTimeFormat().resolvedOptions().timeZone || null; | |
| 33 | − | } catch { | |
| 34 | − | return null; | |
| 35 | − | } | |
| 36 | − | } | |
| 37 | − | ||
| 38 | 29 | /** What the composer says about where the question is asked from. */ | |
| 39 | 30 | export function askWhere(page: AskPage): string | null { | |
| 40 | 31 | if (page.issue) return `On issue #${page.issue}`; |
| 165 | 165 | // --- Ask ------------------------------------------------------------------ | |
| 166 | 166 | ||
| 167 | 167 | /** Event types the bench knows. Anything else is recorded as unknown, never dropped. */ | |
| 168 | − | const KNOWN = new Set(["meta", "text", "done", "error"]); | |
| 168 | + | const KNOWN = new Set(["meta", "text", "done", "error", "tool", "sources", "proposal"]); | |
| 169 | 169 | ||
| 170 | 170 | /** Looks like a tool step: `tool`, `tool_call`, `tool_result`, `step`, … */ | |
| 171 | 171 | export const isToolEvent = (event) => /tool|step|action|call/i.test(String(event?.type ?? "")); | |
| ⋯ | |||
| 182 | 182 | return found; | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | + | /** A chat API call on the site as the user: the `{ ok, value | error }` it answers. */ | |
| 186 | + | async function chatApi(workspace, body) { | |
| 187 | + | const response = await site(`/${workspace}/-/chat/api`, { | |
| 188 | + | method: "POST", | |
| 189 | + | headers: { "content-type": "application/json", origin: SITE }, | |
| 190 | + | body: JSON.stringify(body), | |
| 191 | + | }); | |
| 192 | + | const text = await response.text(); | |
| 193 | + | try { | |
| 194 | + | return JSON.parse(text); | |
| 195 | + | } catch { | |
| 196 | + | return { ok: false, error: { message: `chat api ${response.status}: ${text.slice(0, 200)}` } }; | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | async function chatMessages(workspace, channel) { | |
| 201 | + | const response = await site(`/${workspace}/-/chat/api?channel=${encodeURIComponent(channel)}&limit=60`); | |
| 202 | + | const data = await response.json().catch(() => null); | |
| 203 | + | return data?.value?.messages ?? []; | |
| 204 | + | } | |
| 205 | + | ||
| 185 | 206 | /** | |
| 186 | − | * Asks once, as the user, and reads the whole answer: every event with the | |
| 187 | − | * time it came, the time to the first text, the total, the tool steps and | |
| 188 | − | * sources it reported, and the text. | |
| 207 | + | * Asks once, as the user, the way Ask and Chat both do: in the user's | |
| 208 | + | * direct message with @g1t, after a New conversation mark, posting each of | |
| 209 | + | * the conversation's questions in turn and following g1t's answer to each | |
| 210 | + | * as it streams (polled). The last answer is the one graded: its text, the | |
| 211 | + | * time to its first words and to its end, and the steps and sources it kept, | |
| 212 | + | * as events in the order the stream gave them. | |
| 189 | 213 | */ | |
| 190 | 214 | export async function ask(workspace, { messages, page }, { timeoutMs = 180_000 } = {}) { | |
| 191 | 215 | const started = performance.now(); | |
| ⋯ | |||
| 205 | 229 | source_events: [], | |
| 206 | 230 | source_urls: [], | |
| 207 | 231 | }; | |
| 208 | − | const controller = new AbortController(); | |
| 209 | − | const timer = setTimeout(() => controller.abort(), timeoutMs); | |
| 210 | 232 | try { | |
| 211 | − | const response = await site(`/${workspace}/-/ask`, { | |
| 212 | − | method: "POST", | |
| 213 | − | headers: { "content-type": "application/json", accept: "application/x-ndjson" }, | |
| 214 | − | body: JSON.stringify({ messages, page: page ?? {} }), | |
| 215 | − | signal: controller.signal, | |
| 216 | − | }); | |
| 217 | − | result.status = response.status; | |
| 218 | − | if (!response.body) throw new Error(`no body (${response.status})`); | |
| 219 | − | const decoder = new TextDecoder(); | |
| 220 | − | let pending = ""; | |
| 221 | − | const take = (line) => { | |
| 222 | − | if (!line.trim()) return; | |
| 223 | − | let event; | |
| 224 | − | try { | |
| 225 | − | event = JSON.parse(line); | |
| 226 | − | } catch { | |
| 227 | − | event = { type: "_unparsed", line: line.slice(0, 500) }; | |
| 233 | + | const dm = await chatApi(workspace, { intent: "g1t_dm" }); | |
| 234 | + | if (!dm.ok) throw new Error(dm.error?.message ?? "could not open the direct message with g1t"); | |
| 235 | + | const channel = dm.value.channel.id; | |
| 236 | + | await chatApi(workspace, { intent: "g1t_reset", channel_id: channel }); | |
| 237 | + | const questions = messages.filter((m) => m.role === "user"); | |
| 238 | + | let answer = null; | |
| 239 | + | for (const [index, question] of questions.entries()) { | |
| 240 | + | const last = index === questions.length - 1; | |
| 241 | + | const asked = performance.now(); | |
| 242 | + | const posted = await chatApi(workspace, { intent: "post", channel_id: channel, body: question.content, page: last ? (page ?? null) : null }); | |
| 243 | + | result.status = posted.ok ? 200 : 400; | |
| 244 | + | if (!posted.ok) throw new Error(posted.error?.message ?? "could not post"); | |
| 245 | + | const mine = posted.value.id; | |
| 246 | + | answer = null; | |
| 247 | + | while (performance.now() - asked < timeoutMs) { | |
| 248 | + | await sleep(700); | |
| 249 | + | const list = await chatMessages(workspace, channel); | |
| 250 | + | answer = list.find((m) => m.id > mine && m.author?.kind === "system" && m.answer) ?? null; | |
| 251 | + | if (last && answer?.body && result.first_text_ms === null) result.first_text_ms = at(); | |
| 252 | + | if (last && answer && result.first_event_ms === null) result.first_event_ms = at(); | |
| 253 | + | if (answer && ["done", "error", "stopped"].includes(answer.answer.state)) break; | |
| 228 | 254 | } | |
| 229 | − | const ms = at(); | |
| 230 | − | result.first_event_ms ??= ms; | |
| 231 | − | result.events.push({ ms, event }); | |
| 232 | − | const type = String(event?.type ?? ""); | |
| 255 | + | if (!answer || !["done", "error", "stopped"].includes(answer.answer?.state)) throw new Error(`timed out after ${timeoutMs} ms`); | |
| 256 | + | } | |
| 257 | + | const kept = answer.answer; | |
| 258 | + | result.meta = { type: "meta", provider_label: kept.provider_label, model: kept.model }; | |
| 259 | + | result.events.push({ ms: 0, event: result.meta }); | |
| 260 | + | for (const step of kept.steps ?? []) { | |
| 261 | + | const type = String(step?.type ?? ""); | |
| 262 | + | result.events.push({ ms: null, event: step }); | |
| 233 | 263 | if (!KNOWN.has(type) && !result.unknown_types.includes(type)) result.unknown_types.push(type); | |
| 234 | − | if (type === "meta") result.meta = event; | |
| 235 | − | if (type === "text" && typeof event.text === "string") { | |
| 236 | − | result.first_text_ms ??= ms; | |
| 237 | − | result.text += event.text; | |
| 238 | − | } | |
| 239 | − | if (type === "done") result.done = event; | |
| 240 | − | if (type === "error") result.error = event.message ?? "error"; | |
| 241 | − | if (!KNOWN.has(type) && isToolEvent(event)) result.tool_events.push({ ms, event }); | |
| 242 | − | if (!KNOWN.has(type) && isSourceEvent(event)) result.source_events.push({ ms, event }); | |
| 243 | − | }; | |
| 244 | − | for await (const chunk of response.body) { | |
| 245 | − | pending += decoder.decode(chunk, { stream: true }); | |
| 246 | − | const lines = pending.split("\n"); | |
| 247 | − | pending = lines.pop() ?? ""; | |
| 248 | − | lines.forEach(take); | |
| 264 | + | if (isToolEvent(step)) result.tool_events.push({ ms: null, event: step }); | |
| 265 | + | if (isSourceEvent(step)) result.source_events.push({ ms: null, event: step }); | |
| 249 | 266 | } | |
| 250 | − | take(pending); | |
| 267 | + | result.text = answer.body ?? ""; | |
| 268 | + | result.events.push({ ms: at(), event: { type: "text", text: result.text } }); | |
| 269 | + | if (kept.state === "done") result.done = { type: "done", stop_reason: kept.stop_reason ?? null }; | |
| 270 | + | else result.error = kept.error ?? kept.state; | |
| 251 | 271 | } catch (error) { | |
| 252 | − | result.error = controller.signal.aborted ? `timed out after ${timeoutMs} ms` : String(error?.message ?? error); | |
| 272 | + | result.error = String(error?.message ?? error); | |
| 253 | 273 | } finally { | |
| 254 | − | clearTimeout(timer); | |
| 255 | 274 | result.total_ms = at(); | |
| 256 | 275 | } | |
| 257 | 276 | const urls = new Set(); | |
| 524 | 524 | pub issue: Option<u32>, | |
| 525 | 525 | #[serde(default)] | |
| 526 | 526 | pub pull: Option<u32>, | |
| 527 | + | /// The browser's IANA time zone, such as `America/Denver`: what | |
| 528 | + | /// "today" means when their profile names none. | |
| 529 | + | #[serde(default)] | |
| 530 | + | pub time_zone: Option<String>, | |
| 527 | 531 | } | |
| 528 | 532 | ||
| 529 | 533 | /// `ask`: one answer, streamed as newline-delimited JSON [`AskEvent`]s: |
| 161 | 161 | repo?: RepoPath | null; | |
| 162 | 162 | issue?: number | null; | |
| 163 | 163 | pull?: number | null; | |
| 164 | + | /** The browser's IANA time zone, such as `America/Denver`: what "today" means when their profile names none. */ | |
| 165 | + | time_zone?: string | null; | |
| 164 | 166 | }; | |
| 165 | 167 | ||
| 166 | 168 | /** The most rounds of tool calls one answer takes before it must answer. */ |
| 134 | 134 | repo: { namespace: "acme", name: "web" }, | |
| 135 | 135 | issue: null, | |
| 136 | 136 | pull: null, | |
| 137 | + | time_zone: null, | |
| 137 | 138 | }); | |
| 139 | + | // The browser's time zone is kept when it is one; anything else is dropped. | |
| 140 | + | assert.equal(pageFrom({ path: "/acme", time_zone: "America/Denver" })?.time_zone, "America/Denver"); | |
| 141 | + | assert.equal(pageFrom({ path: "/acme", time_zone: "<script>" })?.time_zone, null); | |
| 142 | + | assert.deepEqual(pageFrom({ time_zone: "Asia/Kolkata" }), { path: null, repo: null, issue: null, pull: null, time_zone: "Asia/Kolkata" }); | |
| 138 | 143 | assert.equal(pageFrom({ path: "https://elsewhere" }), null); | |
| 139 | 144 | assert.equal(pageFrom("nope"), null); | |
| 140 | 145 | assert.equal(metaColumn({}), null); |
| 156 | 156 | : null, | |
| 157 | 157 | issue: number(sent.issue), | |
| 158 | 158 | pull: number(sent.pull), | |
| 159 | + | time_zone: typeof sent.time_zone === "string" && /^[A-Za-z][A-Za-z0-9_+\-]*(\/[A-Za-z0-9_+\-]+){0,2}$/.test(sent.time_zone) && sent.time_zone.length <= 64 ? sent.time_zone : null, | |
| 159 | 160 | }; | |
| 160 | − | return page.path || page.repo ? page : null; | |
| 161 | + | return page.path || page.repo || page.time_zone ? page : null; | |
| 161 | 162 | } | |
| 162 | 163 | ||
| 163 | 164 | /** What g1t says when nobody chose an AI provider for Ask yet: once, and where to choose one. */ |
| 1241 | 1241 | channel: { id: channel.id, kind: channel.kind, name: channel.name }, | |
| 1242 | 1242 | actor: viewer, | |
| 1243 | 1243 | messages, | |
| 1244 | − | page: latestPage(rows) ?? { path: `/${place.slug}/-/chat/dm/${channel.id}` }, | |
| 1244 | + | // The page of the newest question; one asked in Chat is on the direct message itself. | |
| 1245 | + | page: { ...latestPage(rows), path: latestPage(rows)?.path ?? `/${place.slug}/-/chat/dm/${channel.id}` }, | |
| 1245 | 1246 | message: written.value, | |
| 1246 | 1247 | }); | |
| 1247 | 1248 | } |
| 273 | 273 | pub facts: &'a [Fact], | |
| 274 | 274 | /// Whether g1t's tools could be offered this time. | |
| 275 | 275 | pub tools: bool, | |
| 276 | + | /// Their time zone, their date there, and when that day began (UTC): | |
| 277 | + | /// what "today" means to them. None when their profile names no zone. | |
| 278 | + | pub local: Option<(&'a str, &'a str, &'a str)>, | |
| 276 | 279 | } | |
| 277 | 280 | ||
| 278 | 281 | /// What the provider is told before the conversation. | |
| 279 | 282 | pub fn system_prompt(context: &Context) -> String { | |
| 280 | − | let Context { slug, name, username, site, today, now, page, facts, tools } = *context; | |
| 283 | + | let Context { slug, name, username, site, today, now, page, facts, tools, local } = *context; | |
| 281 | 284 | let site = site.trim_end_matches('/'); | |
| 282 | 285 | let named = if name.is_empty() || name.eq_ignore_ascii_case(slug) { | |
| 283 | 286 | slug.to_owned() | |
| ⋯ | |||
| 288 | 291 | "You are g1t, the assistant built into g1t ({site}), where teams keep their repositories, issues, pull \ | |
| 289 | 292 | requests, workflows, deployments, releases and docs. You are answering @{username}, a member of the \ | |
| 290 | 293 | workspace {named}, and you act for them: you can read what they can read in g1t, and nothing more.\n\n\ | |
| 291 | − | Today is {today} (UTC); it is now {now}. \"Today\" means since {today}T00:00:00Z unless they say otherwise." | |
| 294 | + | It is now {now} (UTC)." | |
| 292 | 295 | ); | |
| 296 | + | match local { | |
| 297 | + | Some((zone, date, started)) => prompt.push_str(&format!( | |
| 298 | + | " They are in {zone}, where it is {date}: \"today\" means since {started} unless they say otherwise, and \"yesterday\" the day before that. Give times in their zone where it helps." | |
| 299 | + | )), | |
| 300 | + | None => prompt.push_str(&format!(" Today is {today} (UTC): \"today\" means since {today}T00:00:00Z unless they say otherwise.")), | |
| 301 | + | } | |
| 293 | 302 | if tools { | |
| 294 | 303 | prompt.push_str(&format!( | |
| 295 | 304 | "\n\n## Reading g1t\n\ | |
| ⋯ | |||
| 299 | 308 | - For anything across the workspace (what shipped, what happened today or this week, who did what, \ | |
| 300 | 309 | what is in flight), start with the workspace tool's `activity` action with workspace \"{slug}\" and a \ | |
| 301 | 310 | `since`: one call covers every repository they can read. \"What shipped\" means merged pull requests, \ | |
| 302 | − | releases, production deployments and pushes to a default branch.\n\ | |
| 311 | + | releases, production deployments and pushes to a default branch; its `landed` lists the commits those \ | |
| 312 | + | pushes carried, one line each, so say what changed (group related commits, lead with what matters to \ | |
| 313 | + | people), not only how many. Pass `since` as the start of their today for \"today\". If `complete` is \ | |
| 314 | + | false, narrow `kinds` (such as merged, releases, deployments, pushes) or the span and read again rather \ | |
| 315 | + | than guessing.\n\ | |
| 303 | 316 | - To find a repository, use the repository tool's `list` or the search tool. Repositories are \ | |
| 304 | 317 | \"owner/name\", here usually \"{slug}/…\".\n\ | |
| 305 | 318 | - For one pull request, read it with pull_request `get` (status, checks, reviews) and `changes` for \ | |
| ⋯ | |||
| 901 | 914 | page, | |
| 902 | 915 | facts, | |
| 903 | 916 | tools, | |
| 917 | + | local: None, | |
| 904 | 918 | } | |
| 905 | 919 | } | |
| 906 | 920 | ||
| ⋯ | |||
| 911 | 925 | repo: Some(RepoPath { namespace: "acme".into(), name: "web".into() }), | |
| 912 | 926 | issue: Some(12), | |
| 913 | 927 | pull: None, | |
| 928 | + | time_zone: None, | |
| 914 | 929 | }; | |
| 915 | 930 | let facts = [Fact { kind: "issue", number: 12, title: "Login fails".into(), state: "open".into(), body: "Steps…".into() }]; | |
| 916 | 931 | let prompt = system_prompt(&context(&page, &facts, true)); | |
| ⋯ | |||
| 936 | 951 | assert!(!bare.contains("## Where they are")); | |
| 937 | 952 | assert!(bare.contains("could not be reached")); | |
| 938 | 953 | assert!(!bare.contains("## Reading g1t")); | |
| 954 | + | // In their own zone, "today" began at their midnight. | |
| 955 | + | let local = system_prompt(&Context { | |
| 956 | + | local: Some(("America/Denver", "2026-10-12", "2026-10-12T06:00:00Z")), | |
| 957 | + | ..context(&AskPage::default(), &[], true) | |
| 958 | + | }); | |
| 959 | + | assert!(local.contains("They are in America/Denver, where it is 2026-10-12: \"today\" means since 2026-10-12T06:00:00Z")); | |
| 960 | + | assert!(!local.contains("(UTC): \"today\"")); | |
| 939 | 961 | } | |
| 940 | 962 | ||
| 941 | 963 | #[test] | |
| 16 | 16 | mod github; | |
| 17 | 17 | mod github_jwt; | |
| 18 | 18 | mod http; | |
| 19 | + | mod local_day; | |
| 19 | 20 | mod models; | |
| 20 | 21 | mod refs; | |
| 21 | 22 | mod rename; | |
| ⋯ | |||
| 23 | 24 | mod trackers; | |
| 24 | 25 | ||
| 25 | 26 | use g1t_contracts::events::Event; | |
| 26 | − | use g1t_contracts::identity::{AskToken, CreateAskTokenArgs, RevokeAskTokenArgs, SlugArgs, Workspace}; | |
| 27 | + | use g1t_contracts::identity::{AskToken, CreateAskTokenArgs, Profile, RevokeAskTokenArgs, SlugArgs, UsernameArgs, Workspace}; | |
| 27 | 28 | use g1t_contracts::integrations::*; | |
| 28 | 29 | use g1t_contracts::repos::RepoPath; | |
| 29 | 30 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 710 | 711 | let provider = row.provider(); | |
| 711 | 712 | let config = row.config(); | |
| 712 | 713 | let key = self.secrets(&row).secret; | |
| 713 | − | let (named, facts, credential) = futures_util::future::join3( | |
| 714 | + | let (named, facts, credential, profile) = futures_util::future::join4( | |
| 714 | 715 | g1t_kit::call::<_, Option<Workspace>>(&self.identity, "get_workspace", &SlugArgs { slug: workspace.clone() }), | |
| 715 | 716 | self.ask_facts(&a.actor, &a.page), | |
| 716 | 717 | g1t_kit::call::<_, Outcome<AskToken>>( | |
| ⋯ | |||
| 718 | 719 | "create_ask_token", | |
| 719 | 720 | &CreateAskTokenArgs { actor: a.actor.clone(), workspace: workspace.clone() }, | |
| 720 | 721 | ), | |
| 722 | + | // Their time zone, for what "today" means to them. | |
| 723 | + | g1t_kit::call::<_, Option<Profile>>(&self.identity, "profile", &UsernameArgs { username: a.actor.username.clone() }), | |
| 721 | 724 | ) | |
| 722 | 725 | .await; | |
| 723 | 726 | let name = named.ok().flatten().map(|found| found.name).unwrap_or_default(); | |
| ⋯ | |||
| 746 | 749 | let now = rfc3339(started); | |
| 747 | 750 | let today: String = now.chars().take(10).collect(); | |
| 748 | 751 | let now: String = format!("{}Z", now.chars().take(19).collect::<String>()); | |
| 752 | + | // Their profile's zone first, else the one their browser sent. | |
| 753 | + | let zone = profile | |
| 754 | + | .ok() | |
| 755 | + | .flatten() | |
| 756 | + | .and_then(|profile| profile.timezone) | |
| 757 | + | .or_else(|| a.page.time_zone.clone()) | |
| 758 | + | .filter(|zone| !zone.trim().is_empty() && zone.len() <= 64); | |
| 759 | + | let day = zone.as_deref().and_then(|zone| local_day::today(zone, started)); | |
| 760 | + | let day_started = day.as_ref().map(local_day::started_at); | |
| 761 | + | let local = match (&zone, &day, &day_started) { | |
| 762 | + | (Some(zone), Some(day), Some(started)) => Some((zone.as_str(), day.date.as_str(), started.as_str())), | |
| 763 | + | _ => None, | |
| 764 | + | }; | |
| 749 | 765 | let username = a.actor.display_username.clone().unwrap_or_else(|| a.actor.username.clone()); | |
| 750 | 766 | let system = ask::system_prompt(&ask::Context { | |
| 751 | 767 | slug: &workspace, | |
| ⋯ | |||
| 757 | 773 | page: &a.page, | |
| 758 | 774 | facts: &facts, | |
| 759 | 775 | tools: !toolbox.is_empty(), | |
| 776 | + | local, | |
| 760 | 777 | }); | |
| 761 | 778 | let mut answering = ask_loop::Answering::new( | |
| 762 | 779 | provider, | |
| 1 | + | //! When the person asking's day began, in their own time zone (their | |
| 2 | + | //! profile's IANA name, such as `America/Denver`): what "today" means in a | |
| 3 | + | //! question to Ask. The runtime's `Intl` says the wall-clock time there; | |
| 4 | + | //! the rest is arithmetic, tested on its own. | |
| 5 | + | ||
| 6 | + | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 7 | + | ||
| 8 | + | /// Their day: its date, and when it began as UTC milliseconds. | |
| 9 | + | #[derive(Debug, PartialEq, Eq)] | |
| 10 | + | pub struct Day { | |
| 11 | + | pub date: String, | |
| 12 | + | pub started: u64, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /// The day in `zone` at `now`, or none when the zone is unknown. | |
| 16 | + | pub fn today(zone: &str, now: u64) -> Option<Day> { | |
| 17 | + | from_wallclock(&wallclock(zone, now)?, now) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// From the wall-clock time in the zone at `now`, written | |
| 21 | + | /// `MM/DD/YYYY, HH:MM:SS` (en-US, 24-hour), the day it is there and when it | |
| 22 | + | /// began. | |
| 23 | + | pub fn from_wallclock(text: &str, now: u64) -> Option<Day> { | |
| 24 | + | let (date, time) = text.split_once(", ")?; | |
| 25 | + | let mut day = date.split('/'); | |
| 26 | + | let (month, day_of, year) = (day.next()?, day.next()?, day.next()?); | |
| 27 | + | let date = format!("{year:0>4}-{month:0>2}-{day_of:0>2}"); | |
| 28 | + | let wall = parse_rfc3339(&format!("{date}T{}Z", time.trim()))?; | |
| 29 | + | // The zone's offset from UTC, to the minute. | |
| 30 | + | let offset = (wall as i64 - now as i64) / 60_000 * 60_000; | |
| 31 | + | let midnight = parse_rfc3339(&format!("{date}T00:00:00Z"))? as i64; | |
| 32 | + | let started = u64::try_from(midnight - offset).ok()?; | |
| 33 | + | Some(Day { date, started }) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// The day's start as RFC 3339, to the second. | |
| 37 | + | pub fn started_at(day: &Day) -> String { | |
| 38 | + | format!("{}Z", rfc3339(day.started).chars().take(19).collect::<String>()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[cfg(target_arch = "wasm32")] | |
| 42 | + | fn wallclock(zone: &str, now: u64) -> Option<String> { | |
| 43 | + | use worker::js_sys::{Array, Date, Function, Object, Reflect, global}; | |
| 44 | + | use worker::wasm_bindgen::{JsCast, JsValue}; | |
| 45 | + | let options = Object::new(); | |
| 46 | + | for (key, value) in [ | |
| 47 | + | ("timeZone", zone), | |
| 48 | + | ("hourCycle", "h23"), | |
| 49 | + | ("year", "numeric"), | |
| 50 | + | ("month", "2-digit"), | |
| 51 | + | ("day", "2-digit"), | |
| 52 | + | ("hour", "2-digit"), | |
| 53 | + | ("minute", "2-digit"), | |
| 54 | + | ("second", "2-digit"), | |
| 55 | + | ] { | |
| 56 | + | Reflect::set(&options, &key.into(), &value.into()).ok()?; | |
| 57 | + | } | |
| 58 | + | // Constructed through Reflect so an unknown zone is an error, not a trap. | |
| 59 | + | let intl = Reflect::get(&global(), &"Intl".into()).ok()?; | |
| 60 | + | let class: Function = Reflect::get(&intl, &"DateTimeFormat".into()).ok()?.dyn_into().ok()?; | |
| 61 | + | let formatter = Reflect::construct(&class, &Array::of2(&"en-US".into(), &options)).ok()?; | |
| 62 | + | let format: Function = Reflect::get(&formatter, &"format".into()).ok()?.dyn_into().ok()?; | |
| 63 | + | format.call1(&formatter, &Date::new(&JsValue::from_f64(now as f64))).ok()?.as_string() | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #[cfg(not(target_arch = "wasm32"))] | |
| 67 | + | fn wallclock(_zone: &str, _now: u64) -> Option<String> { | |
| 68 | + | None | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | #[cfg(test)] | |
| 72 | + | mod tests { | |
| 73 | + | use super::*; | |
| 74 | + | ||
| 75 | + | #[test] | |
| 76 | + | fn a_day_begins_at_local_midnight() { | |
| 77 | + | // 05:20 UTC on the 13th is 23:20 on the 12th in Denver (UTC−6). | |
| 78 | + | let now = parse_rfc3339("2026-10-13T05:20:00Z").unwrap(); | |
| 79 | + | let day = from_wallclock("10/12/2026, 23:20:00", now).unwrap(); | |
| 80 | + | assert_eq!(day.date, "2026-10-12"); | |
| 81 | + | assert_eq!(started_at(&day), "2026-10-12T06:00:00Z"); | |
| 82 | + | // East of UTC: 05:20 UTC is 14:20 in Tokyo (UTC+9), whose day began at 15:00 UTC the day before. | |
| 83 | + | let day = from_wallclock("10/13/2026, 14:20:00", now).unwrap(); | |
| 84 | + | assert_eq!((day.date.as_str(), started_at(&day).as_str()), ("2026-10-13", "2026-10-12T15:00:00Z")); | |
| 85 | + | // Half-hour zones keep their minutes: India (UTC+5:30). | |
| 86 | + | let day = from_wallclock("10/13/2026, 10:50:00", now).unwrap(); | |
| 87 | + | assert_eq!(started_at(&day), "2026-10-12T18:30:00Z"); | |
| 88 | + | assert!(from_wallclock("nonsense", now).is_none()); | |
| 89 | + | } | |
| 90 | + | } |