g1t/packages/contracts/src/search.ts

164 lines5,289 bytesCodeBlame
1import type { ServiceBinding } from "./clients";
2import type { Viewer } from "./identity";
3import type { Result } from "./result";
4
5/**
6 * Search across all of g1t: repositories (name, description, topics,
7 * README), code on default branches, issues and pull requests, people and
8 * workspaces. Everything public, and private content the viewer is a member
9 * of, decided when the query runs. Mirrors `crates/contracts/src/search.rs`.
10 */
11
12/** One tab of results. */
13export type SearchType = "repositories" | "code" | "issues" | "pulls" | "people";
14
15export const SEARCH_TYPES: SearchType[] = ["repositories", "code", "issues", "pulls", "people"];
16
17/** The most results on one page, and how many a page has when none is asked for. */
18export const MAX_PER_PAGE = 50;
19export const DEFAULT_PER_PAGE = 20;
20/** Counts stop here: past it a tab says "1,000+". */
21export const COUNT_CAP = 1000;
22
23/** A piece of a snippet, highlighted where it matched the query. */
24export type Segment = { text: string; highlight: boolean };
25
26/** A line of code in a result, numbered from 1. */
27export type CodeLine = { number: number; parts: Segment[] };
28
29export type HitKind = "repository" | "code" | "issue" | "pull" | "user" | "workspace";
30
31/** One result. Which fields are set depends on `kind`. */
32export type SiteHit = {
33 kind: HitKind;
34 /** `acme/web`, a file's path, an issue's title, a person's name. */
35 title: string;
36 /** A path on g1t.sh: `/acme/web/blob/main/src/app.rs#L12`. */
37 url: string;
38 /** `owner/name`, for everything but people. */
39 repo: string | null;
40 private: boolean;
41 description: string | null;
42 snippet: Segment[];
43 /** Code only: the lines that matched, with a line around each. */
44 lines: CodeLine[];
45 path: string | null;
46 language: string | null;
47 ref: string | null;
48 number: number | null;
49 /** `open`/`closed` for an issue; `draft`/`open`/`merged`/`closed` for a pull request. */
50 state: string | null;
51 /** Who opened the issue or pull request: `g1t` for one g1t made. */
52 author: string | null;
53 /** For an issue or pull request g1t opened: who asked for it. */
54 requestedBy?: string | null;
55 labels: string[];
56 topics: string[];
57 /** A username or a workspace's slug. */
58 slug: string | null;
59 avatar: string | null;
60 /** RFC 3339. */
61 updatedAt: string | null;
62};
63
64export type SearchCounts = Record<SearchType, number>;
65
66export type SearchResults = {
67 /** The query as it was read. */
68 query: string;
69 type: SearchType;
70 counts: SearchCounts;
71 page: number;
72 perPage: number;
73 more: boolean;
74 hits: SiteHit[];
75 /** What the query could not do, said plainly. */
76 notes: string[];
77};
78
79export type ExploreRepo = {
80 namespace: string;
81 name: string;
82 description: string | null;
83 topics: string[];
84 language: string | null;
85 createdAt: string;
86 pushedAt: string | null;
87 /** Whether it is archived: read-only, kept for reference. */
88 archived: boolean;
89};
90
91export type Facet = { name: string; count: number };
92
93export type Explore = {
94 repos: ExploreRepo[];
95 languages: Facet[];
96 topics: Facet[];
97 page: number;
98 more: boolean;
99};
100
101export interface SearchApi {
102 /** One page of results of one type, with counts for every type. */
103 search(
104 viewer: Viewer,
105 query: string,
106 options?: { type?: SearchType | null; page?: number; perPage?: number },
107 ): Promise<Result<SearchResults>>;
108 /** A few repositories, issues, pull requests and people, as someone types. */
109 suggest(viewer: Viewer, query: string): Promise<SiteHit[]>;
110 /** Public repositories, recently active or new, by language or topic. */
111 explore(
112 viewer: Viewer,
113 options?: { sort?: "active" | "new"; language?: string | null; topic?: string | null; page?: number },
114 ): Promise<Explore>;
115}
116
117export function searchClient(service: ServiceBinding): SearchApi {
118 const call = async <T>(method: string, args: object): Promise<T> => {
119 const response = await service.fetch(`https://service/rpc/${method}`, {
120 method: "POST",
121 headers: { "content-type": "application/json" },
122 body: JSON.stringify(args),
123 });
124 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
125 return (await response.json()) as T;
126 };
127 return {
128 search: (viewer, query, options = {}) =>
129 call("search", { viewer, query, type: options.type ?? null, page: options.page ?? null, perPage: options.perPage ?? null }),
130 suggest: (viewer, query) => call("suggest", { viewer, query }),
131 explore: (viewer, options = {}) =>
132 call("explore", {
133 viewer,
134 sort: options.sort ?? null,
135 language: options.language ?? null,
136 topic: options.topic ?? null,
137 page: options.page ?? null,
138 }),
139 };
140}
141
142/** Reads a type as an address or a person writes it; null when it is not one. */
143export function searchType(value: string | null | undefined): SearchType | null {
144 switch ((value ?? "").trim().toLowerCase()) {
145 case "repositories":
146 case "repos":
147 case "repo":
148 return "repositories";
149 case "code":
150 return "code";
151 case "issues":
152 case "issue":
153 return "issues";
154 case "pulls":
155 case "prs":
156 case "pr":
157 return "pulls";
158 case "people":
159 case "users":
160 return "people";
161 default:
162 return null;
163 }
164}