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

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1//! Jira and Linear: a ticket key resolves to the ticket, and the ticket is
2//! told when work on it starts and lands.
3
4use base64::Engine;
5use base64::engine::general_purpose::STANDARD;
6use g1t_contracts::integrations::{ConnectionConfig, ContextItem, Provider};
7use g1t_contracts::time::rfc3339;
8use g1t_kit::now_ms;
9use serde_json::{Value, json};
10use worker::{Method, Result};
11
12use crate::http::{self, Answer};
13
14/// A ticket, and the id its system's API takes for it.
15pub struct Ticket {
16 pub item: ContextItem,
17 pub external_id: String,
18}
19
20/// `Ok(None)` when the system has no such ticket; `Err` with what went
21/// wrong otherwise.
22pub type Fetched = std::result::Result<Option<Ticket>, String>;
23
24const MAX_BODY: usize = 8000;
25
26// --- Jira ----------------------------------------------------------------
27
28fn jira_site(config: &ConnectionConfig) -> String {
29 config.site.as_deref().unwrap_or_default().trim_end_matches('/').to_owned()
30}
31
32async fn jira(config: &ConnectionConfig, token: &str, method: Method, path: &str, body: Option<Value>) -> Result<Answer> {
33 let auth = format!(
34 "Basic {}",
35 STANDARD.encode(format!("{}:{token}", config.email.as_deref().unwrap_or_default()))
36 );
37 http::send(
38 method,
39 &format!("{}/rest/api/3{path}", jira_site(config)),
40 &[("authorization", &auth)],
41 body.map(|body| body.to_string()),
42 )
43 .await
44}
45
46/// Atlassian's document format, as plain text.
47pub fn adf_text(node: &Value) -> String {
48 match node["type"].as_str() {
49 Some("text") => node["text"].as_str().unwrap_or_default().to_owned(),
50 Some("hardBreak") => "\n".to_owned(),
51 Some("mention") => node["attrs"]["text"].as_str().unwrap_or_default().to_owned(),
52 Some("inlineCard") => node["attrs"]["url"].as_str().unwrap_or_default().to_owned(),
53 kind => {
54 let inner: Vec<String> = node["content"].as_array().map(|nodes| nodes.iter().map(adf_text).collect()).unwrap_or_default();
55 match kind {
56 Some("paragraph" | "heading" | "blockquote" | "rule") => format!("{}\n\n", inner.concat()),
57 Some("listItem") => format!("- {}", inner.concat().trim_end()) + "\n",
58 Some("bulletList" | "orderedList") => format!("{}\n", inner.concat()),
59 Some("codeBlock") => format!("```\n{}\n```\n\n", inner.concat()),
60 _ => inner.concat(),
61 }
62 }
63 }
64}
65
66pub async fn jira_fetch(config: &ConnectionConfig, token: &str, key: &str) -> Result<Fetched> {
67 let answer = jira(
68 config,
69 token,
70 Method::Get,
71 &format!("/issue/{key}?fields=summary,description,status,issuetype,priority,labels"),
72 None,
73 )
74 .await?;
75 if answer.status == 404 {
76 return Ok(Ok(None));
77 }
78 if !answer.ok() {
79 return Ok(Err(answer.problem("Jira")));
80 }
81 let issue = answer.json();
82 let fields = &issue["fields"];
83 let mut body = Vec::new();
84 let kind = [fields["issuetype"]["name"].as_str(), fields["priority"]["name"].as_str()]
85 .into_iter()
86 .flatten()
87 .collect::<Vec<_>>()
88 .join(", priority ");
89 if !kind.is_empty() {
90 body.push(format!("{kind}."));
91 }
92 let description = adf_text(&fields["description"]);
93 if !description.trim().is_empty() {
94 body.push(description.trim().to_owned());
95 }
96 if let Some(labels) = fields["labels"].as_array().filter(|labels| !labels.is_empty()) {
97 body.push(format!(
98 "Labels: {}",
99 labels.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
100 ));
101 }
102 let key = issue["key"].as_str().unwrap_or(key).to_owned();
103 Ok(Ok(Some(Ticket {
104 external_id: key.clone(),
105 item: ContextItem {
106 provider: Provider::Jira,
107 url: format!("{}/browse/{key}", jira_site(config)),
108 key,
109 title: fields["summary"].as_str().unwrap_or_default().to_owned(),
110 status: fields["status"]["name"].as_str().map(str::to_owned),
111 body: http::shorten(&body.join("\n\n"), MAX_BODY),
112 fetched_at: rfc3339(now_ms()),
113 },
114 })))
115}
116
117pub async fn jira_comment(config: &ConnectionConfig, token: &str, key: &str, text: &str, link: &str) -> Result<std::result::Result<(), String>> {
118 let body = json!({ "body": { "type": "doc", "version": 1, "content": [{
119 "type": "paragraph",
120 "content": [
121 { "type": "text", "text": format!("{text} ") },
122 { "type": "text", "text": link, "marks": [{ "type": "link", "attrs": { "href": link } }] }
123 ]
124 }]}});
125 let answer = jira(config, token, Method::Post, &format!("/issue/{key}/comment"), Some(body)).await?;
126 Ok(if answer.ok() { Ok(()) } else { Err(answer.problem("Jira")) })
127}
128
129pub async fn jira_test(config: &ConnectionConfig, token: &str) -> Result<std::result::Result<String, String>> {
130 let answer = jira(config, token, Method::Get, "/myself", None).await?;
131 Ok(if answer.ok() {
132 Ok(format!(
133 "Connected to {} as {}.",
134 jira_site(config),
135 answer.json()["displayName"].as_str().unwrap_or("you")
136 ))
137 } else {
138 Err(answer.problem("Jira"))
139 })
140}
141
142// --- Linear --------------------------------------------------------------
143
144async fn linear(token: &str, query: &str, variables: Value) -> Result<Answer> {
145 http::send(
146 Method::Post,
147 "https://api.linear.app/graphql",
148 &[("authorization", token)],
149 Some(json!({ "query": query, "variables": variables }).to_string()),
150 )
151 .await
152}
153
154fn linear_problem(answer: &Answer) -> Option<String> {
155 if !answer.ok() {
156 return Some(answer.problem("Linear"));
157 }
158 answer.json()["errors"][0]["message"].as_str().map(|message| format!("Linear: {message}"))
159}
160
161pub async fn linear_fetch(token: &str, key: &str) -> Result<Fetched> {
162 let answer = linear(
163 token,
164 "query($id: String!) { issue(id: $id) { id identifier title description url priorityLabel state { name } labels { nodes { name } } } }",
165 json!({ "id": key }),
166 )
167 .await?;
168 let said = answer.json();
169 if let Some(problem) = linear_problem(&answer) {
170 // Linear says a missing issue is an error like any other.
171 return Ok(if problem.to_ascii_lowercase().contains("not found") { Ok(None) } else { Err(problem) });
172 }
173 let issue = &said["data"]["issue"];
174 if issue.is_null() {
175 return Ok(Ok(None));
176 }
177 let mut body = Vec::new();
178 if let Some(priority) = issue["priorityLabel"].as_str().filter(|p| *p != "No priority") {
179 body.push(format!("Priority {priority}."));
180 }
181 if let Some(description) = issue["description"].as_str().filter(|d| !d.trim().is_empty()) {
182 body.push(description.trim().to_owned());
183 }
184 Ok(Ok(Some(Ticket {
185 external_id: issue["id"].as_str().unwrap_or_default().to_owned(),
186 item: ContextItem {
187 provider: Provider::Linear,
188 key: issue["identifier"].as_str().unwrap_or(key).to_owned(),
189 title: issue["title"].as_str().unwrap_or_default().to_owned(),
190 url: issue["url"].as_str().unwrap_or_default().to_owned(),
191 status: issue["state"]["name"].as_str().map(str::to_owned),
192 body: http::shorten(&body.join("\n\n"), MAX_BODY),
193 fetched_at: rfc3339(now_ms()),
194 },
195 })))
196}
197
198pub async fn linear_comment(token: &str, issue_id: &str, text: &str, link: &str) -> Result<std::result::Result<(), String>> {
199 let answer = linear(
200 token,
201 "mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }",
202 json!({ "input": { "issueId": issue_id, "body": format!("{text} [{link}]({link})") } }),
203 )
204 .await?;
205 Ok(match linear_problem(&answer) {
206 Some(problem) => Err(problem),
207 None => Ok(()),
208 })
209}
210
211pub async fn linear_test(token: &str) -> Result<std::result::Result<String, String>> {
212 let answer = linear(token, "{ viewer { name } organization { name } }", json!({})).await?;
213 if let Some(problem) = linear_problem(&answer) {
214 return Ok(Err(problem));
215 }
216 let said = answer.json();
217 Ok(Ok(format!(
218 "Connected to {} as {}.",
219 said["data"]["organization"]["name"].as_str().unwrap_or("Linear"),
220 said["data"]["viewer"]["name"].as_str().unwrap_or("you")
221 )))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn atlassian_documents_read_as_text() {
230 let doc = json!({ "type": "doc", "content": [
231 { "type": "paragraph", "content": [{ "type": "text", "text": "Users see a 500 on " }, { "type": "text", "text": "/login" }] },
232 { "type": "bulletList", "content": [
233 { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "on Safari" }] }] },
234 { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "since Monday" }] }] }
235 ]},
236 { "type": "codeBlock", "content": [{ "type": "text", "text": "GET /login 500" }] }
237 ]});
238 let text = adf_text(&doc);
239 assert!(text.starts_with("Users see a 500 on /login\n\n"));
240 assert!(text.contains("- on Safari\n- since Monday\n"));
241 assert!(text.contains("```\nGET /login 500\n```"));
242 }
243}

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