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
279 linesCodeBlameRaw
1//! `g1t logs`: a project's log lines, or its errors grouped.
2
3use serde_json::Value;
4use std::time::Duration;
5
6use crate::cli::LogsArgs;
7use crate::client::Client;
8use crate::context::Ctx;
9use crate::error::{Error, Result};
10use crate::output::{Cell, Output, s};
11use crate::repo::Repo;
12
13/// `15m`, `2h`, `1d`, `1w` as Unix milliseconds that long ago; anything
14/// else (an RFC 3339 time) as it is.
15pub fn since(text: &str, now_ms: i64) -> String {
16 let text = text.trim();
17 let (number, unit) = text.split_at(
18 text.find(|c: char| !c.is_ascii_digit())
19 .unwrap_or(text.len()),
20 );
21 let secs = match unit {
22 "s" => 1,
23 "m" => 60,
24 "h" => 3600,
25 "d" => 86_400,
26 "w" => 604_800,
27 _ => return text.to_owned(),
28 };
29 match number.parse::<i64>() {
30 Ok(n) => (now_ms - n * secs * 1000).to_string(),
31 Err(_) => text.to_owned(),
32 }
33}
34
35/// `2026-10-12 09:01:02` (UTC) for microseconds since 1970.
36pub fn clock(time_us: i64) -> String {
37 let secs = time_us.div_euclid(1_000_000);
38 let days = secs.div_euclid(86_400);
39 let rem = secs.rem_euclid(86_400);
40 // Civil from days (Howard Hinnant's algorithm).
41 let z = days + 719_468;
42 let era = z.div_euclid(146_097);
43 let doe = z - era * 146_097;
44 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
45 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
46 let mp = (5 * doy + 2) / 153;
47 let d = doy - (153 * mp + 2) / 5 + 1;
48 let m = if mp < 10 { mp + 3 } else { mp - 9 };
49 let y = yoe + era * 400 + i64::from(m <= 2);
50 format!(
51 "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}",
52 rem / 3600,
53 (rem % 3600) / 60,
54 rem % 60
55 )
56}
57
58/// The workspace and project: --project (SLUG or WORKSPACE/SLUG), or the
59/// current repository's project.
60fn project(ctx: &Ctx, args: &LogsArgs) -> Result<(String, String, Client)> {
61 if let Some(p) = &args.project
62 && let Some((w, slug)) = p.split_once('/')
63 {
64 return Ok((w.to_owned(), slug.to_owned(), ctx.signed_in_client()?));
65 }
66 let (repo, client) = ctx.repo_client()?;
67 if let Some(slug) = &args.project {
68 return Ok((repo.owner.clone(), slug.clone(), client));
69 }
70 let (workspace, slug) = project_of(&client, &repo)?;
71 Ok((workspace, slug, client))
72}
73
74/// The project whose repository this is. A repository moved to another
75/// workspace answers to its old name, so the projects are looked up where
76/// it is now.
77fn project_of(client: &Client, repo: &Repo) -> Result<(String, String)> {
78 let found = client.op("get_repo").repo(repo).send()?;
79 let here = Repo {
80 host: repo.host.clone(),
81 owner: found["namespace"]
82 .as_str()
83 .unwrap_or(&repo.owner)
84 .to_owned(),
85 name: found["name"].as_str().unwrap_or(&repo.name).to_owned(),
86 };
87 let projects = super::items(
88 client
89 .op("list_projects")
90 .arg("workspace", &here.owner)
91 .send()?,
92 "projects",
93 );
94 let full = here.to_string();
95 let mine: Vec<&Value> = projects
96 .iter()
97 .filter(|p| s(p, "repository").eq_ignore_ascii_case(&full))
98 .collect();
99 let pick = mine.iter().find(|p| p["primary"] == true).or(mine.first());
100 match pick {
101 Some(p) => Ok((here.owner.clone(), s(p, "slug").to_owned())),
102 None => Err(Error::failed(format!(
103 "{here} has no project. Name one with --project WORKSPACE/SLUG."
104 ))),
105 }
106}
107
108fn print_lines(out: &Output, lines: &[Value]) -> Result<()> {
109 if !out.tty {
110 for l in lines {
111 let attrs = serde_json::to_string(&l["attributes"]).unwrap_or_default();
112 println!(
113 "{}\t{}\t{}\t{}\t{}",
114 clock(l["time_us"].as_i64().unwrap_or(0)),
115 s(l, "severity"),
116 s(l, "service"),
117 s(l, "body").replace('\n', "\\n"),
118 attrs
119 );
120 }
121 return Ok(());
122 }
123 for l in lines {
124 let sev = s(l, "severity");
125 let painted = match sev {
126 "error" | "fatal" => out.red(sev),
127 "warn" => out.yellow(sev),
128 "debug" | "trace" => out.dim(sev),
129 _ => out.cyan(sev),
130 };
131 let pad = " ".repeat(5usize.saturating_sub(sev.len()));
132 println!(
133 "{} {painted}{pad} {} {}",
134 out.dim(&clock(l["time_us"].as_i64().unwrap_or(0))),
135 out.dim(s(l, "service")),
136 s(l, "body")
137 );
138 }
139 Ok(())
140}
141
142pub fn run(ctx: &Ctx, args: LogsArgs) -> Result<()> {
143 let (workspace, slug, client) = project(ctx, &args)?;
144 let now_ms = crate::output::now() * 1000;
145 let from = args.since.as_deref().map(|t| since(t, now_ms));
146 if args.errors {
147 let found = client
148 .op("list_errors")
149 .arg("workspace", &workspace)
150 .arg("project", &slug)
151 .query_opt("state", args.state.clone())
152 .query_opt("environment", args.env.clone())
153 .query_opt("service", args.service.clone())
154 .query_opt("from", from)
155 .send()?;
156 let mut errors = super::items(found, "errors");
157 errors.truncate(args.limit);
158 if ctx.out.structured() {
159 return ctx.out.emit(&Value::Array(errors));
160 }
161 if errors.is_empty() {
162 eprintln!("No errors in {workspace}/{slug}.");
163 return Ok(());
164 }
165 let out = &ctx.out;
166 let rows: Vec<Vec<Cell>> = errors
167 .iter()
168 .map(|e| {
169 vec![
170 Cell::new(e["count"].to_string()),
171 Cell::styled(
172 format!("{}: {}", s(e, "name"), s(e, "message")),
173 format!("{}: {}", out.red(s(e, "name")), s(e, "message")),
174 ),
175 Cell::new(format!("{} {}", s(e, "service"), s(e, "environment"))),
176 Cell::styled(s(e, "state"), out.state(s(e, "state"))),
177 Cell::new(clock(e["last_us"].as_i64().unwrap_or(0))),
178 Cell::styled(s(e, "fingerprint"), out.dim(s(e, "fingerprint"))),
179 ]
180 })
181 .collect();
182 return out.table(
183 &[
184 "count",
185 "error",
186 "where",
187 "state",
188 "last seen",
189 "fingerprint",
190 ],
191 &rows,
192 );
193 }
194 let fetch = |from: Option<String>, limit: usize| -> Result<Vec<Value>> {
195 let found = client
196 .op("list_logs")
197 .arg("workspace", &workspace)
198 .arg("project", &slug)
199 .query_opt("q", args.query.clone())
200 .query_opt("level", args.level.clone())
201 .query_opt("environment", args.env.clone())
202 .query_opt("service", args.service.clone())
203 .query_opt("from", from)
204 .query("limit", limit.clamp(1, 1000))
205 .send()?;
206 Ok(super::items(found, "items"))
207 };
208 let mut lines = fetch(from, args.limit)?;
209 lines.truncate(args.limit);
210 // Newest first from the API; oldest first reads like a log.
211 lines.reverse();
212 if ctx.out.structured() && !args.follow {
213 return ctx.out.emit(&Value::Array(lines));
214 }
215 let emit = |batch: &[Value]| -> Result<()> {
216 if ctx.out.structured() {
217 for l in batch {
218 ctx.out.emit(l)?;
219 }
220 Ok(())
221 } else {
222 print_lines(&ctx.out, batch)
223 }
224 };
225 emit(&lines)?;
226 if !args.follow {
227 if lines.is_empty() && !ctx.out.structured() {
228 eprintln!(
229 "No log lines in {workspace}/{slug}{}.",
230 args.since
231 .as_deref()
232 .map(|s| format!(" in the last {s}"))
233 .unwrap_or_default()
234 );
235 }
236 return Ok(());
237 }
238 let mut last_us = lines
239 .iter()
240 .filter_map(|l| l["time_us"].as_i64())
241 .max()
242 .unwrap_or(now_ms * 1000);
243 let mut seen: std::collections::HashSet<String> =
244 lines.iter().map(|l| s(l, "id").to_owned()).collect();
245 loop {
246 std::thread::sleep(Duration::from_secs(2));
247 let mut batch = fetch(Some((last_us / 1000).to_string()), 200)?;
248 batch.retain(|l| seen.insert(s(l, "id").to_owned()));
249 batch.reverse();
250 if let Some(max) = batch.iter().filter_map(|l| l["time_us"].as_i64()).max() {
251 last_us = last_us.max(max);
252 }
253 emit(&batch)?;
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn spans_become_times() {
263 assert_eq!(
264 since("15m", 1_000_000_000),
265 (1_000_000_000 - 900_000).to_string()
266 );
267 assert_eq!(
268 since("2h", 10_000_000_000),
269 (10_000_000_000i64 - 7_200_000).to_string()
270 );
271 assert_eq!(since("2026-10-12T09:00:00Z", 0), "2026-10-12T09:00:00Z");
272 }
273
274 #[test]
275 fn clocks_read_in_utc() {
276 assert_eq!(clock(0), "1970-01-01 00:00:00");
277 assert_eq!(clock(1_790_877_851_000_000), "2026-10-01 18:04:11");
278 }
279}