Skip to main content

libgrite_cli/
issue.rs

1use libgrite_core::{
2    hash::compute_event_id,
3    lock::LockCheckResult,
4    store::IssueFilter,
5    types::event::{Event, EventKind, IssueState},
6    types::ids::{generate_issue_id, id_to_hex},
7    GriteError,
8};
9
10use crate::context::GriteContext;
11use crate::event_helper::insert_and_append;
12use crate::types::*;
13
14fn current_ts() -> u64 {
15    std::time::SystemTime::now()
16        .duration_since(std::time::UNIX_EPOCH)
17        .unwrap_or_default()
18        .as_millis() as u64
19}
20
21/// RAII guard for auto-releasing locks
22struct LockGuard<'a> {
23    ctx: &'a GriteContext,
24    resource: String,
25    acquired: bool,
26}
27
28impl<'a> LockGuard<'a> {
29    fn acquire(
30        ctx: &'a GriteContext,
31        issue_id_hex: &str,
32        should_lock: bool,
33    ) -> Result<Self, GriteError> {
34        let resource = format!("issue:{}", issue_id_hex);
35        if should_lock {
36            let lock_manager = ctx.open_lock_manager()?;
37            lock_manager
38                .acquire(&resource, &ctx.actor_id, None)
39                .map_err(|e| match e {
40                    libgrite_git::GitError::LockConflict {
41                        resource,
42                        owner,
43                        expires_in_ms,
44                    } => GriteError::Conflict(format!(
45                        "Cannot acquire lock on {} - held by {} (expires in {}s)",
46                        resource,
47                        owner,
48                        expires_in_ms / 1000
49                    )),
50                    _ => GriteError::Internal(e.to_string()),
51                })?;
52            Ok(Self {
53                ctx,
54                resource,
55                acquired: true,
56            })
57        } else {
58            Ok(Self {
59                ctx,
60                resource,
61                acquired: false,
62            })
63        }
64    }
65}
66
67impl<'a> Drop for LockGuard<'a> {
68    fn drop(&mut self) {
69        if self.acquired {
70            if let Ok(lock_manager) = self.ctx.open_lock_manager() {
71                let _ = lock_manager.release(&self.resource, &self.ctx.actor_id);
72            }
73        }
74    }
75}
76
77/// Create a new issue.
78pub fn issue_create(
79    ctx: &GriteContext,
80    opts: &IssueCreateOptions,
81) -> Result<IssueCreateResult, GriteError> {
82    // Check for repo-level locks before creating
83    match ctx.check_lock("repo:global")? {
84        LockCheckResult::Clear => {}
85        LockCheckResult::Warning(_) => {}
86        LockCheckResult::Blocked(_) => {
87            return Err(GriteError::Conflict(
88                "Repository is locked by another process".to_string(),
89            ));
90        }
91    }
92
93    let store = ctx.open_store()?;
94    let wal = ctx.open_wal()?;
95    let actor = ctx.actor_config.actor_id_bytes()?;
96
97    let issue_id = generate_issue_id();
98    let ts = current_ts();
99    let kind = EventKind::IssueCreated {
100        title: opts.title.clone(),
101        body: opts.body.clone(),
102        labels: opts.labels.clone(),
103    };
104    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
105    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
106    let event = ctx.sign_event(event);
107
108    insert_and_append(&store, &wal, &actor, &event)?;
109
110    Ok(IssueCreateResult {
111        issue_id: id_to_hex(&issue_id),
112        event_id: id_to_hex(&event_id),
113    })
114}
115
116/// List issues.
117pub fn issue_list(
118    ctx: &GriteContext,
119    opts: &IssueListOptions,
120) -> Result<IssueListResult, GriteError> {
121    let store = ctx.open_store()?;
122
123    let state_filter = opts
124        .state
125        .as_ref()
126        .map(|s| match s.to_lowercase().as_str() {
127            "open" => IssueState::Open,
128            "closed" => IssueState::Closed,
129            _ => IssueState::Open,
130        });
131
132    let filter = IssueFilter {
133        state: state_filter,
134        label: opts.label.clone(),
135    };
136
137    let issues = store.list_issues(&filter)?;
138
139    Ok(IssueListResult { issues })
140}
141
142/// Show issue details.
143pub fn issue_show(
144    ctx: &GriteContext,
145    opts: &IssueShowOptions,
146) -> Result<IssueShowResult, GriteError> {
147    let store = ctx.open_store()?;
148
149    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
150    let proj = store
151        .get_issue(&issue_id)?
152        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
153
154    let events = store.get_issue_events(&issue_id)?;
155
156    Ok(IssueShowResult {
157        issue: proj,
158        events,
159    })
160}
161
162/// Update an issue.
163pub fn issue_update(
164    ctx: &GriteContext,
165    opts: &IssueUpdateOptions,
166) -> Result<IssueUpdateResult, GriteError> {
167    if opts.title.is_none() && opts.body.is_none() {
168        return Err(GriteError::InvalidArgs(
169            "Either --title or --body must be provided".to_string(),
170        ));
171    }
172
173    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
174
175    let store = ctx.open_store()?;
176    let wal = ctx.open_wal()?;
177    let actor = ctx.actor_config.actor_id_bytes()?;
178
179    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
180    let _existing = store
181        .get_issue(&issue_id)?
182        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
183
184    let title = opts.title.clone();
185    let body = opts.body.clone();
186
187    let ts = current_ts();
188    let kind = EventKind::IssueUpdated { title, body };
189    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
190    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
191    let event = ctx.sign_event(event);
192
193    insert_and_append(&store, &wal, &actor, &event)?;
194
195    Ok(IssueUpdateResult {
196        issue_id: id_to_hex(&issue_id),
197        event_id: id_to_hex(&event_id),
198    })
199}
200
201/// Add a comment to an issue.
202pub fn issue_comment(
203    ctx: &GriteContext,
204    opts: &IssueCommentOptions,
205) -> Result<IssueCommentResult, GriteError> {
206    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
207
208    let store = ctx.open_store()?;
209    let wal = ctx.open_wal()?;
210    let actor = ctx.actor_config.actor_id_bytes()?;
211
212    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
213    let _existing = store
214        .get_issue(&issue_id)?
215        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
216
217    let ts = current_ts();
218    let kind = EventKind::CommentAdded {
219        body: opts.body.clone(),
220    };
221    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
222    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
223    let event = ctx.sign_event(event);
224
225    insert_and_append(&store, &wal, &actor, &event)?;
226
227    Ok(IssueCommentResult {
228        issue_id: id_to_hex(&issue_id),
229        event_id: id_to_hex(&event_id),
230    })
231}
232
233/// Close an issue.
234pub fn issue_close(
235    ctx: &GriteContext,
236    opts: &IssueStateOptions,
237) -> Result<IssueStateResult, GriteError> {
238    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
239
240    let store = ctx.open_store()?;
241    let wal = ctx.open_wal()?;
242    let actor = ctx.actor_config.actor_id_bytes()?;
243
244    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
245    let _existing = store
246        .get_issue(&issue_id)?
247        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
248
249    let ts = current_ts();
250    let kind = EventKind::StateChanged {
251        state: IssueState::Closed,
252    };
253    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
254    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
255    let event = ctx.sign_event(event);
256
257    insert_and_append(&store, &wal, &actor, &event)?;
258
259    Ok(IssueStateResult {
260        issue_id: id_to_hex(&issue_id),
261        event_id: id_to_hex(&event_id),
262        action: "closed".to_string(),
263    })
264}
265
266/// Reopen an issue.
267pub fn issue_reopen(
268    ctx: &GriteContext,
269    opts: &IssueStateOptions,
270) -> Result<IssueStateResult, GriteError> {
271    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
272
273    let store = ctx.open_store()?;
274    let wal = ctx.open_wal()?;
275    let actor = ctx.actor_config.actor_id_bytes()?;
276
277    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
278    let _existing = store
279        .get_issue(&issue_id)?
280        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
281
282    let ts = current_ts();
283    let kind = EventKind::StateChanged {
284        state: IssueState::Open,
285    };
286    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
287    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
288    let event = ctx.sign_event(event);
289
290    insert_and_append(&store, &wal, &actor, &event)?;
291
292    Ok(IssueStateResult {
293        issue_id: id_to_hex(&issue_id),
294        event_id: id_to_hex(&event_id),
295        action: "reopened".to_string(),
296    })
297}
298
299/// Add or remove labels.
300pub fn issue_label(
301    ctx: &GriteContext,
302    opts: &IssueLabelOptions,
303) -> Result<IssueLabelResult, GriteError> {
304    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
305
306    let store = ctx.open_store()?;
307    let wal = ctx.open_wal()?;
308    let actor = ctx.actor_config.actor_id_bytes()?;
309
310    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
311    let _existing = store
312        .get_issue(&issue_id)?
313        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
314
315    let ts = current_ts();
316    let kind = if !opts.add.is_empty() {
317        EventKind::LabelAdded {
318            label: opts.add[0].clone(),
319        }
320    } else if !opts.remove.is_empty() {
321        EventKind::LabelRemoved {
322            label: opts.remove[0].clone(),
323        }
324    } else {
325        return Err(GriteError::InvalidArgs(
326            "No labels to add or remove".to_string(),
327        ));
328    };
329
330    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
331    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
332    let event = ctx.sign_event(event);
333
334    insert_and_append(&store, &wal, &actor, &event)?;
335
336    Ok(IssueLabelResult {
337        issue_id: id_to_hex(&issue_id),
338        event_id: id_to_hex(&event_id),
339    })
340}
341
342/// Add or remove assignees.
343pub fn issue_assign(
344    ctx: &GriteContext,
345    opts: &IssueAssignOptions,
346) -> Result<IssueAssignResult, GriteError> {
347    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
348
349    let store = ctx.open_store()?;
350    let wal = ctx.open_wal()?;
351    let actor = ctx.actor_config.actor_id_bytes()?;
352
353    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
354    let _existing = store
355        .get_issue(&issue_id)?
356        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
357
358    let ts = current_ts();
359    let kind = if !opts.add.is_empty() {
360        EventKind::AssigneeAdded {
361            user: opts.add[0].clone(),
362        }
363    } else if !opts.remove.is_empty() {
364        EventKind::AssigneeRemoved {
365            user: opts.remove[0].clone(),
366        }
367    } else {
368        return Err(GriteError::InvalidArgs(
369            "No assignees to add or remove".to_string(),
370        ));
371    };
372
373    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
374    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
375    let event = ctx.sign_event(event);
376
377    insert_and_append(&store, &wal, &actor, &event)?;
378
379    Ok(IssueAssignResult {
380        issue_id: id_to_hex(&issue_id),
381        event_id: id_to_hex(&event_id),
382    })
383}
384
385/// Add a link.
386pub fn issue_link(
387    ctx: &GriteContext,
388    opts: &IssueLinkOptions,
389) -> Result<IssueLinkResult, GriteError> {
390    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
391
392    let store = ctx.open_store()?;
393    let wal = ctx.open_wal()?;
394    let actor = ctx.actor_config.actor_id_bytes()?;
395
396    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
397    let _existing = store
398        .get_issue(&issue_id)?
399        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
400
401    let ts = current_ts();
402    let kind = EventKind::LinkAdded {
403        url: opts.url.clone(),
404        note: opts.note.clone(),
405    };
406    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
407    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
408    let event = ctx.sign_event(event);
409
410    insert_and_append(&store, &wal, &actor, &event)?;
411
412    Ok(IssueLinkResult {
413        issue_id: id_to_hex(&issue_id),
414        event_id: id_to_hex(&event_id),
415    })
416}
417
418/// Add an attachment.
419pub fn issue_attach(
420    ctx: &GriteContext,
421    opts: &IssueAttachOptions,
422) -> Result<IssueAttachResult, GriteError> {
423    let _guard = LockGuard::acquire(ctx, &opts.issue_id, opts.acquire_lock)?;
424
425    let store = ctx.open_store()?;
426    let wal = ctx.open_wal()?;
427    let actor = ctx.actor_config.actor_id_bytes()?;
428
429    let issue_id = store.resolve_issue_id(&opts.issue_id)?;
430    let _existing = store
431        .get_issue(&issue_id)?
432        .ok_or_else(|| GriteError::NotFound(format!("Issue {} not found", opts.issue_id)))?;
433
434    let ts = current_ts();
435    let sha256_bytes = hex::decode(&opts.sha256)
436        .map_err(|e| GriteError::InvalidArgs(format!("Invalid sha256 hex: {}", e)))?
437        .try_into()
438        .map_err(|_| GriteError::InvalidArgs("sha256 must be 32 bytes".to_string()))?;
439    let kind = EventKind::AttachmentAdded {
440        name: opts.name.clone(),
441        sha256: sha256_bytes,
442        mime: opts.mime.clone(),
443    };
444    let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
445    let event = Event::new(event_id, issue_id, actor, ts, None, kind);
446    let event = ctx.sign_event(event);
447
448    insert_and_append(&store, &wal, &actor, &event)?;
449
450    Ok(IssueAttachResult {
451        issue_id: id_to_hex(&issue_id),
452        event_id: id_to_hex(&event_id),
453    })
454}