Skip to main content

escouade_plugin_kit/
hosts.rs

1//! The service of the code hosts and of the Kanban's sources, for every plugin that serves some (docs/PLUGINS.md §4.4,
2//! §4.5): GitHub today, GitLab, Bitbucket, Gitea and Azure DevOps after it. A plugin gives its kinds — a
3//! [`ForgeKind`] for each host it serves, a [`SourceKind`] for each source — and [`Hosts`] answers the core's adapters
4//! (`ProcessForge`, `ProcessTicketSource`) for them:
5//!
6//! - **the account** is the call's (`account`: an instance's address, where its API is when the core says it); its
7//!   token is asked of Escouade by `secrets/get` of `account:<name>` (an instance's host, `github.com`; else the
8//!   source's id), never carried by the call. The core answers it from its accounts: the one the user gave, or the
9//!   one of the host's CLI (`gh auth token`);
10//! - **one client per account and token**, kept: what a client learns (a host's conditional reads, its limits) serves
11//!   the next call. A token changed makes a new one;
12//! - **the typed errors** as the protocol's codes: an HTTP error `-(32000 + status)` (401: « reconnect the account »),
13//!   a lasting refusal `-32010`, a call the host cannot do `-32601`;
14//! - **the budget of calls** in each answer of a host: `{ value, rateLimit }`, which the core keeps for the account
15//!   (the sources and the hosts of a plugin spend the same).
16
17use 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/// What an account gives its client: where its instance is, and its credentials.
33#[derive(Debug, Clone, PartialEq, Default)]
34pub struct Credentials {
35    /// The instance's address (`https://github.com`, `https://ghe.acme.dev`), without a trailing slash; empty for a
36    /// source of an account of its own.
37    pub url: String,
38    /// The e-mail some hosts' tokens go with (Bitbucket's).
39    pub email: String,
40    pub token: String,
41    /// Who the account is for the service (a source's « mine » filters on it); empty when the core does not say.
42    pub user: String,
43    /// Where its API is, in place of where the address says (github.com's as the core reaches it, a fake one).
44    pub api: Option<String>,
45}
46
47/// A kind of code host a plugin serves (`contributes.forges`).
48pub trait ForgeKind: Send + Sync {
49    /// Its id, as the core names it (`github`).
50    fn id(&self) -> &str;
51    /// Its name, as the user reads it (`GitHub`).
52    fn name(&self) -> &str;
53    /// Its public instance (`https://github.com`), the account's when the call names none.
54    fn default_url(&self) -> Option<&str> {
55        None
56    }
57    /// The client of one account.
58    fn client(&self, http: crate::http::reqwest::Client, account: &Credentials) -> Box<dyn Forge>;
59}
60
61/// The states of a container and the state each column gives by default (`doing`, `review`, `done`).
62#[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
68/// A source's client for one account, the plugin's side of `tickets/*`.
69pub trait TicketSource: Send + Sync {
70    /// The account as the window shows it (`@ada`) and its login.
71    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    /// The states with the state each column gives by default, for a source that knows better than the names Escouade
75    /// guesses them by (Azure Boards: by its categories). None: Escouade guesses, and `states` is what is answered.
76    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    /// What its service's last answer said of the account's limit, when it says it.
88    fn rate_limit(&self) -> Option<RateLimit> {
89        None
90    }
91}
92
93/// A kind of source a plugin serves (`contributes.ticketSources`).
94pub trait SourceKind: Send + Sync {
95    /// Its id, as the core names it (`github`).
96    fn id(&self) -> &str;
97    fn name(&self) -> &str;
98    /// The client of one account (`url`: the instance of a code host's account; empty for a source's own).
99    fn client(
100        &self,
101        http: crate::http::reqwest::Client,
102        account: &Credentials,
103    ) -> Box<dyn TicketSource>;
104    /// The name of the account's secret (`account:<name>`) when the instance is `url`: by default its host, else the
105    /// source's own id. A source whose instance is only an address typed by the user (Jira's site) keeps its own id.
106    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/// The account a call names, as the core's adapters write it.
116#[derive(Debug, Clone, Default, Deserialize)]
117#[serde(default)]
118struct Named {
119    url: String,
120    /// A source's account: its instance (GitLab Issues on a GitLab's).
121    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
137/// The service of a plugin's code hosts and sources: a `serve::Plugin` that answers `account/me`, `forge/*` and
138/// `tickets/*`, and refuses the rest.
139pub 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    /// The service of these kinds, on the network Escouade's settings say (`Network::from_env`).
149    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    /// The token of the account `name`, as Escouade gives it.
168    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    /// The client of the host the call names, for its account: the one kept, or a new one.
193    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        // A token that changed leaves its account's former client.
224        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    /// The client of the source the call names, for its account.
229    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        // Its account: its instance's host (github.com's), else the source's own (`SourceKind::account_name`).
239        let name = kind.account_name(&url);
240        let token = self.token(cx, kind.name(), &name).await?;
241        // The user is part of the key: a client made before the account knew it (at « Connecter ») must not serve the
242        // calls after it (ClickUp's « Mes tâches » filters on it).
243        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, &params).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, &params).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, &params).await?;
278            let v = forge_call(&*f, name, &params).await;
279            if let (Err(_), Some(rl)) = (&v, f.rate_limit()) {
280                // A refusal carries no budget: said before it (a 403 of the limit says when it starts over).
281                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, &params).await?;
290            let v = source_call(&*s, name, &params).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
327/// What a failed call of a client is on the protocol: the code of its type, its message as it reads.
328pub 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
353/// An answer with the account's budget beside it, when its client says it: `{ value, rateLimit }`. A host's answer is
354/// always `{ value }` at least; a source's is bare without a budget.
355fn 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
363/// A host's call `name` (`forge/<name>`), with the arguments the core's `ProcessForge` writes.
364async 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
426/// A source's call `name` (`tickets/<name>`), with the arguments the core's `ProcessTicketSource` writes.
427async 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}