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
301 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.

The runner base keeps its old comment, so the pushed base image still matches its folder1//! Sentry: issues it reports become g1t issues, the latest event's stack
2//! trace goes with them, and the Sentry issue is resolved when the fix
3//! lands.
4//!
5//! Connected as a Sentry internal integration: its webhook points at the
6//! connection's address and is signed with the integration's client
7//! secret; its token reads events and resolves issues.
8
9use g1t_contracts::integrations::{ConnectionConfig, ContextItem, Provider};
10use g1t_contracts::time::rfc3339;
11use g1t_kit::now_ms;
12use serde_json::{Value, json};
13use worker::{Method, Result};
14
15use crate::alerts::{Action, Signal};
16use crate::http::{self, Answer};
17
18fn site(config: &ConnectionConfig) -> String {
19 config
20 .site
21 .as_deref()
22 .unwrap_or("https://sentry.io")
23 .trim_end_matches('/')
24 .to_owned()
25}
26
27async fn call(config: &ConnectionConfig, token: &str, method: Method, path: &str, body: Option<Value>) -> Result<Answer> {
28 let auth = format!("Bearer {token}");
29 http::send(
30 method,
31 &format!("{}/api/0{path}", site(config)),
32 &[("authorization", &auth)],
33 body.map(|body| body.to_string()),
34 )
35 .await
36}
37
38fn text(value: &Value) -> Option<String> {
39 match value {
40 Value::String(text) if !text.is_empty() => Some(text.clone()),
41 Value::Number(number) => Some(number.to_string()),
42 _ => None,
43 }
44}
45
46/// The address people open the issue at.
47fn web_url(config: &ConnectionConfig, issue: &Value, id: &str) -> String {
48 text(&issue["web_url"])
49 .or_else(|| text(&issue["permalink"]))
50 .unwrap_or_else(|| match &config.organization {
51 Some(org) => format!("https://{org}.sentry.io/issues/{id}/"),
52 None => format!("{}/issues/{id}/", site(config)),
53 })
54}
55
56/// What a webhook from Sentry asks for, or why it asks for nothing.
57pub fn signal(resource: &str, payload: &Value, config: &ConnectionConfig) -> std::result::Result<Signal, String> {
58 let action = payload["action"].as_str().unwrap_or_default();
59 let event = format!("{resource}.{action}");
60 let (issue, act) = match (resource, action) {
61 ("issue", "created") => (&payload["data"]["issue"], Action::Open),
62 // A regression: Sentry saw it again after it was resolved.
63 ("issue", "unresolved") => (&payload["data"]["issue"], Action::Reopen),
64 ("event_alert", "triggered") => (&payload["data"]["event"], Action::Open),
65 ("installation", _) => return Err("Sentry installed the integration.".to_owned()),
66 _ => return Err(format!("g1t does nothing with {event}.")),
67 };
68 let id = text(&issue["id"])
69 .filter(|_| resource == "issue")
70 .or_else(|| text(&issue["issue_id"]))
71 .ok_or_else(|| "The payload names no issue.".to_owned())?;
72 let title = text(&issue["title"]).unwrap_or_else(|| "A problem Sentry reported".to_owned());
73 let key = text(&issue["shortId"]).unwrap_or_else(|| format!("Sentry {id}"));
74 let mut facts = vec![format!("**Sentry** · [{key}]({})", web_url(config, issue, &id))];
75 if let Some(level) = text(&issue["level"]) {
76 facts.push(level);
77 }
78 let count = text(&issue["count"]).and_then(|count| count.parse::<u32>().ok());
79 if let Some(count) = count {
80 facts.push(format!("{count} {}", if count == 1 { "event" } else { "events" }));
81 }
82 if let Some(users) = issue["userCount"].as_u64().filter(|users| *users > 0) {
83 facts.push(format!("{users} {}", if users == 1 { "user" } else { "users" }));
84 }
85 let mut body = vec![facts.join(" · ")];
86 if let Some(project) = text(&issue["project"]["slug"]).or_else(|| text(&issue["project"])) {
87 body.push(format!("Project `{project}`."));
88 }
89 let what = [text(&issue["metadata"]["type"]), text(&issue["metadata"]["value"])]
90 .into_iter()
91 .flatten()
92 .collect::<Vec<_>>()
93 .join(": ");
94 if !what.is_empty() {
95 body.push(format!("> {}", http::shorten(&what, 600).replace('\n', "\n> ")));
96 }
97 if let Some(culprit) = text(&issue["culprit"]) {
98 body.push(format!("In `{culprit}`."));
99 }
100 // An alert carries its event, and with it the stack trace.
101 if let Some(trace) = stack_trace(issue) {
102 body.push(trace);
103 }
104 Ok(Signal {
105 event,
106 action: act,
107 external_id: id,
108 key,
109 title,
110 url: web_url(config, issue, &text(&issue["id"]).unwrap_or_default()),
111 body: body.join("\n\n"),
112 count,
113 })
114}
115
116/// The frames of an event's exceptions, newest last, as a code block.
117/// Takes both the API's form (`entries`) and the raw event's
118/// (`exception.values`).
119pub fn stack_trace(event: &Value) -> Option<String> {
120 let values: Vec<&Value> = event["entries"]
121 .as_array()
122 .and_then(|entries| entries.iter().find(|entry| entry["type"] == "exception"))
123 .and_then(|entry| entry["data"]["values"].as_array())
124 .or_else(|| event["exception"]["values"].as_array())
125 .map(|values| values.iter().collect())
126 .unwrap_or_default();
127 let mut lines = Vec::new();
128 for value in values {
129 let frames = value["stacktrace"]["frames"].as_array().cloned().unwrap_or_default();
130 let in_app: Vec<&Value> = frames
131 .iter()
132 .filter(|frame| frame["inApp"].as_bool().or(frame["in_app"].as_bool()) == Some(true))
133 .collect();
134 let shown: Vec<&Value> = if in_app.is_empty() { frames.iter().collect() } else { in_app };
135 let skip = shown.len().saturating_sub(12);
136 if let Some(kind) = text(&value["type"]) {
137 lines.push(format!("{kind}: {}", text(&value["value"]).unwrap_or_default()));
138 }
139 for (at, frame) in shown.iter().enumerate().skip(skip) {
140 let file = text(&frame["filename"]).or_else(|| text(&frame["absPath"])).unwrap_or_else(|| "?".to_owned());
141 let line = frame["lineNo"].as_u64().or(frame["lineno"].as_u64());
142 let function = text(&frame["function"]).unwrap_or_else(|| "?".to_owned());
143 lines.push(format!(
144 " {file}{} in {function}",
145 line.map(|line| format!(":{line}")).unwrap_or_default()
146 ));
147 // The line itself, for the frame the error was raised in.
148 if at + 1 == shown.len()
149 && let Some(code) = frame["context"]
150 .as_array()
151 .and_then(|context| context.iter().find(|pair| pair[0].as_u64() == line))
152 .and_then(|pair| pair[1].as_str())
153 {
154 lines.push(format!(" > {}", code.trim()));
155 }
156 }
157 }
158 (!lines.is_empty()).then(|| format!("Stack trace, most recent call last:\n\n```\n{}\n```", lines.join("\n")))
159}
160
161/// The stack trace of the issue's latest event, read with the token.
162pub async fn latest_trace(config: &ConnectionConfig, token: &str, id: &str) -> Result<Option<String>> {
163 let Some(org) = &config.organization else {
164 return Ok(None);
165 };
166 let answer = call(config, token, Method::Get, &format!("/organizations/{org}/issues/{id}/events/latest/"), None).await?;
167 Ok(answer.ok().then(|| stack_trace(&answer.json())).flatten())
168}
169
170/// A Sentry issue as it is now.
171pub async fn fetch(config: &ConnectionConfig, token: &str, id: &str) -> Result<std::result::Result<Option<ContextItem>, String>> {
172 let Some(org) = &config.organization else {
173 return Ok(Err("The Sentry connection names no organization.".to_owned()));
174 };
175 let answer = call(config, token, Method::Get, &format!("/organizations/{org}/issues/{id}/"), None).await?;
176 if answer.status == 404 {
177 return Ok(Ok(None));
178 }
179 if !answer.ok() {
180 return Ok(Err(answer.problem("Sentry")));
181 }
182 let issue = answer.json();
183 let mut body = Vec::new();
184 if let Some(culprit) = text(&issue["culprit"]) {
185 body.push(format!("In `{culprit}`."));
186 }
187 if let Some(count) = text(&issue["count"]) {
188 body.push(format!("{count} events, last seen {}.", text(&issue["lastSeen"]).unwrap_or_default()));
189 }
190 if let Some(trace) = latest_trace(config, token, id).await? {
191 body.push(trace);
192 }
193 Ok(Ok(Some(ContextItem {
194 provider: Provider::Sentry,
195 key: text(&issue["shortId"]).unwrap_or_else(|| id.to_owned()),
196 title: text(&issue["title"]).unwrap_or_default(),
197 url: web_url(config, &issue, id),
198 status: text(&issue["status"]),
199 body: http::shorten(&body.join("\n\n"), 6000),
200 fetched_at: rfc3339(now_ms()),
201 })))
202}
203
204/// Marks the Sentry issue resolved and says why. `Err` with what went
205/// wrong, for the connection to show.
206pub async fn resolve(config: &ConnectionConfig, token: &str, id: &str, note: &str) -> Result<std::result::Result<(), String>> {
207 let Some(org) = &config.organization else {
208 return Ok(Err("The Sentry connection names no organization.".to_owned()));
209 };
210 let answer = call(
211 config,
212 token,
213 Method::Put,
214 &format!("/organizations/{org}/issues/{id}/"),
215 Some(json!({ "status": "resolved" })),
216 )
217 .await?;
218 if !answer.ok() {
219 return Ok(Err(answer.problem("Sentry")));
220 }
221 comment(config, token, id, note).await
222}
223
224pub async fn comment(config: &ConnectionConfig, token: &str, id: &str, note: &str) -> Result<std::result::Result<(), String>> {
225 let answer = call(config, token, Method::Post, &format!("/issues/{id}/comments/"), Some(json!({ "text": note }))).await?;
226 Ok(if answer.ok() { Ok(()) } else { Err(answer.problem("Sentry")) })
227}
228
229pub async fn test(config: &ConnectionConfig, token: &str) -> Result<std::result::Result<String, String>> {
230 let Some(org) = &config.organization else {
231 return Ok(Err("Name the Sentry organization, by its slug.".to_owned()));
232 };
233 let answer = call(config, token, Method::Get, &format!("/organizations/{org}/"), None).await?;
234 Ok(if answer.ok() {
235 Ok(format!("Connected to {}.", text(&answer.json()["name"]).unwrap_or_else(|| org.clone())))
236 } else {
237 Err(answer.problem("Sentry"))
238 })
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 fn config() -> ConnectionConfig {
246 ConnectionConfig {
247 organization: Some("acme".to_owned()),
248 ..ConnectionConfig::default()
249 }
250 }
251
252 #[test]
253 fn a_new_issue_opens_one() {
254 let payload = json!({
255 "action": "created",
256 "data": { "issue": {
257 "id": "4509", "shortId": "WEB-3F", "title": "TypeError: x is undefined",
258 "culprit": "getUser(src/api/users.ts)", "level": "error", "count": "12", "userCount": 3,
259 "project": { "slug": "web" },
260 "metadata": { "type": "TypeError", "value": "x is undefined" }
261 }}
262 });
263 let signal = signal("issue", &payload, &config()).unwrap();
264 assert_eq!(signal.external_id, "4509");
265 assert_eq!(signal.key, "WEB-3F");
266 assert_eq!(signal.action, Action::Open);
267 assert_eq!(signal.count, Some(12));
268 assert_eq!(signal.url, "https://acme.sentry.io/issues/4509/");
269 assert!(signal.body.contains("12 events · 3 users"));
270 assert!(signal.body.contains("> TypeError: x is undefined"));
271 }
272
273 #[test]
274 fn a_regression_reopens_and_other_actions_are_ignored() {
275 let issue = json!({ "id": "1", "title": "t" });
276 let reopened = signal("issue", &json!({ "action": "unresolved", "data": { "issue": issue } }), &config()).unwrap();
277 assert_eq!(reopened.action, Action::Reopen);
278 assert!(signal("issue", &json!({ "action": "assigned", "data": { "issue": issue } }), &config()).is_err());
279 assert!(signal("installation", &json!({ "action": "created" }), &config()).is_err());
280 }
281
282 #[test]
283 fn an_alert_brings_its_stack_trace() {
284 let payload = json!({
285 "action": "triggered",
286 "data": { "event": {
287 "issue_id": "77", "title": "boom", "web_url": "https://acme.sentry.io/issues/77/events/abc/",
288 "exception": { "values": [{ "type": "Error", "value": "boom", "stacktrace": { "frames": [
289 { "filename": "node_modules/x.js", "lineno": 1, "function": "lib", "in_app": false },
290 { "filename": "src/a.ts", "lineno": 10, "function": "outer", "in_app": true },
291 { "filename": "src/b.ts", "lineno": 20, "function": "inner", "in_app": true,
292 "context": [[19, " const y = 1;"], [20, " throw new Error('boom');"]] }
293 ]}}]}
294 }}
295 });
296 let signal = signal("event_alert", &payload, &config()).unwrap();
297 assert_eq!(signal.external_id, "77");
298 assert!(signal.body.contains("src/a.ts:10 in outer\n src/b.ts:20 in inner\n > throw new Error('boom');"));
299 assert!(!signal.body.contains("node_modules"));
300 }
301}

This file's history is long; its oldest lines are credited to the oldest commit read.