1use std::collections::BTreeMap;
24use std::sync::Arc;
25
26use serde::{Deserialize, Serialize};
27
28use crate::bodies::{self, BodyOutcome, HostedReader};
29use crate::compare::{self, commit_rows, query_decoded, valid_owner, valid_ref, HeadAccess};
30use crate::error::ForgeError;
31use crate::html::{agent_label, crumbs, page};
32use crate::merge::{NoopTipRecorder, TipRecorder};
33use crate::prgit;
34use crate::repo;
35use crate::request::{esc, parse_form};
36use crate::spine::issues::{IssueState, ThreadPointer};
37use crate::spine::SpineStore;
38use crate::{ForgeAgent, ForgeRequest, ForgeResponse, ForgeService};
39
40pub const KIND: &str = "pulls";
42
43pub const PULLS_PER_PAGE: usize = 25;
45pub const TITLE_CAP: usize = 256;
47pub const BODY_CAP: usize = 64 * 1024;
49pub const THREAD_CAP: usize = 500;
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct PullHead {
55 pub owner: String,
57 pub repo: String,
59 #[serde(rename = "ref")]
61 pub ref_name: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct MergeRecord {
68 pub sha: String,
70 pub merged_by: String,
72 pub at: u64,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub base_sha: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub head_sha: Option<String>,
80 #[serde(default)]
82 pub fast_forward: bool,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct PullEntry {
89 pub number: u64,
91 pub title: String,
93 pub state: IssueState,
95 pub author: String,
97 pub created_at: u64,
99 pub base: String,
101 pub head: PullHead,
103 #[serde(default)]
105 pub merged: Option<MergeRecord>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub labels: Option<Vec<String>>,
109 pub thread: Vec<ThreadPointer>,
111}
112
113impl PullEntry {
114 #[must_use]
116 pub fn head_spec(&self) -> String {
117 format!("{}:{}", self.head.owner, self.head.ref_name)
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct PullIndex {
124 pub next: u64,
126 #[serde(default)]
128 pub pulls: BTreeMap<u64, PullEntry>,
129}
130
131impl Default for PullIndex {
132 fn default() -> Self {
133 Self {
134 next: 1,
135 pulls: BTreeMap::new(),
136 }
137 }
138}
139
140pub async fn load_pull_index(
143 store: &dyn SpineStore,
144 owner: &str,
145 repo: &str,
146) -> Result<PullIndex, ForgeError> {
147 match store.load(KIND, owner, repo).await? {
148 Some(b) => {
149 let idx: PullIndex = serde_json::from_slice(&b).map_err(|e| {
150 ForgeError::Backend(format!("corrupt pull index {owner}/{repo}: {e}"))
151 })?;
152 if idx.next < 1 {
153 return Err(ForgeError::Backend(format!(
154 "corrupt pull index {owner}/{repo}: next < 1"
155 )));
156 }
157 Ok(idx)
158 }
159 None => Ok(PullIndex::default()),
160 }
161}
162
163pub async fn save_pull_index(
165 store: &dyn SpineStore,
166 owner: &str,
167 repo: &str,
168 idx: &PullIndex,
169) -> Result<(), ForgeError> {
170 store
171 .store(KIND, owner, repo, &serde_json::to_vec(idx)?)
172 .await
173}
174
175#[must_use]
177pub fn state_str(s: IssueState) -> &'static str {
178 match s {
179 IssueState::Open => "open",
180 IssueState::Closed => "closed",
181 IssueState::Merged => "merged",
182 }
183}
184
185pub trait PullLabels: Send + Sync {
190 fn resolve(&self, owner: &str, repo: &str, names: &[String]) -> Vec<serde_json::Value>;
192}
193
194#[derive(Debug, Clone, Copy, Default)]
196pub struct PlainLabels;
197
198impl PullLabels for PlainLabels {
199 fn resolve(&self, _owner: &str, _repo: &str, names: &[String]) -> Vec<serde_json::Value> {
200 names
201 .iter()
202 .map(|n| serde_json::json!({"name": n, "color": "ededed"}))
203 .collect()
204 }
205}
206
207#[derive(Clone)]
210pub struct PrExt {
211 pub(crate) tip: Arc<dyn TipRecorder>,
212 pub(crate) labels: Arc<dyn PullLabels>,
213 pub(crate) merge_tree_ok: bool,
214 pub(crate) git_version: String,
215}
216
217impl Default for PrExt {
218 fn default() -> Self {
219 let v = crate::merge::detected_git_version();
220 Self {
221 tip: Arc::new(NoopTipRecorder),
222 labels: Arc::new(PlainLabels),
223 merge_tree_ok: crate::merge::merge_tree_supported(v),
224 git_version: v.to_string(),
225 }
226 }
227}
228
229impl std::fmt::Debug for PrExt {
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 f.debug_struct("PrExt")
232 .field("merge_tree_ok", &self.merge_tree_ok)
233 .field("git_version", &self.git_version)
234 .finish()
235 }
236}
237
238#[must_use]
240pub fn render_body_html(text: &str) -> String {
241 crate::markdown::render(text)
242}
243
244fn author_owner(author: &str) -> Option<String> {
246 if let Some(h) = author.strip_prefix("did:nostr:") {
247 return (h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
248 .then(|| h.to_ascii_lowercase());
249 }
250 let rest = author
251 .strip_prefix("https://")
252 .or_else(|| author.strip_prefix("http://"))?;
253 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
254 let host = host.split(':').next().unwrap_or("");
255 let segs: Vec<&str> = path
256 .split(['?', '#'])
257 .next()
258 .unwrap_or("")
259 .split('/')
260 .filter(|s| !s.is_empty())
261 .collect();
262 let name = if segs.len() >= 2 && segs[0] != "profile" {
263 segs[0].to_string()
264 } else {
265 host.split('.').next().unwrap_or("").to_string()
266 };
267 valid_owner(&name).then_some(name)
268}
269
270fn author_display(author: &str) -> String {
272 match author_owner(author) {
273 Some(o) => agent_label(&o),
274 None => author.to_string(),
275 }
276}
277
278fn author_meta(author: &str) -> serde_json::Value {
280 let nostr = author.starts_with("did:nostr:");
281 serde_json::json!({
282 "id": author,
283 "displayName": author_display(author),
284 "kind": if nostr { "nostr" } else { "webid" },
285 })
286}
287
288#[derive(Debug, Default)]
290struct PrInput {
291 title: Option<String>,
292 body: Option<String>,
293 base: Option<String>,
294 head: Option<String>,
295 resource_url: Option<String>,
296 expected_base: Option<String>,
297}
298
299fn parse_input(req: &ForgeRequest) -> Option<PrInput> {
300 let ct = req.header("content-type").unwrap_or("");
301 let raw = String::from_utf8_lossy(&req.raw_body);
302 if ct.contains("application/json") || raw.trim_start().starts_with('{') {
303 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
304 let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
305 return Some(PrInput {
306 title: s("title"),
307 body: s("body"),
308 base: s("base"),
309 head: s("head"),
310 resource_url: s("resourceUrl"),
311 expected_base: s("expectedBase"),
312 });
313 }
314 let pairs = parse_form(&raw);
315 let f = |k: &str| {
316 pairs
317 .iter()
318 .find(|(kk, _)| kk == k)
319 .map(|(_, v)| v.clone())
320 .filter(|v| !v.is_empty())
321 };
322 Some(PrInput {
323 title: f("title"),
324 body: f("body"),
325 base: f("base"),
326 head: f("head"),
327 resource_url: f("resourceUrl"),
328 expected_base: f("expectedBase"),
329 })
330}
331
332fn redirect_on_ok(resp: ForgeResponse, location: &str) -> ForgeResponse {
334 if (200..300).contains(&resp.status) {
335 ForgeResponse::redirect(303, location)
336 } else {
337 resp
338 }
339}
340
341impl ForgeService {
342 #[must_use]
344 pub fn with_pull_labels(mut self, labels: Arc<dyn PullLabels>) -> Self {
345 self.pr.labels = labels;
346 self
347 }
348
349 fn hosted_url(&self, resource_url: &str) -> String {
350 match resource_url.strip_prefix(crate::hosted::REF_SCHEME) {
351 Some(rest) => format!("{}/api/hosted/{rest}", self.prefix()),
352 None => resource_url.to_string(),
353 }
354 }
355
356 pub(crate) async fn pulls_tab(&self, owner: &str, name: &str) -> String {
358 let open = load_pull_index(self.spine.as_ref(), owner, name)
359 .await
360 .map(|i| {
361 i.pulls
362 .values()
363 .filter(|p| p.state == IssueState::Open)
364 .count()
365 })
366 .unwrap_or(0);
367 let badge = if open > 0 {
368 format!(" <span class=\"badge\">{open}</span>")
369 } else {
370 String::new()
371 };
372 format!(
373 "<nav class=\"crumbs\"><a href=\"{}/{}/{}/pulls\">Pull requests{badge}</a></nav>",
374 esc(&self.prefix()),
375 esc(owner),
376 esc(name)
377 )
378 }
379
380 async fn thread_json(&self, entries: &[ThreadPointer]) -> Vec<serde_json::Value> {
382 let hosted: Arc<dyn HostedReader> = self.hosted.clone();
383 let rendered =
384 bodies::render_thread(entries, self.loopback.clone(), Some(hosted), &self.cfg).await;
385 entries
386 .iter()
387 .zip(rendered.iter())
388 .map(|(p, r)| {
389 let (body, removed) = match &r.body {
390 BodyOutcome::Present(b) => (Some(b.clone()), false),
391 _ => (None, true),
392 };
393 serde_json::json!({
394 "author": p.author,
395 "at": p.at,
396 "resourceUrl": self.hosted_url(&p.resource_url),
397 "hosted": p.hosted,
398 "body": body,
399 "removed": removed,
400 "authorInfo": author_meta(&p.author),
401 "html": body.as_deref().map(render_body_html),
402 })
403 })
404 .collect()
405 }
406
407 fn pull_summary(
408 &self,
409 defs: &[crate::spine::issues::Label],
410 p: &PullEntry,
411 ) -> serde_json::Value {
412 serde_json::json!({
413 "number": p.number,
414 "title": p.title,
415 "state": state_str(p.state),
416 "author": p.author,
417 "authorInfo": author_meta(&p.author),
418 "createdAt": p.created_at,
419 "base": p.base,
420 "head": p.head,
421 "merged": p.merged,
422 "labels": crate::labels::resolve_labels(p.labels.as_deref().unwrap_or(&[]), defs),
423 "comments": p.thread.len().saturating_sub(1),
424 })
425 }
426
427 pub(crate) async fn h_api_pulls(
430 &self,
431 req: &ForgeRequest,
432 agent: &ForgeAgent,
433 owner: &str,
434 name: &str,
435 tail: &[String],
436 ) -> Result<ForgeResponse, ForgeError> {
437 self.resolve_repo(owner, name).await?;
438 let m = req.method.to_ascii_uppercase();
439 let read = m == "GET" || m == "HEAD";
440 let not_allowed = || Ok(ForgeResponse::error(405, "method not allowed"));
441 let not_found = || Ok(ForgeResponse::error(404, "not found"));
442 if tail.is_empty() {
443 return match m.as_str() {
444 "POST" => self.api_pull_create(req, agent, owner, name).await,
445 _ if read => self.api_pull_list(req, owner, name).await,
446 _ => not_allowed(),
447 };
448 }
449 let Some(number) = parse_number(&tail[0]) else {
450 return not_found();
451 };
452 match tail.get(1).map(String::as_str) {
453 None if read => self.api_pull_get(owner, name, number).await,
454 None => not_allowed(),
455 Some("comments" | "close" | "reopen" | "merge") if m != "POST" => not_allowed(),
456 Some("comments") if tail.len() == 2 => {
457 self.api_pull_comment(req, agent, owner, name, number).await
458 }
459 Some("merge") if tail.len() == 2 => {
460 let exp = parse_input(req).and_then(|i| i.expected_base);
461 self.merge_pull(agent, owner, name, number, exp.as_deref())
462 .await
463 }
464 Some(v @ ("close" | "reopen")) if tail.len() == 2 => {
465 let st = if v == "close" {
466 IssueState::Closed
467 } else {
468 IssueState::Open
469 };
470 self.api_pull_state(agent, owner, name, number, st).await
471 }
472 Some("labels") if tail.len() == 2 && req.method.eq_ignore_ascii_case("PUT") => {
473 self.put_item_labels(
474 req,
475 agent,
476 owner,
477 name,
478 number,
479 crate::labels::ItemKind::Pull,
480 )
481 .await
482 }
483 _ => not_found(),
484 }
485 }
486
487 async fn api_pull_list(
488 &self,
489 req: &ForgeRequest,
490 owner: &str,
491 name: &str,
492 ) -> Result<ForgeResponse, ForgeError> {
493 let idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
494 let mut all: Vec<&PullEntry> = idx.pulls.values().collect();
495 all.sort_by_key(|p| std::cmp::Reverse(p.number));
496 let count = |s| all.iter().filter(|p| p.state == s).count();
497 let state = list_state(req);
498 let label = query_decoded(req, "label");
499 let page_no = list_page(req);
500 let filtered: Vec<&&PullEntry> = all
501 .iter()
502 .filter(|p| {
503 p.state == state
504 && (label.is_empty() || p.labels.as_ref().is_some_and(|l| l.contains(&label)))
505 })
506 .collect();
507 let defs = self.label_defs(owner, name).await.unwrap_or_default();
508 let pulls: Vec<_> = filtered
509 .iter()
510 .skip((page_no - 1) * PULLS_PER_PAGE)
511 .take(PULLS_PER_PAGE)
512 .map(|p| self.pull_summary(&defs, p))
513 .collect();
514 Ok(ForgeResponse::json(
515 200,
516 &serde_json::json!({
517 "state": state_str(state),
518 "page": page_no,
519 "perPage": PULLS_PER_PAGE,
520 "hasMore": filtered.len() > page_no * PULLS_PER_PAGE,
521 "openCount": count(IssueState::Open),
522 "mergedCount": count(IssueState::Merged),
523 "closedCount": count(IssueState::Closed),
524 "pulls": pulls,
525 }),
526 ))
527 }
528
529 async fn api_pull_get(
530 &self,
531 owner: &str,
532 name: &str,
533 number: u64,
534 ) -> Result<ForgeResponse, ForgeError> {
535 let idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
536 let Some(pr) = idx.pulls.get(&number) else {
537 return Ok(ForgeResponse::error(404, "not found"));
538 };
539 let info = if pr.state == IssueState::Open {
540 self.pull_merge_info(owner, name, pr).await?
541 } else {
542 let dir = repo::repo_git_dir(&self.repo_root, owner, name)?;
543 let base_sha = if valid_ref(&pr.base) {
544 prgit::rev_parse(&dir, &format!("refs/heads/{}", pr.base)).await
545 } else {
546 None
547 };
548 crate::merge::MergeInfo {
549 base_sha,
550 ..Default::default()
551 }
552 };
553 let thread = self.thread_json(&pr.thread).await;
554 let defs = self.label_defs(owner, name).await.unwrap_or_default();
555 let mut v = self.pull_summary(&defs, pr);
556 let obj = v.as_object_mut().expect("object");
557 obj.remove("comments");
558 obj.insert("baseSha".into(), serde_json::json!(info.base_sha));
559 obj.insert(
560 "head".into(),
561 serde_json::json!({
562 "owner": pr.head.owner, "repo": pr.head.repo,
563 "ref": pr.head.ref_name, "sha": info.head_sha,
564 }),
565 );
566 obj.insert("mergeable".into(), serde_json::json!(info.mergeable));
567 obj.insert("conflicts".into(), serde_json::json!(info.conflicts));
568 obj.insert("thread".into(), serde_json::json!(thread));
569 Ok(ForgeResponse::json(200, &v))
570 }
571
572 async fn api_pull_create(
573 &self,
574 req: &ForgeRequest,
575 agent: &ForgeAgent,
576 owner: &str,
577 name: &str,
578 ) -> Result<ForgeResponse, ForgeError> {
579 if matches!(agent, ForgeAgent::Anonymous) {
580 return Ok(ForgeResponse::error(401, "authentication required"));
581 }
582 let Some(inp) = parse_input(req) else {
583 return Ok(ForgeResponse::error(400, "invalid JSON body"));
584 };
585 let title = inp.title.as_deref().map(str::trim).unwrap_or("");
586 if title.is_empty() || title.chars().count() > TITLE_CAP {
587 return Ok(ForgeResponse::error(
588 422,
589 format!("title required (1-{TITLE_CAP} chars)"),
590 ));
591 }
592 if inp.body.as_deref().map_or(0, str::len) > BODY_CAP {
593 return Ok(ForgeResponse::error(422, "body too large"));
594 }
595 let base_ref = inp.base.clone().unwrap_or_default();
596 if !valid_ref(&base_ref) {
597 return Ok(ForgeResponse::error(
598 422,
599 "base must be a branch of this repository",
600 ));
601 }
602 let dir = repo::repo_git_dir(&self.repo_root, owner, name)?;
603 let Some(base_sha) = prgit::rev_parse(&dir, &format!("refs/heads/{base_ref}")).await else {
604 return Ok(ForgeResponse::error(
605 422,
606 format!("base branch {base_ref} does not exist"),
607 ));
608 };
609 let Some(head) = self
610 .resolve_head(
611 owner,
612 name,
613 inp.head.as_deref().unwrap_or(""),
614 HeadAccess::Caller(agent),
615 )
616 .await?
617 else {
618 return Ok(ForgeResponse::error(
619 422,
620 "head must be <branch> or <owner>:<branch> of a same-named repo",
621 ));
622 };
623 let counts = prgit::text(
624 &dir,
625 &[
626 "rev-list",
627 "--left-right",
628 "--count",
629 &format!("{base_sha}...{}", head.sha),
630 ],
631 )
632 .await?;
633 let ahead: u64 = counts
634 .split_whitespace()
635 .nth(1)
636 .and_then(|v| v.parse().ok())
637 .unwrap_or(0);
638 if ahead == 0 {
639 return Ok(ForgeResponse::error(
640 422,
641 "no commits between base and head",
642 ));
643 }
644 let _g = prgit::pull_locks().lock(&format!("{owner}/{name}")).await;
645 let mut idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
646 if matches!(agent, ForgeAgent::Pod { .. }) && inp.resource_url.is_none() {
647 return Ok(ForgeResponse::error(422, "resourceUrl required"));
648 }
649 if agent.is_nostr() && inp.body.as_deref().is_none_or(|b| b.trim().is_empty()) {
650 return Ok(ForgeResponse::error(422, "body required"));
651 }
652 let ptr = self
653 .make_pointer(
654 req,
655 agent,
656 owner,
657 name,
658 inp.resource_url.as_deref(),
659 inp.body.as_deref(),
660 )
661 .await?;
662 let number = idx.next;
663 let hosted = ptr.hosted;
664 let resource_url = self.hosted_url(&ptr.resource_url);
665 idx.next = number + 1;
666 idx.pulls.insert(
667 number,
668 PullEntry {
669 number,
670 title: title.to_string(),
671 state: IssueState::Open,
672 author: agent.author_id(),
673 created_at: crate::now_secs(),
674 base: base_ref,
675 head: PullHead {
676 owner: head.owner,
677 repo: head.repo,
678 ref_name: head.ref_name,
679 },
680 merged: None,
681 labels: None,
682 thread: vec![ptr],
683 },
684 );
685 save_pull_index(self.spine.as_ref(), owner, name, &idx).await?;
686 let mut v = serde_json::json!({
687 "number": number,
688 "url": format!("{}/{owner}/{name}/pulls/{number}", self.prefix()),
689 "resourceUrl": resource_url,
690 });
691 if hosted {
692 v["hosted"] = serde_json::json!(true);
693 }
694 Ok(ForgeResponse::json(201, &v))
695 }
696
697 async fn api_pull_comment(
698 &self,
699 req: &ForgeRequest,
700 agent: &ForgeAgent,
701 owner: &str,
702 name: &str,
703 number: u64,
704 ) -> Result<ForgeResponse, ForgeError> {
705 if matches!(agent, ForgeAgent::Anonymous) {
706 return Ok(ForgeResponse::error(401, "authentication required"));
707 }
708 let Some(inp) = parse_input(req) else {
709 return Ok(ForgeResponse::error(400, "invalid JSON body"));
710 };
711 let has_body = inp.body.as_deref().is_some_and(|b| !b.trim().is_empty());
712 if inp.resource_url.is_none() && !has_body {
713 return Ok(ForgeResponse::error(422, "body required"));
714 }
715 if inp.body.as_deref().map_or(0, str::len) > BODY_CAP {
716 return Ok(ForgeResponse::error(422, "body too large"));
717 }
718 let _g = prgit::pull_locks().lock(&format!("{owner}/{name}")).await;
719 let mut idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
720 let Some(pr) = idx.pulls.get_mut(&number) else {
721 return Ok(ForgeResponse::error(404, "not found"));
722 };
723 if pr.thread.len() >= THREAD_CAP {
724 return Ok(ForgeResponse::error(422, "thread is full"));
725 }
726 let ptr = self
727 .make_pointer(
728 req,
729 agent,
730 owner,
731 name,
732 inp.resource_url.as_deref(),
733 inp.body.as_deref(),
734 )
735 .await?;
736 let hosted = ptr.hosted;
737 let url = self.hosted_url(&ptr.resource_url);
738 pr.thread.push(ptr);
739 let comments = pr.thread.len() - 1;
740 save_pull_index(self.spine.as_ref(), owner, name, &idx).await?;
741 let mut v = serde_json::json!({"number": number, "comments": comments, "resourceUrl": url});
742 if hosted {
743 v["hosted"] = serde_json::json!(true);
744 }
745 Ok(ForgeResponse::json(201, &v))
746 }
747
748 async fn api_pull_state(
751 &self,
752 agent: &ForgeAgent,
753 owner: &str,
754 name: &str,
755 number: u64,
756 state: IssueState,
757 ) -> Result<ForgeResponse, ForgeError> {
758 if matches!(agent, ForgeAgent::Anonymous) {
759 return Ok(ForgeResponse::error(401, "authentication required"));
760 }
761 let _g = prgit::pull_locks().lock(&format!("{owner}/{name}")).await;
762 let mut idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
763 let Some(pr) = idx.pulls.get_mut(&number) else {
764 return Ok(ForgeResponse::error(404, "not found"));
765 };
766 if agent.owner() != Some(owner) && agent.author_id() != pr.author {
767 return Ok(ForgeResponse::error(
768 403,
769 "only the repo owner or the pull-request author may do that",
770 ));
771 }
772 if pr.state == IssueState::Merged {
773 return Ok(ForgeResponse::error(
774 422,
775 "a merged pull request cannot be reopened or closed",
776 ));
777 }
778 pr.state = state;
779 let snapshot = pr.clone();
780 save_pull_index(self.spine.as_ref(), owner, name, &idx).await?;
781 if state == IssueState::Closed {
782 self.release_head_ref(owner, name, &idx, &snapshot).await;
783 }
784 Ok(ForgeResponse::json(
785 200,
786 &serde_json::json!({"number": number, "state": state_str(state)}),
787 ))
788 }
789
790 pub(crate) async fn h_pulls_page(
794 &self,
795 req: &ForgeRequest,
796 agent: &ForgeAgent,
797 owner: &str,
798 name: &str,
799 tail: &[String],
800 ) -> Result<ForgeResponse, ForgeError> {
801 self.resolve_repo(owner, name).await?;
802 let base = format!("{}/{owner}/{name}", self.prefix());
803 let post = req.method.eq_ignore_ascii_case("POST");
804 let nf = || Ok(ForgeResponse::error(404, "not found"));
805 match tail {
806 [] if post => {
807 let r = self.api_pull_create(req, agent, owner, name).await?;
808 if r.status == 201 {
810 let n = serde_json::from_slice::<serde_json::Value>(&r.body)
811 .ok()
812 .and_then(|v| v["number"].as_u64())
813 .unwrap_or(0);
814 return Ok(ForgeResponse::redirect(303, &format!("{base}/pulls/{n}")));
815 }
816 Ok(r)
817 }
818 [] => self.pulls_list_page(req, owner, name).await,
819 [n] if n == "new" => self.new_pull_page(req, agent, owner, name).await,
820 [n, rest @ ..] => {
821 let Some(number) = parse_number(n) else {
822 return nf();
823 };
824 let loc = format!("{base}/pulls/{number}");
825 match (rest, post) {
826 ([], false) => self.pull_page(owner, name, number).await,
827 ([], true) => {
828 let r = self
829 .api_pull_comment(req, agent, owner, name, number)
830 .await?;
831 Ok(redirect_on_ok(r, &loc))
832 }
833 ([v], true) if v == "merge" => {
834 let exp = parse_input(req).and_then(|i| i.expected_base);
835 let r = self
836 .merge_pull(agent, owner, name, number, exp.as_deref())
837 .await?;
838 Ok(redirect_on_ok(r, &loc))
839 }
840 ([v], true) if v == "close" || v == "reopen" => {
841 let st = if v == "close" {
842 IssueState::Closed
843 } else {
844 IssueState::Open
845 };
846 let r = self.api_pull_state(agent, owner, name, number, st).await?;
847 Ok(redirect_on_ok(r, &loc))
848 }
849 ([v], false) if v == "commits" => {
850 self.pull_diff_page(owner, name, number, true).await
851 }
852 ([v], false) if v == "files" => {
853 self.pull_diff_page(owner, name, number, false).await
854 }
855 _ => nf(),
856 }
857 }
858 }
859 }
860
861 fn chip(state: IssueState) -> String {
862 let (cls, label) = match state {
863 IssueState::Open => ("pr-open state-open", "Open"),
864 IssueState::Merged => ("pr-merged state-merged", "Merged"),
865 IssueState::Closed => ("pr-closed state-closed", "Closed"),
866 };
867 format!("<span class=\"badge {cls}\">{label}</span>")
868 }
869
870 async fn pulls_list_page(
871 &self,
872 req: &ForgeRequest,
873 owner: &str,
874 name: &str,
875 ) -> Result<ForgeResponse, ForgeError> {
876 let idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
877 let mut all: Vec<&PullEntry> = idx.pulls.values().collect();
878 all.sort_by_key(|p| std::cmp::Reverse(p.number));
879 let count = |s| all.iter().filter(|p| p.state == s).count();
880 let state = list_state(req);
881 let page_no = list_page(req);
882 let base = format!("{}/{owner}/{name}", self.prefix());
883 let filtered: Vec<&&PullEntry> = all.iter().filter(|p| p.state == state).collect();
884 let mut body = crumbs(&[
885 ("forge", Some(self.prefix())),
886 (
887 &agent_label(owner),
888 Some(format!("{}/{owner}", self.prefix())),
889 ),
890 (name, Some(base.clone())),
891 ("pulls", None),
892 ]);
893 body.push_str("<h1>Pull requests</h1><p>");
894 for (s, label) in [
895 (IssueState::Open, "Open"),
896 (IssueState::Merged, "Merged"),
897 (IssueState::Closed, "Closed"),
898 ] {
899 body.push_str(&format!(
900 "<a href=\"{}/pulls?state={}\">{} {}</a> · ",
901 esc(&base),
902 state_str(s),
903 count(s),
904 label
905 ));
906 }
907 body.push_str("</p>");
908 if filtered.is_empty() {
909 body.push_str(&format!(
910 "<p class=\"muted\">No {} pull requests.</p>",
911 state_str(state)
912 ));
913 } else {
914 body.push_str("<table><tbody>");
915 for p in filtered
916 .iter()
917 .skip((page_no - 1) * PULLS_PER_PAGE)
918 .take(PULLS_PER_PAGE)
919 {
920 let nc = p.thread.len().saturating_sub(1);
921 body.push_str(&format!(
922 "<tr><td>{chip} <a href=\"{b}/pulls/{n}\">{t}</a>\
923<div class=\"muted\">#{n} opened by {a} · <code>{ho}:{hr} → {bs}</code></div></td>\
924<td class=\"muted\">{c}</td></tr>",
925 chip = Self::chip(p.state),
926 b = esc(&base),
927 n = p.number,
928 t = esc(&p.title),
929 a = esc(&author_display(&p.author)),
930 ho = esc(&agent_label(&p.head.owner)),
931 hr = esc(&p.head.ref_name),
932 bs = esc(&p.base),
933 c = if nc > 0 {
934 format!("{nc} comment{}", if nc == 1 { "" } else { "s" })
935 } else {
936 String::new()
937 },
938 ));
939 }
940 body.push_str("</tbody></table>");
941 }
942 if filtered.len() > page_no * PULLS_PER_PAGE {
943 body.push_str(&format!(
944 "<p><a href=\"{}/pulls?state={}&page={}\">Older</a></p>",
945 esc(&base),
946 state_str(state),
947 page_no + 1
948 ));
949 }
950 Ok(ForgeResponse::html(
951 200,
952 page(&format!("Pull requests - {owner}/{name}"), &body),
953 ))
954 }
955
956 async fn new_pull_page(
957 &self,
958 req: &ForgeRequest,
959 agent: &ForgeAgent,
960 owner: &str,
961 name: &str,
962 ) -> Result<ForgeResponse, ForgeError> {
963 let (b, h) = (query_decoded(req, "base"), query_decoded(req, "head"));
964 let Some(c) = self
965 .compare_data(owner, name, &b, &h, HeadAccess::Caller(agent))
966 .await?
967 else {
968 return Ok(ForgeResponse::error(404, "not found"));
969 };
970 let base = format!("{}/{owner}/{name}", self.prefix());
971 let mut body = crumbs(&[
972 ("forge", Some(self.prefix())),
973 (name, Some(base.clone())),
974 ("new pull request", None),
975 ]);
976 body.push_str(&format!(
977 "<h1>Open a pull request</h1><p class=\"muted\"><code>{}:{}</code> → <b>{}</b> \
978· {} commit{} ahead</p>\
979<form method=\"post\" action=\"{base}/pulls\">\
980<input type=\"hidden\" name=\"base\" value=\"{}\"><input type=\"hidden\" name=\"head\" value=\"{}\">\
981<p><input type=\"text\" name=\"title\" placeholder=\"Title\" required></p>\
982<p><input type=\"text\" name=\"resourceUrl\" placeholder=\"Body resource URL in your pod\"></p>\
983<p><textarea name=\"body\" placeholder=\"Body (podless agents)\"></textarea></p>\
984<p><button type=\"submit\">Create pull request</button></p></form>",
985 esc(&agent_label(&c.head.owner)),
986 esc(&c.head.ref_name),
987 esc(&b),
988 c.ahead_by,
989 if c.ahead_by == 1 { "" } else { "s" },
990 esc(&b),
991 esc(&h),
992 base = esc(&base),
993 ));
994 Ok(ForgeResponse::html(
995 200,
996 page(&format!("New pull request - {owner}/{name}"), &body),
997 ))
998 }
999
1000 fn pull_headline(pr: &PullEntry) -> String {
1001 let verb = if pr.state == IssueState::Merged {
1002 format!("merged into <b>{}</b>", esc(&pr.base))
1003 } else {
1004 format!(
1005 "wants to merge <code>{}:{}</code> into <b>{}</b>",
1006 esc(&agent_label(&pr.head.owner)),
1007 esc(&pr.head.ref_name),
1008 esc(&pr.base)
1009 )
1010 };
1011 format!(
1012 "<p>{} <span class=\"muted\"><b>{}</b> {verb}</span></p>",
1013 Self::chip(pr.state),
1014 esc(&author_display(&pr.author))
1015 )
1016 }
1017
1018 fn pull_tabs(base: &str, n: u64) -> String {
1019 format!(
1020 "<nav class=\"crumbs\"><a href=\"{b}/pulls/{n}\">Conversation</a> · \
1021<a href=\"{b}/pulls/{n}/commits\">Commits</a> · \
1022<a href=\"{b}/pulls/{n}/files\">Files changed</a></nav>",
1023 b = esc(base)
1024 )
1025 }
1026
1027 async fn pull_page(
1028 &self,
1029 owner: &str,
1030 name: &str,
1031 number: u64,
1032 ) -> Result<ForgeResponse, ForgeError> {
1033 let idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
1034 let Some(pr) = idx.pulls.get(&number) else {
1035 return Ok(ForgeResponse::error(404, "not found"));
1036 };
1037 let base = format!("{}/{owner}/{name}", self.prefix());
1038 let thread = self.thread_json(&pr.thread).await;
1039 let mut body = crumbs(&[
1040 ("forge", Some(self.prefix())),
1041 (name, Some(base.clone())),
1042 ("pulls", Some(format!("{base}/pulls"))),
1043 (&format!("#{number}"), None),
1044 ]);
1045 body.push_str(&format!(
1046 "<h1>{} <span class=\"muted\">#{}</span></h1>{}{}",
1047 esc(&pr.title),
1048 pr.number,
1049 Self::pull_headline(pr),
1050 Self::pull_tabs(&base, number)
1051 ));
1052 for (i, e) in thread.iter().enumerate() {
1053 let who = esc(&author_display(e["author"].as_str().unwrap_or("")));
1054 let verb = if i == 0 {
1055 " opened this pull request"
1056 } else {
1057 ""
1058 };
1059 let text = match e["html"].as_str() {
1060 Some(h) => h.to_string(),
1061 None => "<p class=\"muted\">content removed by its author</p>".to_string(),
1062 };
1063 body.push_str(&format!(
1064 "<div class=\"cbox\"><div class=\"muted\"><b>{who}</b>{verb}</div>{text}</div>"
1065 ));
1066 }
1067 let post = |verb: &str| format!("{}/pulls/{number}/{verb}", esc(&base));
1068 match pr.state {
1069 IssueState::Merged => {
1070 let m = pr.merged.as_ref();
1071 body.push_str(&format!(
1072 "<div class=\"mergebox\"><h3>Merged</h3><p class=\"muted\">merge commit <code>{}</code> by <b>{}</b>{}</p></div>",
1073 esc(&m.map(|m| m.sha.chars().take(12).collect::<String>()).unwrap_or_default()),
1074 esc(&m.map(|m| author_display(&m.merged_by)).unwrap_or_default()),
1075 if m.is_some_and(|m| m.fast_forward) { " (fast-forward)" } else { "" }
1076 ));
1077 }
1078 IssueState::Closed => body.push_str(
1079 "<div class=\"mergebox\"><h3>Closed</h3>\
1080<p class=\"muted\">This pull request was closed without merging.</p></div>",
1081 ),
1082 IssueState::Open => {
1083 let info = self.pull_merge_info(owner, name, pr).await?;
1084 match (info.mergeable, info.up_to_date) {
1085 (Some(true), _) => body.push_str(&format!(
1086 "<div class=\"mergebox clean\"><h3>This branch has no conflicts with the base branch</h3>\
1087<p class=\"muted\">{}</p>\
1088<form method=\"post\" action=\"{}\"><input type=\"hidden\" name=\"expectedBase\" value=\"{}\">\
1089<button id=\"do-merge\" type=\"submit\">Merge pull request</button></form></div>",
1090 if info.fast_forward {
1091 "Fast-forward merge (no merge commit needed)."
1092 } else {
1093 "A merge commit will join the histories."
1094 },
1095 post("merge"),
1096 esc(info.base_sha.as_deref().unwrap_or(""))
1097 )),
1098 (Some(false), true) => body.push_str(
1099 "<div class=\"mergebox\"><h3>Nothing to merge</h3>\
1100<p class=\"muted\">The base branch already contains every commit of this branch.</p></div>",
1101 ),
1102 (Some(false), false) => {
1103 body.push_str(&format!(
1104 "<div class=\"mergebox conflict\"><h3>This branch has conflicts that must be resolved</h3>\
1105<p class=\"muted\">Conflicting file{}:</p><ul>",
1106 if info.conflicts.len() == 1 { "" } else { "s" }
1107 ));
1108 for c in &info.conflicts {
1109 body.push_str(&format!("<li><code>{}</code></li>", esc(c)));
1110 }
1111 body.push_str("</ul></div>");
1112 }
1113 (None, _) => body.push_str(&format!(
1114 "<div class=\"mergebox\"><h3>Merge status unknown</h3><p class=\"muted\">{}</p></div>",
1115 if self.pr.merge_tree_ok {
1116 "The head branch (or the base branch) is no longer available.".to_string()
1117 } else {
1118 format!(
1119 "This server's {} lacks merge-tree --write-tree (needs git 2.38 or later).",
1120 esc(&self.pr.git_version)
1121 )
1122 }
1123 )),
1124 }
1125 }
1126 }
1127 body.push_str(&format!(
1128 "<form method=\"post\" action=\"{b}/pulls/{number}\">\
1129<p><input type=\"text\" name=\"resourceUrl\" placeholder=\"Comment resource URL in your pod\"></p>\
1130<p><textarea name=\"body\" placeholder=\"Leave a comment (podless agents)\"></textarea></p>\
1131<p><button type=\"submit\">Comment</button></p></form>",
1132 b = esc(&base)
1133 ));
1134 if pr.state != IssueState::Merged {
1135 let (verb, label) = if pr.state == IssueState::Open {
1136 ("close", "Close pull request")
1137 } else {
1138 ("reopen", "Reopen pull request")
1139 };
1140 body.push_str(&format!(
1141 "<form method=\"post\" action=\"{}\"><button type=\"submit\">{label}</button></form>",
1142 post(verb)
1143 ));
1144 }
1145 Ok(ForgeResponse::html(
1146 200,
1147 page(&format!("{} - #{number} - {owner}/{name}", pr.title), &body),
1148 ))
1149 }
1150
1151 async fn pull_diff_page(
1152 &self,
1153 owner: &str,
1154 name: &str,
1155 number: u64,
1156 commits_tab: bool,
1157 ) -> Result<ForgeResponse, ForgeError> {
1158 let idx = load_pull_index(self.spine.as_ref(), owner, name).await?;
1159 let Some(pr) = idx.pulls.get(&number) else {
1160 return Ok(ForgeResponse::error(404, "not found"));
1161 };
1162 let dir = repo::repo_git_dir(&self.repo_root, owner, name)?;
1163 let base = format!("{}/{owner}/{name}", self.prefix());
1164 let (commits, more, files) = match pr
1167 .merged
1168 .as_ref()
1169 .and_then(|m| m.base_sha.clone().zip(m.head_sha.clone()))
1170 {
1171 Some((b, h)) if pr.state == IssueState::Merged => {
1172 let mb = prgit::text(&dir, &["merge-base", &b, &h])
1173 .await
1174 .ok()
1175 .map(|s| s.trim().to_string());
1176 let (c, m) = compare::commit_range(&dir, &b, &h).await?;
1177 (c, m, compare::diff_files(&dir, mb.as_deref(), &b, &h).await)
1178 }
1179 _ => match self
1180 .compare_data(
1181 owner,
1182 name,
1183 &pr.base,
1184 &pr.head_spec(),
1185 if pr.state == IssueState::Open {
1186 HeadAccess::ExistingPr
1187 } else {
1188 HeadAccess::Cached
1189 },
1190 )
1191 .await?
1192 {
1193 Some(c) => (c.commits, c.has_more, c.files),
1194 None => (Vec::new(), false, Vec::new()),
1195 },
1196 };
1197 let mut body = crumbs(&[
1198 ("forge", Some(self.prefix())),
1199 (name, Some(base.clone())),
1200 ("pulls", Some(format!("{base}/pulls"))),
1201 (&format!("#{number}"), None),
1202 ]);
1203 body.push_str(&format!(
1204 "<h1>{} <span class=\"muted\">#{}</span></h1>{}{}",
1205 esc(&pr.title),
1206 pr.number,
1207 Self::pull_headline(pr),
1208 Self::pull_tabs(&base, number)
1209 ));
1210 if commits_tab {
1211 body.push_str(&commit_rows(&base, &commits, more));
1212 } else {
1213 body.push_str(&compare::render_diff(&files));
1214 }
1215 Ok(ForgeResponse::html(
1216 200,
1217 page(
1218 &format!(
1219 "{} - #{number} - {owner}/{name}",
1220 if commits_tab {
1221 "Commits"
1222 } else {
1223 "Files changed"
1224 }
1225 ),
1226 &body,
1227 ),
1228 ))
1229 }
1230}
1231
1232fn parse_number(s: &str) -> Option<u64> {
1234 let b = s.as_bytes();
1235 (!b.is_empty() && b.len() <= 9 && b[0] != b'0' && b.iter().all(u8::is_ascii_digit))
1236 .then(|| s.parse().ok())
1237 .flatten()
1238}
1239
1240fn list_state(req: &ForgeRequest) -> IssueState {
1241 match query_decoded(req, "state").as_str() {
1242 "merged" => IssueState::Merged,
1243 "closed" => IssueState::Closed,
1244 _ => IssueState::Open,
1245 }
1246}
1247
1248fn list_page(req: &ForgeRequest) -> usize {
1249 query_decoded(req, "page")
1250 .parse::<usize>()
1251 .unwrap_or(1)
1252 .clamp(1, 10_000)
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 #[test]
1260 fn number_rules() {
1261 assert_eq!(parse_number("1"), Some(1));
1262 assert_eq!(parse_number("123456789"), Some(123_456_789));
1263 for bad in ["0", "01", "", "1234567890", "1a", "-1", "new"] {
1264 assert_eq!(parse_number(bad), None, "{bad}");
1265 }
1266 }
1267
1268 #[test]
1269 fn author_ids() {
1270 let hex = "ab".repeat(32);
1271 assert_eq!(author_owner(&format!("did:nostr:{hex}")), Some(hex.clone()));
1272 assert_eq!(
1273 author_owner("https://pod.example/dana/profile/card#me").as_deref(),
1274 Some("dana")
1275 );
1276 assert_eq!(
1277 author_owner("https://dana.pod.example/profile/card#me").as_deref(),
1278 Some("dana")
1279 );
1280 assert_eq!(author_owner("did:web:x"), None);
1281 assert_eq!(author_meta(&format!("did:nostr:{hex}"))["kind"], "nostr");
1282 assert_eq!(author_meta("https://p/dana/x")["kind"], "webid");
1283 }
1284
1285 #[test]
1286 fn index_roundtrips_with_jss_shape() {
1287 let json = r#"{"next":2,"pulls":{"1":{"number":1,"title":"t","state":"open","author":"a",
1288"createdAt":5,"base":"main","head":{"owner":"dana","repo":"demo","ref":"feature"},
1289"merged":null,"thread":[{"author":"a","resourceUrl":"u","at":5}]}}}"#;
1290 let idx: PullIndex = serde_json::from_str(json).unwrap();
1291 assert_eq!(idx.pulls[&1].head_spec(), "dana:feature");
1292 let back = serde_json::to_value(&idx).unwrap();
1293 assert_eq!(back["pulls"]["1"]["head"]["ref"], "feature");
1294 assert!(back["pulls"]["1"]["merged"].is_null());
1295 }
1296
1297 #[test]
1298 fn body_html_escapes() {
1299 let h = render_body_html("<b>&\nx <script>alert(1)</script>");
1300 assert!(h.contains("<b>&"), "{h}");
1301 assert!(!h.contains("<b>") && !h.contains("<script"), "{h}");
1302 assert!(h.contains('x'), "{h}");
1303 }
1304}