1use crate::error::GriteError;
2use crate::store::{GriteStore, IssueFilter};
3use crate::types::event::{Event, EventKind};
4use crate::types::ids::{id_to_hex, EventId};
5use crate::types::issue::IssueSummary;
6use serde::Serialize;
7
8#[derive(Debug, Serialize)]
10pub struct ExportMeta {
11 pub schema_version: u32,
12 pub generated_ts: u64,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub wal_head: Option<String>,
15 pub event_count: usize,
16}
17
18#[derive(Debug, Serialize)]
20pub struct JsonExport {
21 pub meta: ExportMeta,
22 pub issues: Vec<IssueSummaryJson>,
23 pub events: Vec<EventJson>,
24}
25
26#[derive(Debug, Serialize)]
28pub struct IssueSummaryJson {
29 pub issue_id: String,
30 pub title: String,
31 pub state: String,
32 pub labels: Vec<String>,
33 pub assignees: Vec<String>,
34 pub created_ts: u64,
35 pub updated_ts: u64,
36 pub comment_count: usize,
37}
38
39impl From<&IssueSummary> for IssueSummaryJson {
40 fn from(s: &IssueSummary) -> Self {
41 Self {
42 issue_id: id_to_hex(&s.issue_id),
43 title: s.title.clone(),
44 state: format!("{:?}", s.state).to_lowercase(),
45 labels: s.labels.clone(),
46 assignees: s.assignees.clone(),
47 created_ts: s.created_ts,
48 updated_ts: s.updated_ts,
49 comment_count: s.comment_count,
50 }
51 }
52}
53
54#[derive(Debug, Serialize)]
56pub struct EventJson {
57 pub event_id: String,
58 pub issue_id: String,
59 pub actor: String,
60 pub ts_unix_ms: u64,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub parent: Option<String>,
63 pub kind: serde_json::Value,
64}
65
66impl From<&Event> for EventJson {
67 fn from(e: &Event) -> Self {
68 Self {
69 event_id: id_to_hex(&e.event_id),
70 issue_id: id_to_hex(&e.issue_id),
71 actor: id_to_hex(&e.actor),
72 ts_unix_ms: e.ts_unix_ms,
73 parent: e.parent.as_ref().map(id_to_hex),
74 kind: event_kind_to_json(&e.kind),
75 }
76 }
77}
78
79fn event_kind_to_json(kind: &EventKind) -> serde_json::Value {
80 match kind {
81 EventKind::IssueCreated {
82 title,
83 body,
84 labels,
85 } => {
86 serde_json::json!({
87 "IssueCreated": {
88 "title": title,
89 "body": body,
90 "labels": labels
91 }
92 })
93 }
94 EventKind::IssueUpdated { title, body } => {
95 serde_json::json!({
96 "IssueUpdated": {
97 "title": title,
98 "body": body
99 }
100 })
101 }
102 EventKind::CommentAdded { body } => {
103 serde_json::json!({
104 "CommentAdded": {
105 "body": body
106 }
107 })
108 }
109 EventKind::LabelAdded { label } => {
110 serde_json::json!({
111 "LabelAdded": {
112 "label": label
113 }
114 })
115 }
116 EventKind::LabelRemoved { label } => {
117 serde_json::json!({
118 "LabelRemoved": {
119 "label": label
120 }
121 })
122 }
123 EventKind::StateChanged { state } => {
124 serde_json::json!({
125 "StateChanged": {
126 "state": state.as_str()
127 }
128 })
129 }
130 EventKind::LinkAdded { url, note } => {
131 serde_json::json!({
132 "LinkAdded": {
133 "url": url,
134 "note": note
135 }
136 })
137 }
138 EventKind::AssigneeAdded { user } => {
139 serde_json::json!({
140 "AssigneeAdded": {
141 "user": user
142 }
143 })
144 }
145 EventKind::AssigneeRemoved { user } => {
146 serde_json::json!({
147 "AssigneeRemoved": {
148 "user": user
149 }
150 })
151 }
152 EventKind::AttachmentAdded { name, sha256, mime } => {
153 serde_json::json!({
154 "AttachmentAdded": {
155 "name": name,
156 "sha256": id_to_hex(sha256),
157 "mime": mime
158 }
159 })
160 }
161 EventKind::DependencyAdded { target, dep_type } => {
162 serde_json::json!({
163 "DependencyAdded": {
164 "target": id_to_hex(target),
165 "dep_type": dep_type.as_str()
166 }
167 })
168 }
169 EventKind::DependencyRemoved { target, dep_type } => {
170 serde_json::json!({
171 "DependencyRemoved": {
172 "target": id_to_hex(target),
173 "dep_type": dep_type.as_str()
174 }
175 })
176 }
177 EventKind::ContextUpdated {
178 path,
179 language,
180 symbols,
181 summary,
182 content_hash,
183 } => {
184 serde_json::json!({
185 "ContextUpdated": {
186 "path": path,
187 "language": language,
188 "symbol_count": symbols.len(),
189 "summary": summary,
190 "content_hash": id_to_hex(content_hash)
191 }
192 })
193 }
194 EventKind::ProjectContextUpdated { key, value } => {
195 serde_json::json!({
196 "ProjectContextUpdated": {
197 "key": key,
198 "value": value
199 }
200 })
201 }
202 }
203}
204
205pub enum ExportSince {
207 Timestamp(u64),
208 EventId(EventId),
209}
210
211pub fn export_json(
213 store: &GriteStore,
214 since: Option<ExportSince>,
215) -> Result<JsonExport, GriteError> {
216 let now = std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .unwrap_or_default()
219 .as_millis() as u64;
220
221 let issues: Vec<IssueSummaryJson> = store
223 .list_issues(&IssueFilter::default())?
224 .iter()
225 .map(IssueSummaryJson::from)
226 .collect();
227
228 let mut events = store.get_all_events()?;
230
231 if let Some(since_filter) = since {
233 events.retain(|e| match &since_filter {
234 ExportSince::Timestamp(ts) => e.ts_unix_ms > *ts,
235 ExportSince::EventId(event_id) => {
236 (&e.issue_id, e.ts_unix_ms, &e.actor, &e.event_id)
238 > (&e.issue_id, e.ts_unix_ms, &e.actor, event_id)
239 }
240 });
241 }
242
243 let event_jsons: Vec<EventJson> = events.iter().map(EventJson::from).collect();
244
245 Ok(JsonExport {
246 meta: ExportMeta {
247 schema_version: 1,
248 generated_ts: now,
249 wal_head: None, event_count: event_jsons.len(),
251 },
252 issues,
253 events: event_jsons,
254 })
255}
256
257pub fn export_markdown(
259 store: &GriteStore,
260 _since: Option<ExportSince>,
261) -> Result<String, GriteError> {
262 let mut md = String::new();
263
264 md.push_str("# grite Export\n\n");
265
266 let now = std::time::SystemTime::now()
267 .duration_since(std::time::UNIX_EPOCH)
268 .unwrap_or_default()
269 .as_millis() as u64;
270 md.push_str(&format!("Generated: {}\n\n", now));
271
272 let issues = store.list_issues(&IssueFilter::default())?;
274
275 if issues.is_empty() {
276 md.push_str("No issues found.\n");
277 return Ok(md);
278 }
279
280 md.push_str("## Issues\n\n");
281
282 for summary in &issues {
283 let issue_id_hex = id_to_hex(&summary.issue_id);
284 let state_str = format!("{:?}", summary.state).to_lowercase();
285
286 md.push_str(&format!("### {} [{}]\n\n", summary.title, state_str));
287 md.push_str(&format!("**ID:** `{}`\n\n", issue_id_hex));
288
289 if !summary.labels.is_empty() {
290 md.push_str(&format!("**Labels:** {}\n\n", summary.labels.join(", ")));
291 }
292
293 if !summary.assignees.is_empty() {
294 md.push_str(&format!(
295 "**Assignees:** {}\n\n",
296 summary.assignees.join(", ")
297 ));
298 }
299
300 if summary.comment_count > 0 {
301 md.push_str(&format!("**Comments:** {}\n\n", summary.comment_count));
302 }
303
304 if let Some(proj) = store.get_issue(&summary.issue_id)? {
306 if !proj.body.is_empty() {
307 md.push_str(&format!("{}\n\n", proj.body));
308 }
309
310 if !proj.comments.is_empty() {
311 md.push_str("#### Comments\n\n");
312 for comment in &proj.comments {
313 let actor_hex = id_to_hex(&comment.actor);
314 md.push_str(&format!(
315 "> **{}** at {}:\n> {}\n\n",
316 &actor_hex[..8],
317 comment.ts_unix_ms,
318 comment.body
319 ));
320 }
321 }
322 }
323
324 md.push_str("---\n\n");
325 }
326
327 Ok(md)
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::hash::compute_event_id;
334 use crate::types::ids::generate_issue_id;
335 use tempfile::tempdir;
336
337 #[test]
338 fn test_export_json() {
339 let dir = tempdir().unwrap();
340 let store = GriteStore::open(dir.path()).unwrap();
341
342 let issue_id = generate_issue_id();
343 let actor = [1u8; 16];
344 let kind = EventKind::IssueCreated {
345 title: "Test".to_string(),
346 body: "Body".to_string(),
347 labels: vec!["bug".to_string()],
348 };
349 let event_id = compute_event_id(&issue_id, &actor, 1000, None, &kind);
350 let event = Event::new(event_id, issue_id, actor, 1000, None, kind);
351 store.insert_event(&event).unwrap();
352
353 let export = export_json(&store, None).unwrap();
354 assert_eq!(export.meta.schema_version, 1);
355 assert_eq!(export.issues.len(), 1);
356 assert_eq!(export.events.len(), 1);
357 assert_eq!(export.issues[0].title, "Test");
358 }
359
360 #[test]
361 fn test_export_markdown() {
362 let dir = tempdir().unwrap();
363 let store = GriteStore::open(dir.path()).unwrap();
364
365 let issue_id = generate_issue_id();
366 let actor = [1u8; 16];
367 let kind = EventKind::IssueCreated {
368 title: "Test Issue".to_string(),
369 body: "This is the body".to_string(),
370 labels: vec!["bug".to_string()],
371 };
372 let event_id = compute_event_id(&issue_id, &actor, 1000, None, &kind);
373 let event = Event::new(event_id, issue_id, actor, 1000, None, kind);
374 store.insert_event(&event).unwrap();
375
376 let md = export_markdown(&store, None).unwrap();
377 assert!(md.contains("# grite Export"));
378 assert!(md.contains("Test Issue"));
379 assert!(md.contains("bug"));
380 }
381}