posthog_cli/api/
releases.rs

1use std::collections::HashMap;
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use tracing::{info, warn};
7use uuid::Uuid;
8
9use crate::{
10    api::client::ClientError,
11    invocation_context::context,
12    utils::{files::content_hash, git::GitInfo},
13};
14
15#[derive(Debug, Clone, Deserialize)]
16pub struct Release {
17    pub id: Uuid,
18    pub hash_id: String,
19    pub version: String,
20    pub project: String,
21}
22
23#[derive(Debug, Clone, Default)]
24pub struct ReleaseBuilder {
25    project: Option<String>,
26    version: Option<String>,
27    metadata: HashMap<String, Value>,
28}
29
30// Internal, what we send to the API
31#[derive(Debug, Clone, Serialize, Deserialize)]
32struct CreateReleaseRequest {
33    #[serde(skip_serializing_if = "HashMap::is_empty")]
34    pub metadata: HashMap<String, Value>,
35    pub hash_id: String,
36    pub version: String,
37    pub project: String,
38}
39
40impl Release {
41    pub fn lookup(project: &str, version: &str) -> Result<Option<Self>, ClientError> {
42        let hash_id = content_hash([project, version]);
43        let client = &context().client;
44
45        let path = format!("error_tracking/releases/hash/{hash_id}");
46        let response = client.send_get(&path, |req| req);
47
48        if let Err(err) = response {
49            if let ClientError::ApiError(404, _, _) = err {
50                warn!("release {} of project {} not found", version, project);
51                return Ok(None);
52            }
53            warn!("failed to get release from hash: {}", err);
54            Err(err)
55        } else {
56            info!("found release {} of project {}", version, project);
57            Ok(Some(response.unwrap().json()?))
58        }
59    }
60}
61
62impl ReleaseBuilder {
63    pub fn init_from_git(info: GitInfo) -> Self {
64        let mut metadata = HashMap::new();
65        metadata.insert(
66            "git".to_string(),
67            serde_json::to_value(info.clone()).expect("can serialize gitinfo"),
68        );
69
70        Self {
71            metadata,
72            version: Some(info.commit_id), // TODO - We should pull this commits tags and use them if we can
73            project: info.repo_name,
74        }
75    }
76
77    pub fn with_git(&mut self, info: GitInfo) -> &mut Self {
78        self.with_metadata("git", info)
79            .expect("We can serialise git info")
80    }
81
82    pub fn with_metadata<T>(&mut self, key: &str, val: T) -> Result<&mut Self>
83    where
84        T: Serialize,
85    {
86        self.metadata
87            .insert(key.to_string(), serde_json::to_value(val)?);
88        Ok(self)
89    }
90
91    pub fn with_project(&mut self, project: &str) -> &mut Self {
92        self.project = Some(project.to_string());
93        self
94    }
95
96    pub fn with_version(&mut self, version: &str) -> &mut Self {
97        self.version = Some(version.to_string());
98        self
99    }
100
101    pub fn can_create(&self) -> bool {
102        self.version.is_some() && self.project.is_some()
103    }
104
105    pub fn missing(&self) -> Vec<&str> {
106        let mut missing = Vec::new();
107
108        if self.version.is_none() {
109            missing.push("version");
110        }
111        if self.project.is_none() {
112            missing.push("project");
113        }
114        missing
115    }
116
117    pub fn fetch_or_create(self) -> Result<Release> {
118        if !self.can_create() {
119            anyhow::bail!(
120                "Tried to create a release while missing key fields: {}",
121                self.missing().join(", ")
122            )
123        }
124        let version = self.version.as_ref().unwrap();
125        let project = self.project.as_ref().unwrap();
126        if let Some(release) = Release::lookup(project, version)? {
127            Ok(release)
128        } else {
129            self.create_release()
130        }
131    }
132
133    pub fn create_release(self) -> Result<Release> {
134        // The way to encode this kind of thing in the type system is a thing called "Type-state". It's cool,
135        // and if you're reading this and thinking "hmm this feels kind of gross and fragile", you should
136        // google "rust type state pattern". The only problem is it's a lot of boilerplate, so I didn't do it.
137        if !self.can_create() {
138            anyhow::bail!(
139                "Tried to create a release while missing key fields: {}",
140                self.missing().join(", ")
141            )
142        }
143        let version = self.version.unwrap();
144        let project = self.project.unwrap();
145        let metadata = self.metadata;
146
147        let hash_id = content_hash([project.as_bytes(), version.as_bytes()]);
148
149        let request = CreateReleaseRequest {
150            metadata,
151            hash_id,
152            version,
153            project,
154        };
155
156        let client = &context().client;
157
158        let response = client
159            .send_post("error_tracking/releases", |req| req.json(&request))
160            .context("Failed to create release")?;
161
162        let response = response.json::<Release>()?;
163        info!(
164            "Release {} of {} created successfully! {}",
165            request.version, request.project, response.id
166        );
167        Ok(response)
168    }
169}