1use crate::errors::{HttpError, Refused};
18use crate::forge::{BoxFuture, Forge, RateLimit, Unsupported};
19use crate::http::{host_of, Network};
20use crate::serve::{Cx, Plugin, Refusal};
21use crate::tr;
22use anyhow::Result;
23use escouade_plugin_protocol::code;
24use escouade_plugin_protocol::tickets::{Container, ExternalRef, ExternalState, IssuePage, Query};
25use parking_lot::Mutex;
26use serde::de::DeserializeOwned;
27use serde::Deserialize;
28use serde_json::{json, Value};
29use std::collections::HashMap;
30use std::sync::Arc;
31
32#[derive(Debug, Clone, PartialEq, Default)]
34pub struct Credentials {
35 pub url: String,
38 pub email: String,
40 pub token: String,
41 pub user: String,
43 pub api: Option<String>,
45}
46
47pub trait ForgeKind: Send + Sync {
49 fn id(&self) -> &str;
51 fn name(&self) -> &str;
53 fn default_url(&self) -> Option<&str> {
55 None
56 }
57 fn client(&self, http: crate::http::reqwest::Client, account: &Credentials) -> Box<dyn Forge>;
59}
60
61#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)]
63pub struct StatesView {
64 pub states: Vec<ExternalState>,
65 pub defaults: std::collections::BTreeMap<String, ExternalState>,
66}
67
68pub trait TicketSource: Send + Sync {
70 fn me(&self) -> BoxFuture<'_, Result<(String, String)>>;
72 fn containers(&self) -> BoxFuture<'_, Result<Vec<Container>>>;
73 fn states<'a>(&'a self, container: &'a str) -> BoxFuture<'a, Result<Vec<ExternalState>>>;
74 fn states_view<'a>(&'a self, _container: &'a str) -> BoxFuture<'a, Result<Option<StatesView>>> {
77 Box::pin(async { Ok(None) })
78 }
79 fn issues<'a>(&'a self, container: &'a str, q: &'a Query) -> BoxFuture<'a, Result<IssuePage>>;
80 fn set_state<'a>(
81 &'a self,
82 r: &'a ExternalRef,
83 state: &'a ExternalState,
84 mapped: &'a [ExternalState],
85 ) -> BoxFuture<'a, Result<()>>;
86 fn comment<'a>(&'a self, r: &'a ExternalRef, text: &'a str) -> BoxFuture<'a, Result<()>>;
87 fn rate_limit(&self) -> Option<RateLimit> {
89 None
90 }
91}
92
93pub trait SourceKind: Send + Sync {
95 fn id(&self) -> &str;
97 fn name(&self) -> &str;
98 fn client(
100 &self,
101 http: crate::http::reqwest::Client,
102 account: &Credentials,
103 ) -> Box<dyn TicketSource>;
104 fn account_name(&self, url: &str) -> String {
107 if url.is_empty() {
108 self.id().to_string()
109 } else {
110 host_of(url)
111 }
112 }
113}
114
115#[derive(Debug, Clone, Default, Deserialize)]
117#[serde(default)]
118struct Named {
119 url: String,
120 site: String,
122 email: String,
123 user: String,
124 api: Option<String>,
125}
126
127impl Named {
128 fn address(&self) -> &str {
129 if self.url.trim().is_empty() {
130 self.site.trim()
131 } else {
132 self.url.trim()
133 }
134 }
135}
136
137pub struct Hosts {
140 forges: Vec<Box<dyn ForgeKind>>,
141 sources: Vec<Box<dyn SourceKind>>,
142 network: Network,
143 forge_clients: Mutex<HashMap<String, Arc<dyn Forge>>>,
144 source_clients: Mutex<HashMap<String, Arc<dyn TicketSource>>>,
145}
146
147impl Hosts {
148 pub fn new(forges: Vec<Box<dyn ForgeKind>>, sources: Vec<Box<dyn SourceKind>>) -> Self {
150 Self::on(Network::from_env(), forges, sources)
151 }
152
153 pub fn on(
154 network: Network,
155 forges: Vec<Box<dyn ForgeKind>>,
156 sources: Vec<Box<dyn SourceKind>>,
157 ) -> Self {
158 Self {
159 forges,
160 sources,
161 network,
162 forge_clients: Mutex::default(),
163 source_clients: Mutex::default(),
164 }
165 }
166
167 async fn token(&self, cx: &Cx, kind: &str, name: &str) -> Result<String, Refusal> {
169 let token = cx
170 .secret(&format!("account:{name}"))
171 .await?
172 .unwrap_or_default();
173 if token.trim().is_empty() {
174 return Err(Refusal {
175 code: code::http(401),
176 message: tr!(
177 "{kind} : aucun jeton pour {name}. Reconnecte le compte dans Réglages › Intégrations.",
178 "{kind}: no token for {name}. Connect the account again in Settings › Integrations."
179 ),
180 });
181 }
182 Ok(token.trim().to_string())
183 }
184
185 fn http(&self) -> Result<crate::http::reqwest::Client, Refusal> {
186 self.network.client().map_err(|e| Refusal {
187 code: -32000,
188 message: format!("{e:#}"),
189 })
190 }
191
192 async fn forge(&self, cx: &Cx, params: &Value) -> Result<(Arc<dyn Forge>, String), Refusal> {
194 let id = params["forge"].as_str().unwrap_or("");
195 let kind = self
196 .forges
197 .iter()
198 .find(|k| k.id() == id)
199 .ok_or_else(|| invalid(format!("this plugin serves no code host {id:?}")))?;
200 let account: Named = arg(params, "account")?;
201 let url = match account.address().trim_end_matches('/') {
202 "" => kind.default_url().unwrap_or("").to_string(),
203 url => url.to_string(),
204 };
205 let host = host_of(&url);
206 if host.is_empty() {
207 return Err(invalid("the account names no instance".into()));
208 }
209 let token = self.token(cx, kind.name(), &host).await?;
210 let key = format!("{id}\n{url}\n{:?}\n{token}", account.api);
211 if let Some(c) = self.forge_clients.lock().get(&key) {
212 return Ok((c.clone(), host));
213 }
214 let creds = Credentials {
215 url,
216 email: account.email,
217 token,
218 user: account.user,
219 api: account.api,
220 };
221 let client: Arc<dyn Forge> = Arc::from(kind.client(self.http()?, &creds));
222 let mut clients = self.forge_clients.lock();
223 clients.retain(|k, _| !k.starts_with(&format!("{id}\n{}\n", creds.url)));
225 Ok((clients.entry(key).or_insert(client).clone(), host))
226 }
227
228 async fn source(&self, cx: &Cx, params: &Value) -> Result<Arc<dyn TicketSource>, Refusal> {
230 let id = params["source"].as_str().unwrap_or("");
231 let kind = self
232 .sources
233 .iter()
234 .find(|k| k.id() == id)
235 .ok_or_else(|| invalid(format!("this plugin serves no ticket source {id:?}")))?;
236 let account: Named = arg(params, "account")?;
237 let url = account.address().trim_end_matches('/').to_string();
238 let name = kind.account_name(&url);
240 let token = self.token(cx, kind.name(), &name).await?;
241 let key = format!("{id}\n{url}\n{:?}\n{token}\n{}", account.api, account.user);
244 if let Some(c) = self.source_clients.lock().get(&key) {
245 return Ok(c.clone());
246 }
247 let creds = Credentials {
248 url,
249 email: account.email,
250 token,
251 user: account.user,
252 api: account.api,
253 };
254 let client: Arc<dyn TicketSource> = Arc::from(kind.client(self.http()?, &creds));
255 let mut clients = self.source_clients.lock();
256 clients.retain(|k, _| !k.starts_with(&format!("{id}\n{}\n", creds.url)));
257 Ok(clients.entry(key).or_insert(client).clone())
258 }
259
260 async fn answer(&self, cx: &Cx, method: &str, params: Value) -> Result<Value, Refusal> {
261 if method == "account/me" {
262 return if params["point"] == "ticketSource" {
263 let s = self.source(cx, ¶ms).await?;
264 let (label, user) = s.me().await.map_err(refusal)?;
265 budgeted(
266 json!({ "label": label, "user": user }),
267 s.rate_limit(),
268 false,
269 )
270 } else {
271 let (f, _) = self.forge(cx, ¶ms).await?;
272 let me = f.me().await.map_err(refusal)?;
273 budgeted(to_value(me)?, f.rate_limit(), false)
274 };
275 }
276 if let Some(name) = method.strip_prefix("forge/") {
277 let (f, host) = self.forge(cx, ¶ms).await?;
278 let v = forge_call(&*f, name, ¶ms).await;
279 if let (Err(_), Some(rl)) = (&v, f.rate_limit()) {
280 cx.notify(
282 "account/budget",
283 json!({ "account": host, "rateLimit": to_value(rl)? }),
284 );
285 }
286 return budgeted(v?, f.rate_limit(), true);
287 }
288 if let Some(name) = method.strip_prefix("tickets/") {
289 let s = self.source(cx, ¶ms).await?;
290 let v = source_call(&*s, name, ¶ms).await?;
291 return budgeted(v, s.rate_limit(), false);
292 }
293 Err(Refusal::not_found(method))
294 }
295}
296
297impl Plugin for Hosts {
298 fn call<'a>(
299 &'a self,
300 cx: &'a Cx,
301 method: &'a str,
302 params: Value,
303 ) -> BoxFuture<'a, Result<Value, Refusal>> {
304 Box::pin(self.answer(cx, method, params))
305 }
306}
307
308fn invalid(message: String) -> Refusal {
309 Refusal {
310 code: -32602,
311 message,
312 }
313}
314
315fn arg<T: DeserializeOwned>(params: &Value, key: &str) -> Result<T, Refusal> {
316 serde_json::from_value(params.get(key).cloned().unwrap_or(Value::Null))
317 .map_err(|e| invalid(format!("{key}: {e}")))
318}
319
320fn to_value<T: serde::Serialize>(v: T) -> Result<Value, Refusal> {
321 serde_json::to_value(v).map_err(|e| Refusal {
322 code: -32603,
323 message: e.to_string(),
324 })
325}
326
327pub fn refusal(e: anyhow::Error) -> Refusal {
329 if let Some(h) = e.downcast_ref::<HttpError>() {
330 return Refusal {
331 code: code::http(h.status),
332 message: h.message.clone(),
333 };
334 }
335 if let Some(r) = e.downcast_ref::<Refused>() {
336 return Refusal {
337 code: code::REFUSED,
338 message: r.0.clone(),
339 };
340 }
341 if e.downcast_ref::<Unsupported>().is_some() {
342 return Refusal {
343 code: code::METHOD_NOT_FOUND,
344 message: format!("{e}"),
345 };
346 }
347 Refusal {
348 code: -32000,
349 message: format!("{e:#}"),
350 }
351}
352
353fn budgeted(v: Value, limit: Option<RateLimit>, wrapped: bool) -> Result<Value, Refusal> {
356 Ok(match limit {
357 Some(rl) => json!({ "value": v, "rateLimit": to_value(rl)? }),
358 None if wrapped => json!({ "value": v }),
359 None => v,
360 })
361}
362
363async fn forge_call(f: &dyn Forge, name: &str, p: &Value) -> Result<Value, Refusal> {
365 let repo = || arg::<crate::forge::RepoRef>(p, "repo");
366 let number = || arg::<u64>(p, "number");
367 let text = |k: &str| arg::<String>(p, k);
368 let done = |r: Result<()>| r.map(|()| Value::Null).map_err(refusal);
369 match name {
370 "createPr" => to_value(
371 f.create_pr(&repo()?, &arg(p, "pr")?)
372 .await
373 .map_err(refusal)?,
374 ),
375 "pullRequest" => to_value(f.pull_request(&repo()?, number()?).await.map_err(refusal)?),
376 "pullRequestsOfBranch" => to_value(
377 f.pull_requests_of_branch(&repo()?, &text("branch")?)
378 .await
379 .map_err(refusal)?,
380 ),
381 "parent" => to_value(f.parent(&repo()?).await.map_err(refusal)?),
382 "openPullRequests" => to_value(f.open_pull_requests(&repo()?).await.map_err(refusal)?),
383 "pullRequests" => {
384 let numbers: Vec<u64> = arg(p, "numbers")?;
385 to_value(f.pull_requests(&repo()?, &numbers).await.map_err(refusal)?)
386 }
387 "mergeOptions" => to_value(
388 f.merge_options(&repo()?, number()?)
389 .await
390 .map_err(refusal)?,
391 ),
392 "merge" => done(f.merge(&repo()?, number()?, arg(p, "strategy")?).await),
393 "autoMerge" => done(f.auto_merge(&repo()?, number()?, arg(p, "strategy")?).await),
394 "checks" => to_value(f.checks(&repo()?, number()?).await.map_err(refusal)?),
395 "jobLog" => to_value(f.job_log(&repo()?, &text("job")?).await.map_err(refusal)?),
396 "rerunFailed" => to_value(f.rerun_failed(&repo()?, number()?).await.map_err(refusal)?),
397 "branchChecks" => to_value(
398 f.branch_checks(&repo()?, &text("branch")?)
399 .await
400 .map_err(refusal)?,
401 ),
402 "rerunFailedOfBranch" => to_value(
403 f.rerun_failed_of_branch(&repo()?, &text("branch")?)
404 .await
405 .map_err(refusal)?,
406 ),
407 "reviews" => to_value(f.reviews(&repo()?, number()?).await.map_err(refusal)?),
408 "comments" => to_value(f.comments(&repo()?, number()?).await.map_err(refusal)?),
409 "comment" => done(f.comment(&repo()?, number()?, &text("body")?).await),
410 "review" => done(f.review(&repo()?, number()?, &arg(p, "review")?).await),
411 "reply" => done(
412 f.reply(&repo()?, number()?, &text("comment")?, &text("body")?)
413 .await,
414 ),
415 "resolve" => done(f.resolve(&repo()?, number()?, &text("comment")?).await),
416 "deployments" => to_value(
417 f.deployments(&repo()?, &text("ref")?)
418 .await
419 .map_err(refusal)?,
420 ),
421 "environments" => to_value(f.environments(&repo()?).await.map_err(refusal)?),
422 other => Err(Refusal::not_found(&format!("forge/{other}"))),
423 }
424}
425
426async fn source_call(s: &dyn TicketSource, name: &str, p: &Value) -> Result<Value, Refusal> {
428 let container = || arg::<String>(p, "container");
429 let r = || arg::<ExternalRef>(p, "ref");
430 match name {
431 "containers" => to_value(s.containers().await.map_err(refusal)?),
432 "states" => match s.states_view(&container()?).await.map_err(refusal)? {
433 Some(view) => to_value(view),
434 None => to_value(s.states(&container()?).await.map_err(refusal)?),
435 },
436 "issues" => {
437 let q: Query = arg(p, "query")?;
438 to_value(s.issues(&container()?, &q).await.map_err(refusal)?)
439 }
440 "setState" => {
441 let mapped: Vec<ExternalState> = arg(p, "mapped")?;
442 s.set_state(&r()?, &arg(p, "state")?, &mapped)
443 .await
444 .map(|()| Value::Null)
445 .map_err(refusal)
446 }
447 "comment" => s
448 .comment(&r()?, &arg::<String>(p, "text")?)
449 .await
450 .map(|()| Value::Null)
451 .map_err(refusal),
452 other => Err(Refusal::not_found(&format!("tickets/{other}"))),
453 }
454}