1use 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
36pub const ISSUES_PER_PAGE: usize = 25;
38pub const ISSUE_TITLE_CAP: usize = 256;
40pub const ISSUE_BODY_CAP: usize = 64 * 1024;
42
43#[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#[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#[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#[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 fn may_moderate(agent: &ForgeAgent, owner: &str, author: &str) -> bool {
165 agent.owner() == Some(owner) || agent.author_id() == author
166 }
167
168 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 #[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 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 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 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 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 #[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 #[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 #[tokio::test]
813 async fn issue_author_may_moderate_without_owning_the_repo() {
814 let (_td, svc, _) = fixture();
815 seed(&svc).await;
816 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 #[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 #[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 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 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 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 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 assert_eq!(
1037 call(&svc, "POST", &url, r#"{"title":"t","body":"b"}"#, dana())
1038 .await
1039 .0,
1040 422
1041 );
1042 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 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}