Skip to main content

solid_pod_rs_forge/
api_issues.rs

1//! The issues JSON API: list, thread, create, comment, close / reopen,
2//! retitle and label assignment.
3//!
4//! Follows the JSS `forge` `apiIssuesHandler` family (`apiIssueList`,
5//! `apiIssueGet`, `apiIssueCreate`, `apiIssueComment`, `apiIssueState`,
6//! `apiIssueRetitle`), cited by name only.
7//!
8//! Moderation rule (JSS `mayModerate`): close, reopen, retitle and label
9//! changes are index-only operations allowed to the **repo owner or the
10//! issue's author**. Every spine write runs under the per-repo issue lock
11//! ([`crate::locks`]); the lock is never held across the pod-body
12//! verification fetch.
13//!
14//! Divergence from JSS: a *pod* agent must hand a `resourceUrl` pointer (the
15//! body is PUT into its own pod by the client). JSS can also write the body
16//! into the pod itself with a forwarded bearer; the Rust forge has no
17//! loopback write, so that variant answers `422`. Podless `did:nostr` agents
18//! post the body inline and the forge hosts it, exactly as in JSS.
19
20use serde_json::{json, Value};
21
22use crate::api::{api_err, json_resp, read_json_body, unauthorised};
23use crate::bodies::{self, BodyOutcome};
24use crate::error::ForgeError;
25use crate::guard::ok_owner;
26use crate::hosted_edit::hosted_doc;
27use crate::labels::{effective_labels, resolve_labels, ItemKind};
28use crate::markdown::{render_with, MdCtx};
29use crate::ownership::ForgeAgent;
30use crate::request::{percent_decode, ForgeRequest, ForgeResponse};
31use crate::spine::issues::{
32    load_issue_index, save_issue_index, IssueEntry, IssueState, ThreadPointer,
33};
34use crate::{gitmark, hosted::HostedStore, ForgeService};
35
36/// Issues per page (JSS `ISSUES_PER_PAGE`).
37pub const ISSUES_PER_PAGE: usize = 25;
38/// Issue title cap in characters (JSS `ISSUE_TITLE_CAP`).
39pub const ISSUE_TITLE_CAP: usize = 256;
40/// Issue / comment body cap in bytes (JSS `ISSUE_BODY_CAP`).
41pub const ISSUE_BODY_CAP: usize = 64 * 1024;
42
43/// JSS `ISSUE_NUM_RE`: `^[1-9][0-9]{0,8}$`.
44///
45/// # Examples
46///
47/// ```
48/// use solid_pod_rs_forge::api_issues::parse_issue_number;
49/// assert_eq!(parse_issue_number("42"), Some(42));
50/// assert_eq!(parse_issue_number("0"), None);
51/// assert_eq!(parse_issue_number("007"), None);
52/// assert_eq!(parse_issue_number("1234567890"), None);
53/// ```
54#[must_use]
55pub fn parse_issue_number(s: &str) -> Option<u64> {
56    let ok =
57        (1..=9).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit()) && !s.starts_with('0');
58    if ok {
59        s.parse().ok()
60    } else {
61        None
62    }
63}
64
65/// Pod username (or hostname label) of a WebID, mirroring JSS
66/// `ownerFromAgent` for non-`did:nostr` agents.
67///
68/// # Examples
69///
70/// ```
71/// use solid_pod_rs_forge::api_issues::owner_from_webid;
72/// assert_eq!(owner_from_webid("https://pod.example/casey/profile/card#me").as_deref(), Some("casey"));
73/// assert_eq!(owner_from_webid("https://alice.pod.example/profile/card#me").as_deref(), Some("alice"));
74/// assert_eq!(owner_from_webid("did:web:example.com"), None);
75/// ```
76#[must_use]
77pub fn owner_from_webid(webid: &str) -> Option<String> {
78    let rest = webid
79        .strip_prefix("https://")
80        .or_else(|| webid.strip_prefix("http://"))?;
81    let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
82    let host = host.rsplit('@').next().unwrap_or(host);
83    let path = path.split(['?', '#']).next().unwrap_or("");
84    let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
85    let name = if segs.len() >= 2 && segs[0] != "profile" {
86        segs[0].to_string()
87    } else {
88        host.split(':')
89            .next()
90            .unwrap_or("")
91            .split('.')
92            .next()
93            .unwrap_or("")
94            .to_string()
95    };
96    (ok_owner(&name)).then_some(name)
97}
98
99/// `npub1…` (NIP-19) for a 64-hex public key.
100///
101/// # Examples
102///
103/// ```
104/// use solid_pod_rs_forge::api_issues::npub_encode;
105/// // BIP-173 / NIP-19 canonical vector
106/// assert_eq!(
107///     npub_encode("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d").as_deref(),
108///     Some("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6")
109/// );
110/// assert_eq!(npub_encode("nothex"), None);
111/// ```
112#[must_use]
113pub fn npub_encode(hex_pub: &str) -> Option<String> {
114    let bytes = hex::decode(hex_pub).ok().filter(|b| b.len() == 32)?;
115    let hrp = bech32::Hrp::parse("npub").ok()?;
116    bech32::encode::<bech32::Bech32>(hrp, &bytes).ok()
117}
118
119/// Additive author metadata for the JSON API (JSS `authorMeta`).
120///
121/// # Examples
122///
123/// ```
124/// use solid_pod_rs_forge::api_issues::author_meta;
125/// let hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
126/// let m = author_meta(&format!("did:nostr:{hex}"));
127/// assert_eq!(m["kind"], "nostr");
128/// assert_eq!(m["displayName"], "did:nostr:3bf0c63f…");
129/// assert_eq!(m["npub"], "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6");
130/// let w = author_meta("https://pod.example/casey/profile/card#me");
131/// assert_eq!(w["kind"], "webid");
132/// assert_eq!(w["displayName"], "casey");
133/// ```
134#[must_use]
135pub fn author_meta(agent: &str) -> Value {
136    if let Some(hex) = agent
137        .strip_prefix("did:nostr:")
138        .filter(|h| h.len() == 64 && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
139    {
140        return json!({
141            "id": agent,
142            "displayName": gitmark::did_short(hex),
143            "npub": npub_encode(hex),
144            "kind": "nostr",
145        });
146    }
147    json!({
148        "id": agent,
149        "displayName": owner_from_webid(agent).unwrap_or_else(|| agent.to_string()),
150        "kind": "webid",
151    })
152}
153
154fn state_str(s: IssueState) -> &'static str {
155    match s {
156        IssueState::Open => "open",
157        IssueState::Closed => "closed",
158        IssueState::Merged => "merged",
159    }
160}
161
162impl ForgeService {
163    /// JSS `mayModerate`: the repo owner or the item's author.
164    fn may_moderate(agent: &ForgeAgent, owner: &str, author: &str) -> bool {
165        agent.owner() == Some(owner) || agent.author_id() == author
166    }
167
168    /// `/api/repos/<o>/<r>/issues[/…]` (JSS `apiIssuesHandler`).
169    pub(crate) async fn h_api_issues(
170        &self,
171        req: &ForgeRequest,
172        agent: &ForgeAgent,
173        owner: &str,
174        repo: &str,
175        tail: &[String],
176    ) -> Result<ForgeResponse, ForgeError> {
177        let m = req.method.to_ascii_uppercase();
178        let read = m == "GET" || m == "HEAD";
179        let Some(first) = tail.first() else {
180            return match m.as_str() {
181                "POST" => self.api_issue_create(req, agent, owner, repo).await,
182                _ if read => self.api_issue_list(req, owner, repo).await,
183                _ => Ok(api_err(405, "method not allowed")),
184            };
185        };
186        let Some(number) = parse_issue_number(first) else {
187            return Ok(api_err(404, "not found"));
188        };
189        match (tail.len(), tail.get(1).map(String::as_str)) {
190            (1, _) if read => self.api_issue_get(req, owner, repo, number).await,
191            (1, _) if m == "PATCH" => {
192                self.api_issue_retitle(req, agent, owner, repo, number)
193                    .await
194            }
195            (1, _) => Ok(api_err(405, "method not allowed")),
196            (2, Some("labels")) if m == "PUT" => {
197                self.put_item_labels(req, agent, owner, repo, number, ItemKind::Issue)
198                    .await
199            }
200            (2, Some("labels")) => Ok(api_err(405, "method not allowed")),
201            (2, Some("comments" | "close" | "reopen")) if m != "POST" => {
202                Ok(api_err(405, "method not allowed"))
203            }
204            (2, Some("comments")) => {
205                self.api_issue_comment(req, agent, owner, repo, number)
206                    .await
207            }
208            (2, Some(verb @ ("close" | "reopen"))) => {
209                let state = if verb == "close" {
210                    IssueState::Closed
211                } else {
212                    IssueState::Open
213                };
214                self.api_issue_state(agent, owner, repo, number, state)
215                    .await
216            }
217            _ => Ok(api_err(404, "not found")),
218        }
219    }
220
221    async fn api_issue_list(
222        &self,
223        req: &ForgeRequest,
224        owner: &str,
225        repo: &str,
226    ) -> Result<ForgeResponse, ForgeError> {
227        let idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
228        let mut all: Vec<&IssueEntry> = idx.issues.values().collect();
229        all.sort_by_key(|i| std::cmp::Reverse(i.number));
230        let open_count = all.iter().filter(|i| i.state == IssueState::Open).count();
231        let state = if req.query_param("state").as_deref() == Some("closed") {
232            IssueState::Closed
233        } else {
234            IssueState::Open
235        };
236        let label: Option<String> = req
237            .query_param("label")
238            .map(|l| percent_decode(&l).chars().take(50).collect::<String>())
239            .filter(|l| !l.is_empty());
240        let page = req
241            .query_param("page")
242            .and_then(|p| p.trim().parse::<i64>().ok())
243            .unwrap_or(1)
244            .clamp(1, 10000) as usize;
245        let defs = effective_labels(&idx);
246        let filtered: Vec<&&IssueEntry> = all
247            .iter()
248            .filter(|i| {
249                i.state == state
250                    && label
251                        .as_ref()
252                        .is_none_or(|l| i.labels.iter().any(|x| x == l))
253            })
254            .collect();
255        let rows: Vec<Value> = filtered
256            .iter()
257            .skip((page - 1) * ISSUES_PER_PAGE)
258            .take(ISSUES_PER_PAGE)
259            .map(|i| {
260                json!({
261                    "number": i.number,
262                    "title": i.title,
263                    "state": state_str(i.state),
264                    "author": i.author,
265                    "authorInfo": author_meta(&i.author),
266                    "createdAt": i.created_at,
267                    "labels": resolve_labels(&i.labels, &defs),
268                    "comments": i.thread.len().saturating_sub(1),
269                })
270            })
271            .collect();
272        Ok(json_resp(
273            200,
274            &json!({
275                "state": state_str(state),
276                "page": page,
277                "perPage": ISSUES_PER_PAGE,
278                "hasMore": filtered.len() > page * ISSUES_PER_PAGE,
279                "openCount": open_count,
280                "closedCount": all.len() - open_count,
281                "issues": rows,
282            }),
283        ))
284    }
285
286    async fn api_issue_get(
287        &self,
288        req: &ForgeRequest,
289        owner: &str,
290        repo: &str,
291        number: u64,
292    ) -> Result<ForgeResponse, ForgeError> {
293        let idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
294        let Some(issue) = idx.issues.get(&number) else {
295            return Ok(api_err(404, "not found"));
296        };
297        let hosted: std::sync::Arc<dyn bodies::HostedReader> = self.hosted.clone();
298        let rendered = bodies::render_thread(
299            &issue.thread,
300            self.loopback.clone(),
301            Some(hosted),
302            &self.cfg,
303        )
304        .await;
305        let base = format!("{}/{owner}/{repo}", self.prefix());
306        let ctx = MdCtx {
307            raw_base: format!("{base}/raw/HEAD"),
308            blob_base: format!("{base}/blob/HEAD"),
309        };
310        let thread: Vec<Value> = issue
311            .thread
312            .iter()
313            .zip(rendered)
314            .map(|(p, r)| {
315                let (body, removed) = match &r.body {
316                    BodyOutcome::Present(t) => (
317                        Some(t.chars().take(ISSUE_BODY_CAP).collect::<String>()),
318                        false,
319                    ),
320                    _ => (None, true),
321                };
322                json!({
323                    "author": p.author,
324                    "at": p.at,
325                    "resourceUrl": self.hosted_public_url(req, &p.resource_url),
326                    "hosted": p.hosted,
327                    "body": body,
328                    "removed": removed,
329                    "authorInfo": author_meta(&p.author),
330                    "html": body.as_deref().map(|b| render_with(b, &ctx)),
331                })
332            })
333            .collect();
334        Ok(json_resp(
335            200,
336            &json!({
337                "number": issue.number,
338                "title": issue.title,
339                "state": state_str(issue.state),
340                "author": issue.author,
341                "authorInfo": author_meta(&issue.author),
342                "createdAt": issue.created_at,
343                "labels": resolve_labels(&issue.labels, &effective_labels(&idx)),
344                "thread": thread,
345            }),
346        ))
347    }
348
349    /// Build the thread pointer for a create / comment call. Hosted ref for
350    /// nostr agents, verified pod pointer for pod agents. `Err` carries the
351    /// JSON error response to send.
352    #[allow(clippy::too_many_arguments)]
353    async fn api_pointer(
354        &self,
355        req: &ForgeRequest,
356        agent: &ForgeAgent,
357        owner: &str,
358        repo: &str,
359        kind: &str,
360        issue: Option<u64>,
361        title: Option<&str>,
362        body: &str,
363        resource_url: Option<&str>,
364    ) -> Result<ThreadPointer, ForgeResponse> {
365        let backend = |e: ForgeError| ForgeResponse::error(e.status_code(), e.to_string());
366        match agent {
367            ForgeAgent::Anonymous => Err(unauthorised()),
368            ForgeAgent::Nostr { pubkey_hex } => {
369                let doc = hosted_doc(
370                    kind,
371                    owner,
372                    repo,
373                    issue,
374                    title,
375                    body,
376                    &agent.author_id(),
377                    crate::now_secs(),
378                );
379                let rref = self
380                    .hosted
381                    .write_doc(pubkey_hex, &doc)
382                    .await
383                    .map_err(backend)?;
384                Ok(ThreadPointer {
385                    author: agent.author_id(),
386                    resource_url: rref,
387                    at: crate::now_secs(),
388                    hosted: true,
389                })
390            }
391            ForgeAgent::Pod { .. } => match resource_url.filter(|u| !u.is_empty()) {
392                Some(url) => self
393                    .verify_pod_pointer(req, agent, owner, repo, url)
394                    .await
395                    .map_err(backend),
396                None => Err(api_err(
397                    422,
398                    "resourceUrl required: write the body into your own pod and pass its URL",
399                )),
400            },
401        }
402    }
403
404    async fn api_issue_create(
405        &self,
406        req: &ForgeRequest,
407        agent: &ForgeAgent,
408        owner: &str,
409        repo: &str,
410    ) -> Result<ForgeResponse, ForgeError> {
411        let p = read_json_body(req);
412        if matches!(agent, ForgeAgent::Anonymous) {
413            return Ok(unauthorised());
414        }
415        let Some(p) = p else {
416            return Ok(api_err(400, "invalid JSON body"));
417        };
418        let title = p.get("title").and_then(Value::as_str).map_or("", str::trim);
419        let body = p.get("body").and_then(Value::as_str).unwrap_or("");
420        let n = title.chars().count();
421        if n == 0 || n > ISSUE_TITLE_CAP {
422            return Ok(api_err(
423                422,
424                &format!("title required (1-{ISSUE_TITLE_CAP} chars)"),
425            ));
426        }
427        if body.len() > ISSUE_BODY_CAP {
428            return Ok(api_err(422, "body too large"));
429        }
430        // The next number is allocated under the lock; the hosted doc embeds
431        // it, so peek, write, then re-check under the lock.
432        let peek = load_issue_index(self.spine.as_ref(), owner, repo)
433            .await?
434            .next;
435        let rurl = p.get("resourceUrl").and_then(Value::as_str);
436        let pointer = match self
437            .api_pointer(
438                req,
439                agent,
440                owner,
441                repo,
442                "ForgeIssue",
443                Some(peek),
444                Some(title),
445                body,
446                rurl,
447            )
448            .await
449        {
450            Ok(ptr) => ptr,
451            Err(resp) => return Ok(resp),
452        };
453        let _g = self.locks.issues.lock(owner, repo).await;
454        let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
455        let number = idx.allocate(IssueEntry {
456            number: 0,
457            title: title.to_string(),
458            state: IssueState::Open,
459            author: agent.author_id(),
460            created_at: crate::now_secs(),
461            thread: vec![pointer.clone()],
462            labels: Vec::new(),
463        });
464        save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
465        let mut out = json!({
466            "number": number,
467            "url": format!("{}/{owner}/{repo}/issues/{number}", self.prefix()),
468            "resourceUrl": self.hosted_public_url(req, &pointer.resource_url),
469        });
470        if pointer.hosted {
471            out["hosted"] = json!(true);
472        }
473        Ok(json_resp(201, &out))
474    }
475
476    async fn api_issue_comment(
477        &self,
478        req: &ForgeRequest,
479        agent: &ForgeAgent,
480        owner: &str,
481        repo: &str,
482        number: u64,
483    ) -> Result<ForgeResponse, ForgeError> {
484        let p = read_json_body(req);
485        if matches!(agent, ForgeAgent::Anonymous) {
486            return Ok(unauthorised());
487        }
488        let Some(p) = p else {
489            return Ok(api_err(400, "invalid JSON body"));
490        };
491        let body = p.get("body").and_then(Value::as_str).unwrap_or("");
492        let rurl = p
493            .get("resourceUrl")
494            .and_then(Value::as_str)
495            .filter(|u| !u.is_empty() && matches!(agent, ForgeAgent::Pod { .. }));
496        if rurl.is_none() && body.trim().is_empty() {
497            return Ok(api_err(422, "body required"));
498        }
499        if body.len() > ISSUE_BODY_CAP {
500            return Ok(api_err(422, "body too large"));
501        }
502        // Fail fast before hosting anything for a missing issue.
503        if !load_issue_index(self.spine.as_ref(), owner, repo)
504            .await?
505            .issues
506            .contains_key(&number)
507        {
508            return Ok(api_err(404, "not found"));
509        }
510        let pointer = match self
511            .api_pointer(
512                req,
513                agent,
514                owner,
515                repo,
516                "ForgeComment",
517                Some(number),
518                None,
519                body,
520                rurl,
521            )
522            .await
523        {
524            Ok(ptr) => ptr,
525            Err(resp) => return Ok(resp),
526        };
527        let _g = self.locks.issues.lock(owner, repo).await;
528        let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
529        let verdict = match idx.issues.get_mut(&number) {
530            None => Some(api_err(404, "not found")),
531            Some(issue) if issue.thread.len() >= self.cfg.thread_cap => {
532                Some(api_err(422, "thread is full"))
533            }
534            Some(issue) => {
535                issue.thread.push(pointer.clone());
536                None
537            }
538        };
539        if let Some(resp) = verdict {
540            // Do not leave an orphaned hosted body behind.
541            if let Some((hex, id)) = pointer
542                .hosted
543                .then(|| HostedStore::parse_ref(&pointer.resource_url))
544                .flatten()
545            {
546                let _ = self.hosted.delete(&hex, &id).await;
547            }
548            return Ok(resp);
549        }
550        save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
551        let comments = idx.issues.get(&number).map_or(0, |i| i.thread.len() - 1);
552        let mut out = json!({
553            "number": number,
554            "comments": comments,
555            "resourceUrl": self.hosted_public_url(req, &pointer.resource_url),
556        });
557        if pointer.hosted {
558            out["hosted"] = json!(true);
559        }
560        Ok(json_resp(201, &out))
561    }
562
563    async fn api_issue_state(
564        &self,
565        agent: &ForgeAgent,
566        owner: &str,
567        repo: &str,
568        number: u64,
569        state: IssueState,
570    ) -> Result<ForgeResponse, ForgeError> {
571        if matches!(agent, ForgeAgent::Anonymous) {
572            return Ok(unauthorised());
573        }
574        let _g = self.locks.issues.lock(owner, repo).await;
575        let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
576        let Some(issue) = idx.issues.get(&number) else {
577            return Ok(api_err(404, "not found"));
578        };
579        if !Self::may_moderate(agent, owner, &issue.author) {
580            return Ok(api_err(
581                403,
582                "only the repo owner or the issue author may do that",
583            ));
584        }
585        idx.set_state(number, state);
586        save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
587        Ok(json_resp(
588            200,
589            &json!({"number": number, "state": state_str(state)}),
590        ))
591    }
592
593    async fn api_issue_retitle(
594        &self,
595        req: &ForgeRequest,
596        agent: &ForgeAgent,
597        owner: &str,
598        repo: &str,
599        number: u64,
600    ) -> Result<ForgeResponse, ForgeError> {
601        let p = read_json_body(req);
602        if matches!(agent, ForgeAgent::Anonymous) {
603            return Ok(unauthorised());
604        }
605        let Some(p) = p else {
606            return Ok(api_err(400, "invalid JSON body"));
607        };
608        let title = p.get("title").and_then(Value::as_str).map_or("", str::trim);
609        let n = title.chars().count();
610        if n == 0 || n > ISSUE_TITLE_CAP {
611            return Ok(api_err(
612                422,
613                &format!("title required (1-{ISSUE_TITLE_CAP} chars)"),
614            ));
615        }
616        let _g = self.locks.issues.lock(owner, repo).await;
617        let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
618        let Some(issue) = idx.issues.get_mut(&number) else {
619            return Ok(api_err(404, "not found"));
620        };
621        if !Self::may_moderate(agent, owner, &issue.author) {
622            return Ok(api_err(
623                403,
624                "only the repo owner or the issue author may do that",
625            ));
626        }
627        issue.title = title.to_string();
628        save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
629        Ok(json_resp(200, &json!({"number": number, "title": title})))
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::api::tests::{casey, dana, fixture, get_json, rq};
637    use crate::ForgeConfig;
638
639    const HEX_A: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
640    const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
641    const BASE: &str = "/forge/api/repos/casey/demo";
642
643    fn nostr(h: &str) -> ForgeAgent {
644        ForgeAgent::Nostr {
645            pubkey_hex: h.into(),
646        }
647    }
648
649    async fn call(
650        svc: &ForgeService,
651        m: &str,
652        path: &str,
653        body: &str,
654        a: ForgeAgent,
655    ) -> (u16, Value) {
656        let r = svc.handle(rq(m, path, "", body), a).await.unwrap();
657        (
658            r.status,
659            serde_json::from_slice(&r.body).unwrap_or(Value::Null),
660        )
661    }
662
663    /// Seed issues straight into the spine (pod-body creation needs a loopback
664    /// server; the state / retitle / label rules do not).
665    async fn seed(svc: &ForgeService) {
666        let mut idx = load_issue_index(svc.spine.as_ref(), "casey", "demo")
667            .await
668            .unwrap();
669        for (title, author) in [
670            ("Clone fails on Windows", casey().author_id()),
671            ("Second", casey().author_id()),
672        ] {
673            idx.allocate(IssueEntry {
674                number: 0,
675                title: title.into(),
676                state: IssueState::Open,
677                author,
678                created_at: 5,
679                thread: vec![ThreadPointer {
680                    author: casey().author_id(),
681                    resource_url: "hosted:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/00000000-0000-4000-8000-000000000000".into(),
682                    at: 5,
683                    hosted: true,
684                }],
685                labels: vec![],
686            });
687        }
688        save_issue_index(svc.spine.as_ref(), "casey", "demo", &idx)
689            .await
690            .unwrap();
691    }
692
693    // JSS "dana cannot close casey's issue (403); anonymous close is 401"
694    // + "casey (owner) closes; the list filters split open/closed correctly"
695    #[tokio::test]
696    async fn close_reopen_rules_and_list_split() {
697        let (_td, svc, _) = fixture();
698        seed(&svc).await;
699        let close = |a: ForgeAgent| {
700            let svc = svc.clone();
701            async move { call(&svc, "POST", &format!("{BASE}/issues/1/close"), "", a).await }
702        };
703        assert_eq!(close(dana()).await.0, 403);
704        assert_eq!(close(ForgeAgent::Anonymous).await.0, 401);
705        let (_, t) = get_json(&svc, &format!("{BASE}/issues/1"), "", ForgeAgent::Anonymous).await;
706        assert_eq!(t["state"], "open", "still open");
707        let (st, v) = close(casey()).await;
708        assert_eq!(st, 200);
709        assert_eq!(v, json!({"number": 1, "state": "closed"}));
710        let (_, open) = get_json(
711            &svc,
712            &format!("{BASE}/issues"),
713            "state=open",
714            ForgeAgent::Anonymous,
715        )
716        .await;
717        let nums: Vec<u64> = open["issues"]
718            .as_array()
719            .unwrap()
720            .iter()
721            .map(|i| i["number"].as_u64().unwrap())
722            .collect();
723        assert_eq!(nums, vec![2]);
724        assert_eq!(open["openCount"], 1);
725        assert_eq!(open["closedCount"], 1);
726        let (_, closed) = get_json(
727            &svc,
728            &format!("{BASE}/issues"),
729            "state=closed",
730            ForgeAgent::Anonymous,
731        )
732        .await;
733        assert_eq!(closed["issues"][0]["number"], 1);
734        assert_eq!(closed["issues"][0]["comments"], 0);
735        let (st, re) = call(
736            &svc,
737            "POST",
738            &format!("{BASE}/issues/1/reopen"),
739            "",
740            casey(),
741        )
742        .await;
743        assert_eq!((st, re), (200, json!({"number": 1, "state": "open"})));
744        assert_eq!(
745            call(
746                &svc,
747                "POST",
748                &format!("{BASE}/issues/99/close"),
749                "",
750                casey()
751            )
752            .await
753            .0,
754            404
755        );
756        assert_eq!(
757            call(&svc, "GET", &format!("{BASE}/issues/1/close"), "", casey())
758                .await
759                .0,
760            405
761        );
762    }
763
764    // JSS "reopen + retitle work for owner/author; a third party gets 403"
765    #[tokio::test]
766    async fn retitle_rules() {
767        let (_td, svc, _) = fixture();
768        seed(&svc).await;
769        let url = format!("{BASE}/issues/1");
770        let (st, _) = call(
771            &svc,
772            "PATCH",
773            &url,
774            r#"{"title":"Clone fails on Windows 11"}"#,
775            casey(),
776        )
777        .await;
778        assert_eq!(st, 200);
779        let (_, t) = get_json(&svc, &url, "", ForgeAgent::Anonymous).await;
780        assert_eq!(t["title"], "Clone fails on Windows 11");
781        assert_eq!(
782            call(&svc, "PATCH", &url, r#"{"title":"hijack"}"#, dana())
783                .await
784                .0,
785            403
786        );
787        assert_eq!(
788            call(
789                &svc,
790                "PATCH",
791                &url,
792                r#"{"title":"x"}"#,
793                ForgeAgent::Anonymous
794            )
795            .await
796            .0,
797            401
798        );
799        assert_eq!(
800            call(&svc, "PATCH", &url, r#"{"title":"  "}"#, casey())
801                .await
802                .0,
803            422
804        );
805        let long = json!({"title": "t".repeat(257)}).to_string();
806        assert_eq!(call(&svc, "PATCH", &url, &long, casey()).await.0, 422);
807        assert_eq!(call(&svc, "PATCH", &url, "nope", casey()).await.0, 400);
808        assert_eq!(call(&svc, "DELETE", &url, "", casey()).await.0, 405);
809    }
810
811    // JSS "a non-owner issue author can close their own issue" + numbering keeps counting
812    #[tokio::test]
813    async fn issue_author_may_moderate_without_owning_the_repo() {
814        let (_td, svc, _) = fixture();
815        seed(&svc).await;
816        // a podless dana-equivalent: nostr agent creates, then closes its own issue
817        let (st, v) = call(
818            &svc,
819            "POST",
820            &format!("{BASE}/issues"),
821            r#"{"title":"Docs typo","body":"in the README"}"#,
822            nostr(HEX_B),
823        )
824        .await;
825        assert_eq!(st, 201);
826        assert_eq!(v["number"], 3, "numbering keeps counting");
827        assert_eq!(v["hosted"], true);
828        assert_eq!(v["url"], "/forge/casey/demo/issues/3");
829        let (st, _) = call(
830            &svc,
831            "POST",
832            &format!("{BASE}/issues/3/close"),
833            "",
834            nostr(HEX_B),
835        )
836        .await;
837        assert_eq!(
838            st, 200,
839            "the issue author may close, without owning the repo"
840        );
841        assert_eq!(
842            call(
843                &svc,
844                "POST",
845                &format!("{BASE}/issues/3/reopen"),
846                "",
847                nostr(HEX_A)
848            )
849            .await
850            .0,
851            403
852        );
853    }
854
855    // JSS "issue list pagination shape"
856    #[tokio::test]
857    async fn list_pagination_shape() {
858        let (_td, svc, _) = fixture();
859        seed(&svc).await;
860        let (_, p1) = get_json(&svc, &format!("{BASE}/issues"), "", ForgeAgent::Anonymous).await;
861        assert_eq!(p1["state"], "open");
862        assert_eq!(p1["page"], 1);
863        assert_eq!(p1["perPage"], 25);
864        assert_eq!(p1["hasMore"], false);
865        let (_, p9) = get_json(
866            &svc,
867            &format!("{BASE}/issues"),
868            "page=9",
869            ForgeAgent::Anonymous,
870        )
871        .await;
872        assert_eq!(p9["issues"], json!([]));
873        assert_eq!(p9["hasMore"], false);
874        let (_, junk) = get_json(
875            &svc,
876            &format!("{BASE}/issues"),
877            "page=abc",
878            ForgeAgent::Anonymous,
879        )
880        .await;
881        assert_eq!(junk["page"], 1);
882    }
883
884    // JSS nostr-agent beat: hosted issue, second key comments, thread carries hosted + nostr author fields
885    #[tokio::test]
886    async fn nostr_issue_comment_and_thread_json() {
887        let td = tempfile::TempDir::new().unwrap();
888        let cfg = ForgeConfig {
889            base_url: Some("https://forge.example".into()),
890            ..Default::default()
891        };
892        let svc = ForgeService::new(cfg, td.path()).unwrap();
893        let bare = td.path().join(format!("repos/{HEX_A}/nrepo.git"));
894        std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
895        crate::gitrun::testutil::git(
896            td.path(),
897            &["init", "--bare", "--quiet", bare.to_str().unwrap()],
898        );
899        let base = format!("/forge/api/repos/{HEX_A}/nrepo");
900        let (st, j) = call(
901            &svc,
902            "POST",
903            &format!("{base}/issues"),
904            r#"{"title":"Nostr-born issue","body":"signed with **schnorr**"}"#,
905            nostr(HEX_A),
906        )
907        .await;
908        assert_eq!(st, 201);
909        assert_eq!(j["number"], 1);
910        assert_eq!(j["hosted"], true);
911        let url = j["resourceUrl"].as_str().unwrap().to_string();
912        assert!(
913            url.starts_with(&format!("https://forge.example/forge/api/hosted/{HEX_A}/")),
914            "{url}"
915        );
916        // the hosted doc is publicly fetchable
917        let path = url.strip_prefix("https://forge.example").unwrap();
918        let doc = call(&svc, "GET", path, "", ForgeAgent::Anonymous).await.1;
919        assert_eq!(doc["author"], format!("did:nostr:{HEX_A}"));
920        assert_eq!(doc["hosted"], true);
921        assert!(doc["body"].as_str().unwrap().contains("**schnorr**"));
922        assert_eq!(doc["type"], "ForgeIssue");
923        // second key comments
924        let (st, c) = call(
925            &svc,
926            "POST",
927            &format!("{base}/issues/1/comments"),
928            r#"{"body":"confirmed from another key"}"#,
929            nostr(HEX_B),
930        )
931        .await;
932        assert_eq!(st, 201);
933        assert_eq!(c["comments"], 1);
934        assert!(c["resourceUrl"]
935            .as_str()
936            .unwrap()
937            .contains(&format!("/forge/api/hosted/{HEX_B}/")));
938        let t = call(
939            &svc,
940            "GET",
941            &format!("{base}/issues/1"),
942            "",
943            ForgeAgent::Anonymous,
944        )
945        .await
946        .1;
947        assert_eq!(t["author"], format!("did:nostr:{HEX_A}"));
948        assert_eq!(
949            t["authorInfo"],
950            json!({"id": format!("did:nostr:{HEX_A}"), "displayName": format!("did:nostr:{}…", &HEX_A[..8]),
951                   "npub": "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6", "kind": "nostr"})
952        );
953        assert_eq!(t["thread"][0]["hosted"], true);
954        assert!(t["thread"][0]["html"]
955            .as_str()
956            .unwrap()
957            .contains("<strong>schnorr</strong>"));
958        assert_eq!(t["thread"][1]["authorInfo"]["kind"], "nostr");
959        // the author deletes the comment: removed placeholder, pointer remains
960        let cpath = c["resourceUrl"]
961            .as_str()
962            .unwrap()
963            .strip_prefix("https://forge.example")
964            .unwrap()
965            .to_string();
966        assert_eq!(call(&svc, "DELETE", &cpath, "", nostr(HEX_B)).await.0, 200);
967        let t = call(
968            &svc,
969            "GET",
970            &format!("{base}/issues/1"),
971            "",
972            ForgeAgent::Anonymous,
973        )
974        .await
975        .1;
976        assert_eq!(t["thread"][1]["removed"], true);
977        assert!(t["thread"][1]["body"].is_null());
978        assert!(t["thread"][1]["html"].is_null());
979        assert_eq!(
980            t["thread"][1]["author"],
981            format!("did:nostr:{HEX_B}"),
982            "the pointer (who/when) remains"
983        );
984        // an edit shows up re-rendered
985        let (st, _) = call(
986            &svc,
987            "PUT",
988            path,
989            r#"{"body":"edited: now with *more* schnorr"}"#,
990            nostr(HEX_A),
991        )
992        .await;
993        assert_eq!(st, 200);
994        let t = call(
995            &svc,
996            "GET",
997            &format!("{base}/issues/1"),
998            "",
999            ForgeAgent::Anonymous,
1000        )
1001        .await
1002        .1;
1003        assert!(t["thread"][0]["html"]
1004            .as_str()
1005            .unwrap()
1006            .contains("<em>more</em>"));
1007    }
1008
1009    #[tokio::test]
1010    async fn create_and_comment_validation() {
1011        let (_td, svc, _) = fixture();
1012        seed(&svc).await;
1013        let url = format!("{BASE}/issues");
1014        assert_eq!(
1015            call(
1016                &svc,
1017                "POST",
1018                &url,
1019                r#"{"title":"x"}"#,
1020                ForgeAgent::Anonymous
1021            )
1022            .await
1023            .0,
1024            401
1025        );
1026        assert_eq!(call(&svc, "POST", &url, "junk", nostr(HEX_A)).await.0, 400);
1027        assert_eq!(
1028            call(&svc, "POST", &url, r#"{"title":""}"#, nostr(HEX_A))
1029                .await
1030                .0,
1031            422
1032        );
1033        let big = json!({"title":"t","body":"x".repeat(ISSUE_BODY_CAP + 1)}).to_string();
1034        assert_eq!(call(&svc, "POST", &url, &big, nostr(HEX_A)).await.0, 422);
1035        // a pod agent without a pointer cannot have the forge write into its pod
1036        assert_eq!(
1037            call(&svc, "POST", &url, r#"{"title":"t","body":"b"}"#, dana())
1038                .await
1039                .0,
1040            422
1041        );
1042        // pod pointers outside the caller's own forge area are refused (SSRF guard)
1043        let evil = r#"{"title":"t","resourceUrl":"http://169.254.169.254/dana/public/forge/casey--demo/x.jsonld"}"#;
1044        assert_eq!(call(&svc, "POST", &url, evil, dana()).await.0, 403);
1045        let c = format!("{BASE}/issues/1/comments");
1046        assert_eq!(
1047            call(&svc, "POST", &c, r#"{"body":"  "}"#, nostr(HEX_A))
1048                .await
1049                .0,
1050            422
1051        );
1052        assert_eq!(
1053            call(
1054                &svc,
1055                "POST",
1056                &format!("{BASE}/issues/77/comments"),
1057                r#"{"body":"x"}"#,
1058                nostr(HEX_A)
1059            )
1060            .await
1061            .0,
1062            404
1063        );
1064        assert_eq!(
1065            call(&svc, "POST", &c, r#"{"body":"x"}"#, ForgeAgent::Anonymous)
1066                .await
1067                .0,
1068            401
1069        );
1070        // unknown / malformed numbers and verbs
1071        assert_eq!(
1072            call(&svc, "GET", &format!("{BASE}/issues/0"), "", dana())
1073                .await
1074                .0,
1075            404
1076        );
1077        assert_eq!(
1078            call(&svc, "GET", &format!("{BASE}/issues/1/nope"), "", dana())
1079                .await
1080                .0,
1081            404
1082        );
1083        assert_eq!(call(&svc, "PUT", &url, "", dana()).await.0, 405);
1084    }
1085
1086    #[tokio::test]
1087    async fn thread_cap_leaves_no_orphan_body() {
1088        let td = tempfile::TempDir::new().unwrap();
1089        let cfg = ForgeConfig {
1090            thread_cap: 1,
1091            ..Default::default()
1092        };
1093        let svc = ForgeService::new(cfg, td.path()).unwrap();
1094        crate::gitrun::testutil::demo_repo(td.path());
1095        seed(&svc).await;
1096        let r = call(
1097            &svc,
1098            "POST",
1099            &format!("{BASE}/issues/1/comments"),
1100            r#"{"body":"x"}"#,
1101            nostr(HEX_A),
1102        )
1103        .await;
1104        assert_eq!(r.0, 422);
1105        let dir = td.path().join("hosted").join(HEX_A);
1106        let left = std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0);
1107        assert_eq!(left, 0, "hosted body removed again");
1108    }
1109
1110    #[test]
1111    fn helpers() {
1112        assert_eq!(
1113            owner_from_webid("https://pod.example/profile/card#me").as_deref(),
1114            Some("pod")
1115        );
1116        assert_eq!(owner_from_webid("ftp://x/y"), None);
1117        assert_eq!(author_meta("anon")["displayName"], "anon");
1118    }
1119}