Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

579 linesCodeBlameRaw
1//! Answering searches, suggestions as someone types, and Explore.
2
3use std::collections::{HashMap, HashSet};
4
5use g1t_contracts::repos::{ReadableArgs, Repo};
6use g1t_contracts::search::*;
7use g1t_contracts::{Outcome, Viewer};
8use serde::Deserialize;
9use worker::Result;
10
11use crate::Search;
12use crate::query::{ItemKind, Query};
13use crate::rules::normalize_language;
14use crate::snippet;
15use crate::sql::{self, CodePlan, Page, Param, Sql};
16use crate::store;
17use crate::visibility::{Indexed, Reader, check};
18
19/// Results a page can reach: no further than the counts go.
20const MAX_PAGE: u32 = 50;
21/// Results of each kind in a suggestion.
22const SUGGEST_EACH: u32 = 4;
23/// Repositories on a page of Explore.
24const EXPLORE_PAGE: u32 = 24;
25/// Pieces of code read for each file on a page, at most.
26const PIECES_PER_FILE: u32 = 4;
27
28/// A path's segments made safe to put in an address.
29pub fn encode_path(path: &str) -> String {
30 path.split('/')
31 .map(|segment| {
32 let mut out = String::with_capacity(segment.len());
33 for byte in segment.bytes() {
34 match byte {
35 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'@' | b'+' | b'=' | b',' => {
36 out.push(byte as char)
37 }
38 _ => out.push_str(&format!("%{byte:02X}")),
39 }
40 }
41 out
42 })
43 .collect::<Vec<_>>()
44 .join("/")
45}
46
47fn split_labels(labels: &str) -> Vec<String> {
48 labels.split('|').filter(|label| !label.is_empty()).map(str::to_owned).collect()
49}
50
51fn split_topics(topics: &str) -> Vec<String> {
52 topics.split_whitespace().map(str::to_owned).collect()
53}
54
55#[derive(Deserialize)]
56struct RepoRow {
57 repo_id: String,
58 namespace: String,
59 name: String,
60 description: Option<String>,
61 topics: String,
62 language: Option<String>,
63 private: u8,
64 pushed_at: Option<String>,
65 created_at: String,
66 #[serde(default)]
67 readme: Option<String>,
68}
69
70#[derive(Deserialize)]
71struct FileHit {
72 fid: i64,
73}
74
75#[derive(Deserialize)]
76struct FileRow {
77 fid: i64,
78 path: String,
79 language: Option<String>,
80 repo_id: String,
81 namespace: String,
82 name: String,
83 default_branch: String,
84 private: u8,
85}
86
87#[derive(Deserialize)]
88struct PieceRow {
89 fid: i64,
90 start_line: u32,
91 content: String,
92}
93
94#[derive(Deserialize)]
95struct ItemRow {
96 kind: String,
97 #[serde(default)]
98 workspace: Option<String>,
99 number: u32,
100 title: String,
101 state: String,
102 status: String,
103 author: String,
104 #[serde(default)]
105 requested_by: Option<String>,
106 labels: String,
107 updated_at: String,
108 // A work item's origin, if it has one, as the index holds it.
109 #[serde(default)]
110 repo_id: Option<String>,
111 #[serde(default)]
112 namespace: Option<String>,
113 #[serde(default)]
114 name: Option<String>,
115 #[serde(default)]
116 private: Option<u8>,
117 body: Option<String>,
118}
119
120#[derive(Deserialize)]
121struct PersonRow {
122 kind: String,
123 slug: String,
124 name: Option<String>,
125 bio: Option<String>,
126 avatar: Option<String>,
127}
128
129/// A result, and what decides whether it is shown: the repository whose
130/// visibility it has, and for a work item its workspace, whose members see
131/// it whatever its repository (with none, only they do).
132struct Found {
133 hit: Hit,
134 repo: Option<Indexed>,
135 workspace: Option<String>,
136}
137
138impl Found {
139 fn shown(&self, reader: &Reader, keep: &HashSet<String>) -> bool {
140 if let Some(workspace) = &self.workspace
141 && reader.member_of(workspace)
142 {
143 return true;
144 }
145 match &self.repo {
146 Some(repo) => keep.contains(&repo.repo_id),
147 None => self.workspace.is_none(),
148 }
149 }
150}
151
152fn repo_hit(row: RepoRow, terms: &[String]) -> Found {
153 let full = format!("{}/{}", row.namespace, row.name);
154 let mut hit = Hit::new(HitKind::Repository, full.clone(), format!("/{}/{}", row.namespace, row.name));
155 hit.repo = Some(full);
156 hit.private = row.private != 0;
157 hit.snippet = match (&row.description, &row.readme) {
158 (Some(description), _) if !description.trim().is_empty() => snippet::prose(description, terms),
159 (_, Some(readme)) if snippet::contains_any(readme, terms) => snippet::prose(readme, terms),
160 _ => Vec::new(),
161 };
162 hit.description = row.description;
163 hit.language = row.language;
164 hit.topics = split_topics(&row.topics);
165 hit.updated_at = row.pushed_at.or(Some(row.created_at));
166 Found {
167 hit,
168 repo: Some(Indexed {
169 repo_id: row.repo_id,
170 namespace: row.namespace,
171 private: row.private != 0,
172 }),
173 workspace: None,
174 }
175}
176
177fn item_hit(row: ItemRow, terms: &[String]) -> Option<Found> {
178 let pull = row.kind == "pull";
179 let repo = match (row.repo_id, row.namespace, row.name) {
180 (Some(repo_id), Some(namespace), Some(name)) => {
181 Some((Indexed { repo_id, namespace, private: row.private.unwrap_or(1) != 0 }, name))
182 }
183 _ => None,
184 };
185 let full = repo.as_ref().map(|(repo, name)| format!("{}/{name}", repo.namespace));
186 let (kind, url, workspace) = if pull {
187 (HitKind::Pull, format!("/{}/pull/{}", full.as_deref()?, row.number), None)
188 } else {
189 let workspace = row.workspace?;
190 (HitKind::Item, format!("/{workspace}/-/work/{}", row.number), Some(workspace))
191 };
192 let mut hit = Hit::new(kind, row.title, url);
193 hit.repo = full;
194 hit.workspace = workspace.clone();
195 // A work item filed in no repository is for its workspace's members.
196 hit.private = repo.as_ref().is_none_or(|(repo, _)| repo.private);
197 hit.snippet = snippet::prose(row.body.as_deref().unwrap_or_default(), terms);
198 hit.number = Some(row.number);
199 hit.state = Some(if pull { row.status } else { row.state });
200 hit.author = Some(row.author);
201 hit.requested_by = row.requested_by;
202 hit.labels = split_labels(&row.labels);
203 hit.updated_at = Some(row.updated_at);
204 Some(Found { hit, repo: repo.map(|(repo, _)| repo), workspace })
205}
206
207fn person_hit(row: PersonRow, terms: &[String]) -> Found {
208 let workspace = row.kind == "workspace";
209 let url = if workspace { format!("/{}", row.slug) } else { format!("/u/{}", row.slug) };
210 let title = row.name.clone().filter(|name| !name.trim().is_empty()).unwrap_or_else(|| row.slug.clone());
211 let mut hit = Hit::new(if workspace { HitKind::Workspace } else { HitKind::User }, title, url);
212 hit.slug = Some(row.slug);
213 hit.snippet = row.bio.as_deref().map(|bio| snippet::prose(bio, terms)).unwrap_or_default();
214 hit.description = row.bio;
215 hit.avatar = row.avatar;
216 Found { hit, repo: None, workspace: None }
217}
218
219impl Search {
220 /// The second visibility check: of these repositories, the ones the
221 /// repos service says this viewer may read now, and of those, the ones
222 /// that are archived. Corrects the index where it was behind.
223 async fn readable(&self, viewer: &Viewer, indexed: &[Indexed]) -> (HashSet<String>, HashSet<String>) {
224 if indexed.is_empty() {
225 return (HashSet::new(), HashSet::new());
226 }
227 let ids: Vec<String> = indexed.iter().map(|row| row.repo_id.clone()).collect::<HashSet<_>>().into_iter().collect();
228 let readable: Result<Vec<Repo>> =
229 g1t_kit::call(&self.repos, "readable", &ReadableArgs { ids, viewer: viewer.clone() }).await;
230 if let Err(error) = &readable {
231 worker::console_error!("search: could not check visibility: {error}");
232 }
233 let verdict = check(&Reader::of(viewer), indexed, readable.as_deref().ok());
234 let archived: HashSet<String> = readable
235 .as_deref()
236 .unwrap_or_default()
237 .iter()
238 .filter(|repo| repo.archived())
239 .map(|repo| repo.id.clone())
240 .collect();
241 if !verdict.corrections.is_empty() {
242 let mut statements = Vec::new();
243 for correction in &verdict.corrections {
244 let (text, params) = match &correction.path {
245 Some((namespace, name)) => (
246 "UPDATE repos SET private = ?, namespace = ?, name = ? WHERE repo_id = ?",
247 vec![
248 Param::Int(i64::from(correction.private)),
249 Param::Text(namespace.clone()),
250 Param::Text(name.clone()),
251 Param::Text(correction.repo_id.clone()),
252 ],
253 ),
254 None => (
255 "UPDATE repos SET private = ? WHERE repo_id = ?",
256 vec![Param::Int(i64::from(correction.private)), Param::Text(correction.repo_id.clone())],
257 ),
258 };
259 if let Ok(statement) = store::prepare(&self.db, text, params) {
260 statements.push(statement);
261 }
262 }
263 if let Err(error) = store::run_all(&self.db, statements).await {
264 worker::console_error!("search: could not correct visibility: {error}");
265 }
266 }
267 (verdict.keep, archived)
268 }
269
270 /// Results the viewer may see, in order.
271 async fn admit(&self, viewer: &Viewer, found: Vec<Found>) -> Vec<Hit> {
272 let indexed: Vec<Indexed> = found.iter().filter_map(|f| f.repo.clone()).collect();
273 let (keep, _) = self.readable(viewer, &indexed).await;
274 let reader = Reader::of(viewer);
275 found
276 .into_iter()
277 .filter(|f| f.shown(&reader, &keep))
278 .map(|f| f.hit)
279 .collect()
280 }
281
282 async fn count_of(&self, kind: SearchType, query: &Query, reader: &Reader) -> Result<u32> {
283 if !query.applies_to(kind) {
284 return Ok(0);
285 }
286 let sql = match kind {
287 SearchType::Repositories => Some(sql::repos_count(query, reader)),
288 SearchType::Code => sql::code_plan(query).ok().map(|plan| sql::code_count(&plan, query, reader)),
289 SearchType::Items => Some(sql::items_count(query, reader, ItemKind::Item)),
290 SearchType::Pulls => Some(sql::items_count(query, reader, ItemKind::Pull)),
291 SearchType::People => sql::people_select(query).map(|select| sql::count(&select)),
292 };
293 match sql {
294 Some(sql) => Ok(store::count(&self.db, &sql).await?.min(COUNT_CAP)),
295 None => Ok(0),
296 }
297 }
298
299 async fn code_hits(&self, plan: &CodePlan, query: &Query, reader: &Reader, page: Page, terms: &[String]) -> Result<Vec<Found>> {
300 let fids: Vec<i64> = store::all::<FileHit>(&self.db, &sql::code_page(plan, query, reader, page))
301 .await?
302 .into_iter()
303 .map(|row| row.fid)
304 .collect();
305 if fids.is_empty() {
306 return Ok(Vec::new());
307 }
308 let files = store::all::<FileRow>(&self.db, &sql::code_files(&fids)).await?;
309 let mut pieces: HashMap<i64, Vec<PieceRow>> = HashMap::new();
310 for piece in store::all::<PieceRow>(&self.db, &sql::code_pieces(plan, &fids, page.limit * PIECES_PER_FILE)).await? {
311 pieces.entry(piece.fid).or_default().push(piece);
312 }
313 // Files that matched by name have no matching piece: their first.
314 let missing: Vec<i64> = fids.iter().copied().filter(|fid| !pieces.contains_key(fid)).collect();
315 if !missing.is_empty() {
316 for piece in store::all::<PieceRow>(&self.db, &sql::first_pieces(&missing)).await? {
317 pieces.entry(piece.fid).or_default().push(piece);
318 }
319 }
320 let by_id: HashMap<i64, FileRow> = files.into_iter().map(|file| (file.fid, file)).collect();
321 let mut found = Vec::new();
322 for fid in fids {
323 let Some(file) = by_id.get(&fid) else { continue };
324 let parts: Vec<(u32, &str)> = pieces
325 .get(&fid)
326 .map(|list| list.iter().take(PIECES_PER_FILE as usize).map(|p| (p.start_line, p.content.as_str())).collect())
327 .unwrap_or_default();
328 let lines = snippet::code(&parts, terms);
329 let line = snippet::first_match(&lines);
330 let full = format!("{}/{}", file.namespace, file.name);
331 let url = format!(
332 "/{full}/blob/{}/{}{}",
333 encode_path(&file.default_branch),
334 encode_path(&file.path),
335 line.map(|n| format!("#L{n}")).unwrap_or_default()
336 );
337 let mut hit = Hit::new(HitKind::Code, file.path.clone(), url);
338 hit.repo = Some(full);
339 hit.private = file.private != 0;
340 hit.path = Some(file.path.clone());
341 hit.language = file.language.clone();
342 hit.git_ref = Some(file.default_branch.clone());
343 hit.lines = lines;
344 found.push(Found {
345 hit,
346 repo: Some(Indexed {
347 repo_id: file.repo_id.clone(),
348 namespace: file.namespace.clone(),
349 private: file.private != 0,
350 }),
351 workspace: None,
352 });
353 }
354 Ok(found)
355 }
356
357 async fn page_of(&self, kind: SearchType, query: &Query, reader: &Reader, page: Page, terms: &[String]) -> Result<Vec<Found>> {
358 if !query.applies_to(kind) {
359 return Ok(Vec::new());
360 }
361 Ok(match kind {
362 SearchType::Repositories => store::all::<RepoRow>(&self.db, &sql::repos_page(query, reader, page))
363 .await?
364 .into_iter()
365 .map(|row| repo_hit(row, terms))
366 .collect(),
367 SearchType::Code => match sql::code_plan(query) {
368 Ok(plan) => self.code_hits(&plan, query, reader, page, terms).await?,
369 Err(_) => Vec::new(),
370 },
371 SearchType::Items | SearchType::Pulls => {
372 let item = if kind == SearchType::Items { ItemKind::Item } else { ItemKind::Pull };
373 store::all::<ItemRow>(&self.db, &sql::items_page(query, reader, item, page))
374 .await?
375 .into_iter()
376 .filter_map(|row| item_hit(row, terms))
377 .collect()
378 }
379 SearchType::People => match sql::people_page(query, page) {
380 Some(sql) => store::all::<PersonRow>(&self.db, &sql)
381 .await?
382 .into_iter()
383 .map(|row| person_hit(row, terms))
384 .collect(),
385 None => Vec::new(),
386 },
387 })
388 }
389
390 pub async fn search(&self, a: SearchArgs) -> Result<Outcome<SearchResults>> {
391 let query = Query::parse(&a.query);
392 let reader = Reader::of(&a.viewer);
393 let kind = a.kind.unwrap_or_else(|| query.default_type());
394 let per_page = a.per_page.unwrap_or(DEFAULT_PER_PAGE).clamp(1, MAX_PER_PAGE);
395 let page = a.page.unwrap_or(1).clamp(1, MAX_PAGE);
396 let mut results = SearchResults {
397 query: query.display(),
398 kind,
399 counts: SearchCounts::default(),
400 page,
401 per_page,
402 more: false,
403 hits: Vec::new(),
404 notes: query.notes.clone(),
405 };
406 if query.is_empty() {
407 results.notes.push("Type a word, a \"phrase\" or a qualifier such as repo:owner/name.".into());
408 return Ok(Outcome::Ok(results));
409 }
410 if kind == SearchType::Code
411 && let Err(why) = sql::code_plan(&query)
412 {
413 results.notes.push(why.to_owned());
414 }
415 for each in SearchType::ALL {
416 let counted = match self.count_of(each, &query, &reader).await {
417 Ok(count) => count,
418 Err(error) => {
419 worker::console_error!("search: counting {} failed: {error}", each.as_str());
420 0
421 }
422 };
423 results.counts.set(each, counted);
424 }
425 // One more than a page, to know whether there is another.
426 let window = Page { limit: per_page + 1, offset: (page - 1) * per_page };
427 let terms = query.highlights();
428 let found = match self.page_of(kind, &query, &reader, window, &terms).await {
429 Ok(found) => found,
430 Err(error) => {
431 // Most often a query the index could not read.
432 worker::console_error!("search: {} failed for {:?}: {error}", kind.as_str(), results.query);
433 results.notes.push("Part of that query could not be read. Try fewer symbols, or put them in quotes.".into());
434 Vec::new()
435 }
436 };
437 let before = found.len();
438 let mut hits = self.admit(&a.viewer, found).await;
439 // What the second check dropped is not counted either.
440 let dropped = (before - hits.len()) as u32;
441 results.counts.set(kind, results.counts.get(kind).saturating_sub(dropped));
442 results.more = hits.len() > per_page as usize;
443 hits.truncate(per_page as usize);
444 results.hits = hits;
445 Ok(Outcome::Ok(results))
446 }
447
448 /// A few repositories, work items, pull requests and people, for the
449 /// command palette. Never code: that is a full search away.
450 pub async fn suggest(&self, a: SuggestArgs) -> Result<Vec<Hit>> {
451 let query = Query::parse(&a.query);
452 if query.prose_match().is_none() {
453 return Ok(Vec::new());
454 }
455 let reader = Reader::of(&a.viewer);
456 let page = Page { limit: SUGGEST_EACH, offset: 0 };
457 let terms = query.highlights();
458 let mut found = Vec::new();
459 for kind in [SearchType::Repositories, SearchType::Items, SearchType::Pulls, SearchType::People] {
460 match self.page_of(kind, &query, &reader, page, &terms).await {
461 Ok(more) => found.extend(more),
462 Err(error) => worker::console_error!("search: suggesting {} failed: {error}", kind.as_str()),
463 }
464 }
465 Ok(self.admit(&a.viewer, found).await)
466 }
467
468 pub async fn explore(&self, a: ExploreArgs) -> Result<Explore> {
469 let newest = a.sort.as_deref() == Some("new");
470 let language = a.language.as_deref().map(str::trim).filter(|l| !l.is_empty()).map(normalize_language);
471 let topic = a.topic.as_deref().map(|t| t.trim().to_lowercase()).filter(|t| !t.is_empty());
472 let page = a.page.unwrap_or(1).clamp(1, MAX_PAGE);
473 let window = Page { limit: EXPLORE_PAGE + 1, offset: (page - 1) * EXPLORE_PAGE };
474 let rows = store::all::<RepoRow>(&self.db, &sql::explore(newest, language.as_deref(), topic.as_deref(), window)).await?;
475 let indexed: Vec<Indexed> = rows
476 .iter()
477 .map(|row| Indexed { repo_id: row.repo_id.clone(), namespace: row.namespace.clone(), private: row.private != 0 })
478 .collect();
479 // Explore is for everyone: checked as if signed out, whoever asks.
480 let (keep, archived) = self.readable(&None, &indexed).await;
481 let mut repos: Vec<ExploreRepo> = rows
482 .into_iter()
483 .filter(|row| keep.contains(&row.repo_id))
484 .map(|row| ExploreRepo {
485 archived: archived.contains(&row.repo_id),
486 id: row.repo_id,
487 namespace: row.namespace,
488 name: row.name,
489 description: row.description,
490 topics: split_topics(&row.topics),
491 language: row.language,
492 created_at: row.created_at,
493 pushed_at: row.pushed_at,
494 })
495 .collect();
496 let more = repos.len() > EXPLORE_PAGE as usize;
497 repos.truncate(EXPLORE_PAGE as usize);
498 let (languages, topics) = if page == 1 {
499 (
500 store::all::<Facet>(&self.db, &Sql { text: sql::LANGUAGE_FACETS.into(), params: vec![] }).await?,
501 store::all::<Facet>(&self.db, &Sql { text: sql::TOPIC_FACETS.into(), params: vec![] }).await?,
502 )
503 } else {
504 (Vec::new(), Vec::new())
505 };
506 Ok(Explore { repos, languages, topics, page, more })
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn paths_are_safe_in_addresses() {
516 assert_eq!(encode_path("src/main.rs"), "src/main.rs");
517 assert_eq!(encode_path("docs/a b#c?.md"), "docs/a%20b%23c%3F.md");
518 assert_eq!(encode_path("src/ünï.rs"), "src/%C3%BCn%C3%AF.rs");
519 }
520
521 fn row(kind: &str, workspace: Option<&str>, origin: Option<(&str, &str, bool)>) -> ItemRow {
522 ItemRow {
523 kind: kind.into(),
524 workspace: workspace.map(str::to_owned),
525 number: 12,
526 title: "Fix it".into(),
527 state: "open".into(),
528 status: "open".into(),
529 author: "ana".into(),
530 requested_by: None,
531 labels: "|bug|".into(),
532 updated_at: String::new(),
533 repo_id: origin.map(|_| "rep_1".to_owned()),
534 namespace: origin.map(|(namespace, _, _)| namespace.to_owned()),
535 name: origin.map(|(_, name, _)| name.to_owned()),
536 private: origin.map(|(_, _, private)| u8::from(private)),
537 body: None,
538 }
539 }
540
541 #[test]
542 fn work_items_are_at_their_workspace() {
543 let found = item_hit(row("item", Some("acme"), Some(("acme", "web", false))), &[]).unwrap();
544 assert_eq!(found.hit.kind, HitKind::Item);
545 assert_eq!(found.hit.url, "/acme/-/work/12");
546 assert_eq!(found.hit.repo.as_deref(), Some("acme/web"));
547 assert_eq!(found.hit.workspace.as_deref(), Some("acme"));
548 assert!(!found.hit.private);
549 // Filed in no repository: for members only.
550 let found = item_hit(row("item", Some("acme"), None), &[]).unwrap();
551 assert_eq!(found.hit.repo, None);
552 assert!(found.hit.private);
553 let pull = item_hit(row("pull", None, Some(("acme", "web", false))), &[]).unwrap();
554 assert_eq!(pull.hit.url, "/acme/web/pull/12");
555 }
556
557 #[test]
558 fn the_second_check_lets_members_see_their_work_items() {
559 use g1t_contracts::{Membership, User};
560 let member = Reader::of(&Some(User { workspaces: vec![Membership::member("acme")], ..User::default() }));
561 let none = HashSet::new();
562 let readable = HashSet::from(["rep_1".to_owned()]);
563 let filed = item_hit(row("item", Some("acme"), Some(("acme", "web", true))), &[]).unwrap();
564 let loose = item_hit(row("item", Some("acme"), None), &[]).unwrap();
565 assert!(filed.shown(&member, &none) && loose.shown(&member, &none));
566 // Others, only where the repos service says they read its origin.
567 let outsider = Reader::default();
568 assert!(!filed.shown(&outsider, &none));
569 assert!(filed.shown(&outsider, &readable));
570 assert!(!loose.shown(&outsider, &readable));
571 }
572
573 #[test]
574 fn labels_and_topics_are_split() {
575 assert_eq!(split_labels("|bug|good first issue|"), vec!["bug", "good first issue"]);
576 assert!(split_labels("|").is_empty());
577 assert_eq!(split_topics("cli rust"), vec!["cli", "rust"]);
578 }
579}