Skip to main content

solid_pod_rs_forge/
pulls.rs

1//! Pull requests: the `pulls` spine index, the JSON API and the server-rendered
2//! pages.
3//!
4//! Follows the JSS forge `apiPullList`, `apiPullGet`, `apiPullCreate`,
5//! `apiPullComment`, `apiPullState`, `pullDiffData`, `pullsListPage`,
6//! `pullPage`, `pullCommitsPage`, `pullFilesPage` and `newPullPage` (cited by
7//! name only). Titles, bodies and comments reuse the issue machinery: the spine
8//! keeps only [`ThreadPointer`]s, the words live in the author's pod (or in
9//! forge-hosted storage for a podless `did:nostr` agent), and pull numbering is
10//! separate from issue numbering (a documented JSS deviation from GitHub).
11//! Merging lives in [`crate::merge`].
12//!
13//! # Integration points
14//!
15//! * [`PullLabels`]: how label names on a pull request resolve to
16//!   `{name, color}`; the labels tier installs its own with
17//!   [`ForgeService::with_pull_labels`]. `PUT pulls/<n>/labels` is served
18//!   through [`ForgeService::put_item_labels`].
19//! * [`render_body_html`]: renders thread bodies with the crate's escape-first
20//!   markdown renderer.
21//! * [`crate::merge::TipRecorder`]: the anchoring hook called after a merge.
22
23use 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
40/// The `pulls` spine kind (also the on-disk subdirectory name).
41pub const KIND: &str = "pulls";
42
43/// Pull requests per list page (JSS `ISSUES_PER_PAGE`).
44pub const PULLS_PER_PAGE: usize = 25;
45/// Longest title in characters (JSS `ISSUE_TITLE_CAP`).
46pub const TITLE_CAP: usize = 256;
47/// Largest body in bytes (JSS `ISSUE_BODY_CAP`).
48pub const BODY_CAP: usize = 64 * 1024;
49/// Most thread entries per pull request (JSS `THREAD_CAP`).
50pub const THREAD_CAP: usize = 500;
51
52/// The head of a pull request: a branch of `<owner>`'s same-named repository.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct PullHead {
55    /// Head owner namespace.
56    pub owner: String,
57    /// Head repository name.
58    pub repo: String,
59    /// Branch name.
60    #[serde(rename = "ref")]
61    pub ref_name: String,
62}
63
64/// How a pull request was merged.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct MergeRecord {
68    /// The resulting tip (the head itself for a fast-forward).
69    pub sha: String,
70    /// Author id of the merger.
71    pub merged_by: String,
72    /// Unix seconds.
73    pub at: u64,
74    /// Base tip at merge time.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub base_sha: Option<String>,
77    /// Head tip at merge time.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub head_sha: Option<String>,
80    /// `true` when no merge commit was made.
81    #[serde(default)]
82    pub fast_forward: bool,
83}
84
85/// One pull request's spine entry.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct PullEntry {
89    /// Allocated number.
90    pub number: u64,
91    /// Title (kept in the spine for cheap listing).
92    pub title: String,
93    /// `open`, `closed` or `merged`.
94    pub state: IssueState,
95    /// Author id (WebID or `did:nostr:<hex>`).
96    pub author: String,
97    /// Unix seconds of creation.
98    pub created_at: u64,
99    /// Target branch of this repository.
100    pub base: String,
101    /// Source branch.
102    pub head: PullHead,
103    /// Merge details once merged.
104    #[serde(default)]
105    pub merged: Option<MergeRecord>,
106    /// Label names (resolved through [`PullLabels`]).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub labels: Option<Vec<String>>,
109    /// Thread pointers; the opening body is index 0.
110    pub thread: Vec<ThreadPointer>,
111}
112
113impl PullEntry {
114    /// The head as a compare spec, `owner:ref` (JSS `prHeadSpec`).
115    #[must_use]
116    pub fn head_spec(&self) -> String {
117        format!("{}:{}", self.head.owner, self.head.ref_name)
118    }
119}
120
121/// The pull index of one repository.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct PullIndex {
124    /// Next number to allocate.
125    pub next: u64,
126    /// Pull requests keyed by number.
127    #[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
140/// Load a pull index (default when absent; a corrupt file is an error rather
141/// than a silent reset).
142pub 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
163/// Persist a pull index atomically.
164pub 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/// Lowercase state name as used on the wire.
176#[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
185/// Resolve label names on a pull request to `{name, color}` objects.
186///
187/// Integration point for the labels tier, which owns the per-repository label
188/// set. The default [`PlainLabels`] returns each name with a neutral colour.
189pub trait PullLabels: Send + Sync {
190    /// Resolve `names` for `<owner>/<repo>`.
191    fn resolve(&self, owner: &str, repo: &str, names: &[String]) -> Vec<serde_json::Value>;
192}
193
194/// The default [`PullLabels`]: names only, neutral colour.
195#[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/// Pull-request runtime state carried by the service: the merge hook, the
208/// label resolver and the git capability detected at start-up.
209#[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/// Render a thread body to HTML with the escape-first markdown renderer.
239#[must_use]
240pub fn render_body_html(text: &str) -> String {
241    crate::markdown::render(text)
242}
243
244/// The owner namespace a stored author id maps to (JSS `ownerFromAgent`).
245fn 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
270/// Display name of a stored author id.
271fn author_display(author: &str) -> String {
272    match author_owner(author) {
273        Some(o) => agent_label(&o),
274        None => author.to_string(),
275    }
276}
277
278/// Additive author metadata for the JSON API (JSS `authorMeta`, without `npub`).
279fn 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/// Parsed pull-request request body (JSON or form).
289#[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
332/// Turn a successful action response into a 303 to `location` (form posts).
333fn 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    /// Install the label resolver for pull requests (integration point).
343    #[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    /// The "Pull requests" link (with an open-count badge) for a repo home.
357    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    /// Per-entry thread JSON: pointer fields plus the re-fetched body.
381    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    /// Router for `api/repos/<o>/<n>/pulls[/<num>[/<verb>]]` (JSS
428    /// `apiPullsHandler`).
429    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    /// close/reopen: the repository owner or the pull-request author; merged
749    /// is final (JSS `apiPullState`, `mayModerate`).
750    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    // ---- pages ----------------------------------------------------------
791
792    /// Pages and form posts under `<owner>/<repo>/pulls`.
793    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                // Land on the new pull request.
809                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> &middot; ",
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} &middot; <code>{ho}:{hr} &rarr; {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> &rarr; <b>{}</b> \
978&middot; {} 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> &middot; \
1021<a href=\"{b}/pulls/{n}/commits\">Commits</a> &middot; \
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        // Merged pulls use the shas frozen at merge time (the objects outlive
1165        // the branch); open and closed ones compute live (JSS `pullDiffData`).
1166        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
1232/// JSS `ISSUE_NUM_RE`: `[1-9][0-9]{0,8}`.
1233fn 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("&lt;b&gt;&amp;"), "{h}");
1301        assert!(!h.contains("<b>") && !h.contains("<script"), "{h}");
1302        assert!(h.contains('x'), "{h}");
1303    }
1304}