use std::collections::BTreeMap;
use serde_json::{Value, json};
use crate::nginx::conf::Block;
use super::{
Hosted, NEWEST_FIRST, NO_CURSOR, NO_OFFSET, Out, Refused, Route, Social, SocialRecord, TICKET,
empty, keys, literal, map_with_default, reroute, spellings,
};
const MINIDOC: &str = "blue.microcosm.identity.resolveMiniDoc";
const REACTIONS: &str = "org.tangled.query.enrichResponse#reactions";
const REPO_VIEW: &str = "org.tangled.repo.defs#repoViewBasic";
const TICKET_AUTHORS: &str = "org.tangled.track.ticket:.repo";
const COMMENT_AUTHORS: &str = "org.tangled.feed.comment:.repo";
const REACTED: &str = "org.tangled.feed.reaction:subject";
const MANIFESTS: &str = "org.tangled.repo.manifest:.";
const REACTION_ORDER: [&str; 8] = ["👍", "👎", "😆", "🎉", "🫤", "❤️", "🚀", "👀"];
const REACTORS_SHOWN: usize = 10;
const SEARCH_SIZES: [usize; 6] = [10, 20, 25, 30, 50, 100];
const SEARCH_LIMIT_MAX: usize = 1000;
const ALL: &str = "all";
const STATES: [&str; 3] = ["open", "closed", "completed"];
const KINDS: [(&str, Option<bool>); 3] = [("", None), ("issue", Some(false)), ("pull", Some(true))];
const QUERY_STATE: &str = "$ks_soc_qstate";
const PAGE: &str = "$ks_soc_page";
const OFFSET: &str = "$ks_soc_offset";
pub(super) const SEARCH: &str = "$ks_soc_search";
pub(super) const SEARCH_NSID: &str = "org.tangled.search.query";
const NO_AUTHOR: Refused = Refused::Unless("author", "");
const TICKET_LIST: &[Refused] = &[NO_AUTHOR, NO_CURSOR, NO_OFFSET, NEWEST_FIRST];
/// the lexicon makes offset and cursor exclusive, the source below joins both into one key.
const OFFSET_AND_CURSOR: &str = "(^|&)offset=[^&].*&cursor=[^&]|(^|&)cursor=[^&].*&offset=[^&]";
const SEARCH_REFUSED: [Refused; 6] = [
NO_AUTHOR,
Refused::Unless("nsid", r"org\.tangled\.track\.ticket|"),
Refused::Unless("label", ""),
Refused::Unless("since", ""),
Refused::Unless("until", ""),
// every hit scores 1, which makes relevance order meaningless.
Refused::Unless("sort", "relevance|created-desc|"),
];
const ISSUES: Route = Route {
nsid: "org.tangled.repo.listIssues",
source: "$arg_subject$arg_repo|$arg_state",
var: "$ks_soc_issues",
miss: "invalid_request",
empty: Some(super::no_items),
refused: TICKET_LIST,
ignored: &["limit", "offset", "order"],
};
const PULLS: Route = Route {
nsid: "org.tangled.repo.listPulls",
source: "$arg_subject$arg_repo|$arg_status$arg_state",
var: "$ks_soc_pulls",
miss: "invalid_request",
empty: Some(super::no_items),
refused: TICKET_LIST,
ignored: &["limit", "offset", "order"],
};
const N_ISSUES: Route = Route {
nsid: "org.tangled.repo.countIssues",
source: "$arg_subject$arg_repo|$arg_state",
var: "$ks_soc_n_issues",
miss: "invalid_request",
empty: Some(super::no_count),
refused: &[NO_AUTHOR],
ignored: &[],
};
const N_PULLS: Route = Route {
nsid: "org.tangled.repo.countPulls",
source: "$arg_subject$arg_repo|$arg_status$arg_state",
var: "$ks_soc_n_pulls",
miss: "invalid_request",
empty: Some(super::no_count),
refused: &[NO_AUTHOR],
ignored: &[],
};
const VIEW: Route = Route {
nsid: "org.tangled.track.getTicketView",
source: "$arg_uri",
var: "$ks_soc_ticket",
miss: "not_found",
empty: None,
refused: &[],
ignored: &[],
};
const COMMENTS: Route = Route {
nsid: "org.tangled.feed.listComments",
source: "$arg_subject|$arg_order",
var: "$ks_soc_comments",
miss: "invalid_request",
empty: Some(no_comments),
refused: &[NO_CURSOR, NO_OFFSET],
ignored: &["limit", "offset"],
};
const PATCHES: Route = Route {
nsid: "org.tangled.temp.review.listPatches",
source: "$arg_ticket|$arg_order",
var: "$ks_soc_patches",
miss: "invalid_request",
empty: Some(super::no_items),
refused: &[NO_CURSOR, NO_OFFSET],
ignored: &["limit", "offset"],
};
pub(super) const ROUTES: &[Route] = &[ISSUES, PULLS, N_ISSUES, N_PULLS, VIEW, COMMENTS, PATCHES];
fn no_comments() -> Value {
json!({ "items": [], "total": 0 })
}
pub(super) fn is_pull(ticket: &SocialRecord) -> bool {
!ticket.text("targetBranch").is_empty()
}
pub(super) fn state(ticket: &SocialRecord) -> &str {
match ticket.text("state") {
"" => "open",
state => state,
}
}
fn state_spellings(state: &str, pulls: bool) -> Vec<String> {
match state {
ALL => literal(&[""]),
"completed" if pulls => literal(&["completed", "merged"]),
state => literal(&[state]),
}
}
/// the svfe enrich readers read this exact shape.
#[derive(Default)]
struct Sidecar(BTreeMap<String, BTreeMap<&'static str, BTreeMap<&'static str, Value>>>);
impl Sidecar {
fn put(&mut self, reference: &str, source: &'static str, kind: &'static str, payload: Value) {
self.0
.entry(reference.to_string())
.or_default()
.entry(source)
.or_default()
.insert(kind, payload);
}
fn doc(&mut self, social: &Social, did: Option<&str>, source: &'static str) {
if let Some(did) = did.filter(|did| !did.is_empty()) {
self.put(did, source, MINIDOC, social.doc(did));
}
}
fn reactions(&mut self, social: &Social, uri: &str, reactions: Option<&Vec<&SocialRecord>>) {
let Some(reactions) = reactions.filter(|reactions| !reactions.is_empty()) else {
return;
};
let mut by_kind: BTreeMap<(usize, &str), Vec<&SocialRecord>> = BTreeMap::new();
for reaction in reactions {
let kind = reaction.text("reaction");
let rank = REACTION_ORDER
.iter()
.position(|known| *known == kind)
.unwrap_or(REACTION_ORDER.len());
by_kind.entry((rank, kind)).or_default().push(reaction);
}
let mut summaries = Vec::new();
for ((_, kind), mut reacted) in by_kind {
reacted.sort_by(|a, b| b.created_at().cmp(a.created_at()));
let reactors: Vec<&str> = reacted
.iter()
.filter_map(|reaction| reaction.author.as_deref())
.take(REACTORS_SHOWN)
.collect();
for reactor in &reactors {
self.doc(social, Some(reactor), REACTED);
}
summaries.push(json!({
"reaction": kind,
"count": reacted.len(),
"reactors": reactors,
}));
}
self.put(uri, REACTED, REACTIONS, json!({ "reactions": summaries }));
}
fn into_value(self) -> Value {
json!(self.0)
}
}
fn list_item(hosted: &Hosted, ticket: &SocialRecord) -> Value {
let mut item = ticket.view();
item["state"] = json!(state(ticket));
item["commentCount"] = json!(hosted.comment_count(&ticket.uri));
if let Some(author) = &ticket.author {
item["author"] = json!(author);
}
item
}
fn state_counts(tickets: &[&SocialRecord]) -> Value {
let mut counts = json!({ "open": 0, "closed": 0, "completed": 0 });
for ticket in tickets {
if let Some(count) = counts.get_mut(state(ticket)) {
*count = json!(count.as_u64().unwrap_or_default() + 1);
}
}
counts
}
fn search_size(limit: usize) -> usize {
SEARCH_SIZES
.iter()
.copied()
.rfind(|&size| size <= limit)
.unwrap_or(SEARCH_SIZES[0])
}
pub(super) fn maps() -> Vec<Block> {
let mut queries = vec![(String::from("|"), ALL.to_string())];
for state in STATES.iter().chain(&["merged"]) {
let label = if *state == "merged" {
"completed"
} else {
state
};
queries.push((format!("|{state}"), label.to_string()));
for query in spellings(&format!("is:{state}")) {
queries.push((format!("{query}|"), label.to_string()));
}
}
let pages = std::iter::once((String::new(), ALL.to_string()))
.chain(
(1..=SEARCH_LIMIT_MAX).map(|limit| (limit.to_string(), search_size(limit).to_string())),
)
.collect();
vec![
map_with_default("", "$arg_q|$arg_state", QUERY_STATE, queries),
map_with_default("", "$arg_limit", PAGE, pages),
map_with_default(
"$arg_offset$arg_cursor",
"$arg_offset$arg_cursor",
OFFSET,
vec![(String::new(), "0".into())],
),
]
}
/// free text needs a search index, and this knot keeps none.
pub(super) fn search_location() -> Block {
let refused = crate::nginx::error_body(
"InvalidQuery",
"this knot only answers is:open, is:closed, is:completed and is:merged",
);
reroute(
Block::exact(format!("/xrpc/{SEARCH_NSID}"))
.directive("default_type", ["application/json"])
.blocks(SEARCH_REFUSED.map(Refused::block))
.block(reroute(
Block::when("$args", "~*", OFFSET_AND_CURSOR),
"/_ks_error/invalid_request",
))
.block(
Block::when(QUERY_STATE, "=", "").directive("return", ["400".to_string(), refused]),
)
.block(empty(PAGE, "invalid_request"))
.block(empty(SEARCH, "invalid_request")),
format!("/_ks_atproto/{SEARCH}"),
)
}
fn search_map(rows: Vec<(String, String)>) -> Block {
map_with_default(
"",
&format!("$arg_repo$arg_repoDid|$arg_kind|{QUERY_STATE}|{PAGE}|{OFFSET}"),
SEARCH,
rows,
)
}
fn emit_search(
out: &mut Out,
hosted: &Hosted,
social: &Social,
rows: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
let repos = spellings(hosted.did);
for (kind, pulls) in KINDS {
let of_kind: Vec<&SocialRecord> = hosted
.tickets
.iter()
.copied()
.filter(|ticket| pulls.is_none_or(|pulls| is_pull(ticket) == pulls))
.collect();
let counts = state_counts(&of_kind);
for state_label in std::iter::once(ALL).chain(STATES) {
let matching: Vec<&SocialRecord> = of_kind
.iter()
.copied()
.filter(|ticket| state_label == ALL || state(ticket) == state_label)
.collect();
let sizes = std::iter::once(None).chain(SEARCH_SIZES.map(Some));
for size in sizes {
let step = size.unwrap_or(matching.len()).max(1);
let page_label = size.map_or(ALL.to_string(), |size| size.to_string());
let mut offset = 0;
loop {
let end = (offset + step).min(matching.len());
let mut data = Sidecar::default();
let hits: Vec<Value> = matching[offset..end]
.iter()
.map(|ticket| {
data.doc(social, ticket.author.as_deref(), TICKET_AUTHORS);
let mut hit = list_item(hosted, ticket);
hit["nsid"] = json!(TICKET);
hit["score"] = json!(1);
hit
})
.collect();
let mut body = json!({
"hits": hits,
"total": matching.len(),
"stateCounts": counts,
"data": data.into_value(),
});
if end < matching.len() {
body["cursor"] = json!(end.to_string());
}
let file = out.put(&body)?;
let key = keys(&[
&repos,
&literal(&[kind]),
&literal(&[state_label]),
&[page_label.clone()],
&[offset.to_string()],
]);
rows.extend(key.into_iter().map(|key| (key, file.clone())));
offset = end;
if offset >= matching.len() {
break;
}
}
}
}
}
Ok(())
}
fn emit_lists(out: &mut Out, hosted: &Hosted) -> anyhow::Result<()> {
let subjects = hosted.subjects();
for (list, count, pulls) in [(&ISSUES, &N_ISSUES, false), (&PULLS, &N_PULLS, true)] {
let of_kind: Vec<&SocialRecord> = hosted
.tickets
.iter()
.copied()
.filter(|ticket| is_pull(ticket) == pulls)
.collect();
for state_label in std::iter::once(ALL).chain(STATES) {
let matching: Vec<&SocialRecord> = of_kind
.iter()
.copied()
.filter(|ticket| state_label == ALL || state(ticket) == state_label)
.collect();
let key = keys(&[&subjects, &state_spellings(state_label, pulls)]);
out.answer(count, key.clone(), &super::count(&matching))?;
let items: Vec<Value> = matching.iter().map(|t| list_item(hosted, t)).collect();
out.answer(list, key, &json!({ "total": items.len(), "items": items }))?;
}
}
Ok(())
}
fn ticket_view(hosted: &Hosted, social: &Social, ticket: &SocialRecord, forks: usize) -> Value {
let author = ticket.author.as_deref().unwrap_or(hosted.did);
let mut view = json!({
"uri": ticket.uri,
"cid": ticket.cid,
"repo": super::repo::basic(hosted, social, forks),
"author": social.profile(author),
"title": ticket.value["title"],
"state": state(ticket),
"createdAt": ticket.created_at(),
});
for field in ["body", "tangledLabels", "assignees", "targetBranch"] {
if let Some(value) = ticket.value.get(field) {
view[field] = value.clone();
}
}
let mut data = Sidecar::default();
for assignee in ticket.value["assignees"].as_array().into_iter().flatten() {
data.doc(social, assignee.as_str(), TICKET_AUTHORS);
}
data.doc(social, Some(author), TICKET_AUTHORS);
data.reactions(
social,
&ticket.uri,
hosted.reactions.get(ticket.uri.as_str()),
);
view["data"] = data.into_value();
view
}
fn comment_view(comment: &SocialRecord) -> Value {
let mut view = json!({
"uri": comment.uri,
"cid": comment.cid,
"subject": comment.value["subject"],
"body": comment.value["body"],
"createdAt": comment.created_at(),
});
if let Some(reply_to) = comment.value.get("replyTo") {
view["replyTo"] = reply_to.clone();
}
if let Some(editor) = &comment.editor {
view["editor"] = json!(editor);
}
let embed = &comment.value["embed"];
if let Some(oid) = embed["commit"]["oid"].as_str() {
view["embed"] = json!({
"$type": "org.tangled.embed.commit#view",
"repo": embed["repo"],
"commit": oid,
});
}
view
}
fn emit_comments(out: &mut Out, hosted: &Hosted, social: &Social) -> anyhow::Result<()> {
for (subject, comments) in &hosted.comments {
let mut data = Sidecar::default();
for comment in comments {
data.doc(social, comment.editor.as_deref(), COMMENT_AUTHORS);
data.reactions(
social,
&comment.uri,
hosted.reactions.get(comment.uri.as_str()),
);
}
let data = data.into_value();
let newest: Vec<Value> = comments
.iter()
.map(|comment| comment_view(comment))
.collect();
let oldest: Vec<Value> = newest.iter().rev().cloned().collect();
let subjects = spellings(subject);
for (orders, items) in [
(literal(&["", "desc"]), newest),
(literal(&["asc"]), oldest),
] {
let body = json!({ "items": items, "total": comments.len(), "data": data });
out.answer(&COMMENTS, keys(&[&subjects, &orders]), &body)?;
}
}
Ok(())
}
fn emit_patches(
out: &mut Out,
hosted: &Hosted,
social: &Social,
by_did: &BTreeMap<&str, (&Hosted, usize)>,
) -> anyhow::Result<()> {
for (ticket, patches) in &hosted.patches {
let mut data = Sidecar::default();
for patch in patches {
let source = patch.value["source"]["repo"].as_str().unwrap_or_default();
if let Some((fork, forks)) = by_did.get(source).filter(|_| source != hosted.did) {
let basic = super::repo::basic(fork, social, *forks);
data.put(source, MANIFESTS, REPO_VIEW, basic);
}
}
let data = data.into_value();
let newest: Vec<Value> = patches.iter().map(|patch| patch.view()).collect();
let oldest: Vec<Value> = newest.iter().rev().cloned().collect();
let uris = spellings(&format!("at://{}/{TICKET}/{ticket}", hosted.did));
for (orders, items) in [
(literal(&["", "desc"]), newest),
(literal(&["asc"]), oldest),
] {
let body = json!({ "items": items, "total": patches.len(), "data": data });
out.answer(&PATCHES, keys(&[&uris, &orders]), &body)?;
}
}
Ok(())
}
pub(super) fn emit(out: &mut Out, hosted: &[Hosted], social: &Social) -> anyhow::Result<()> {
let forks = super::repo::forks(hosted);
let by_did: BTreeMap<&str, (&Hosted, usize)> = hosted
.iter()
.map(|repo| {
(
repo.did,
(repo, forks.get(repo.did).copied().unwrap_or_default()),
)
})
.collect();
let mut search = Vec::new();
for repo in hosted {
let forked = by_did[repo.did].1;
for ticket in &repo.tickets {
let view = ticket_view(repo, social, ticket, forked);
out.answer(&VIEW, spellings(&ticket.uri), &view)?;
}
emit_search(out, repo, social, &mut search)?;
emit_lists(out, repo)?;
emit_comments(out, repo, social)?;
emit_patches(out, repo, social, &by_did)?;
}
out.extra.push(search_map(search));
Ok(())
}