From e9f53e786b68b20d3b7dbd4f9a254c2b99556abe Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 4 Nov 2026 22:39:10 +0300 Subject: [PATCH 1/2] add CODEOWNERS and user search suggestions in popup - add .github/CODEOWNERS so the vouch sync-codeowners workflow has a source (closes #1) - add searchUsers() to core client backed by GitHub user search, with input validation, limit clamping and caching - show matching users below the popup search bar with keyboard navigation (closes #2) --- .github/CODEOWNERS | 3 + packages/core/__tests__/github/client.test.ts | 75 ++++++++ packages/core/src/constants.ts | 1 + packages/core/src/github/client.ts | 29 ++- packages/core/src/github/rest-queries.ts | 26 ++- packages/core/src/index.ts | 2 + packages/core/src/types.ts | 8 + packages/extension/entrypoints/background.ts | 11 ++ packages/extension/entrypoints/popup.html | 52 +++++- packages/extension/entrypoints/popup/main.ts | 173 +++++++++++++++++- packages/extension/utils/messaging.ts | 15 ++ 11 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5e05ce2 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Default owners for everything in the repo. +# Synced into .github/VOUCHED.td by the "Vouch - Sync CODEOWNERS" workflow. +* @dogancanbakir diff --git a/packages/core/__tests__/github/client.test.ts b/packages/core/__tests__/github/client.test.ts index 8f9bf76..5b6079b 100644 --- a/packages/core/__tests__/github/client.test.ts +++ b/packages/core/__tests__/github/client.test.ts @@ -410,3 +410,78 @@ describe("getRepositoryContext", () => { ).rejects.toThrow(RepositoryNotFoundError); }); }); + +describe("searchUsers", () => { + let mockOctokit: { graphql: ReturnType; request: ReturnType }; + + const searchResponse = { + data: { + total_count: 3, + items: [ + { login: "octocat", avatar_url: "https://avatars/1", type: "User" }, + { login: "octo-org", avatar_url: "https://avatars/2", type: "Organization" }, + { login: "octo-bot", avatar_url: "https://avatars/3", type: "Bot" }, + ], + }, + }; + + beforeEach(async () => { + const { Octokit } = await import("@octokit/core"); + mockOctokit = { + graphql: vi.fn(), + request: vi.fn().mockResolvedValue(searchResponse), + }; + vi.mocked(Octokit).mockImplementation(() => mockOctokit as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("maps search results to UserSearchResult", async () => { + const client = createGitBazClient(); + const results = await client.searchUsers("octo"); + + expect(mockOctokit.request).toHaveBeenCalledWith("GET /search/users", { + q: "octo in:login", + per_page: 6, + }); + expect(results).toEqual([ + { login: "octocat", avatarUrl: "https://avatars/1", type: "User" }, + { login: "octo-org", avatarUrl: "https://avatars/2", type: "Organization" }, + { login: "octo-bot", avatarUrl: "https://avatars/3", type: "Bot" }, + ]); + }); + + it("strips a leading @ and clamps the limit", async () => { + const client = createGitBazClient(); + await client.searchUsers("@octo", 100); + expect(mockOctokit.request).toHaveBeenCalledWith("GET /search/users", { + q: "octo in:login", + per_page: 20, + }); + }); + + it("returns empty without calling the API for empty or invalid input", async () => { + const client = createGitBazClient(); + expect(await client.searchUsers("")).toEqual([]); + expect(await client.searchUsers(" ")).toEqual([]); + expect(await client.searchUsers("bad user")).toEqual([]); + expect(await client.searchUsers("a+b:c")).toEqual([]); + expect(mockOctokit.request).not.toHaveBeenCalled(); + }); + + it("caches search results", async () => { + const cache = new MemoryCacheAdapter(); + const client = createGitBazClient({ cache }); + await client.searchUsers("octo"); + await client.searchUsers("OCTO"); + expect(mockOctokit.request).toHaveBeenCalledTimes(1); + }); + + it("wraps 401 errors as AuthenticationError", async () => { + mockOctokit.request.mockRejectedValue(new Error("Bad credentials")); + const client = createGitBazClient(); + await expect(client.searchUsers("octo")).rejects.toThrow(AuthenticationError); + }); +}); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 3453d44..4cf9912 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -83,6 +83,7 @@ export const BADGE_DEFINITIONS: Readonly> = { export const DEFAULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes export const VOUCH_CACHE_TTL = 5 * 60 * 1000; // 5 minutes — vouch files change when commands are issued +export const USER_SEARCH_CACHE_TTL = 10 * 60 * 1000; // 10 minutes — autocomplete suggestions export function getTierForScore(score: number): Tier { const clamped = Math.max(0, Math.min(100, Math.round(score))); diff --git a/packages/core/src/github/client.ts b/packages/core/src/github/client.ts index 1087b45..7a6a44b 100644 --- a/packages/core/src/github/client.ts +++ b/packages/core/src/github/client.ts @@ -1,7 +1,7 @@ import { Octokit } from "@octokit/core"; import { calculateStreaks } from "../activity/streaks.js"; import { buildCacheKey, buildContextCacheKey, buildRepoCacheKey } from "../cache/utils.js"; -import { DEFAULT_CACHE_TTL, VOUCH_CACHE_TTL } from "../constants.js"; +import { DEFAULT_CACHE_TTL, USER_SEARCH_CACHE_TTL, VOUCH_CACHE_TTL } from "../constants.js"; import { analyzeKnowledgeSilos } from "../context/knowledge-silo.js"; import { computeBusFactor } from "../context/knowledge-silo.js"; import { detectBot } from "../contributor/bot-detect.js"; @@ -29,6 +29,7 @@ import type { RepoContext, RepositoryContext, ScoreResult, + UserSearchResult, UserStats, VouchAction, VouchActionResult, @@ -64,6 +65,7 @@ import { countRepoPRsTotal, fetchVouchFile, postVouchComment, + searchUsersByLogin, } from "./rest-queries.js"; import { fetchScorecard } from "./scorecard.js"; @@ -429,6 +431,30 @@ export const createGitBazClient = (options: GitBazClientOptions = {}): GitBazCli return result; }; + const searchUsers = async (query: string, limit = 6): Promise => { + // GitHub logins: alphanumerics and single hyphens, max 39 chars + const term = query.trim().replace(/^@/, ""); + if (!term || !/^[a-zA-Z0-9-]{1,39}$/.test(term)) return []; + + const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 20); + const cacheKey = `usersearch:${term.toLowerCase()}:${safeLimit}`; + + if (cache) { + const cached = await cache.get(cacheKey); + if (cached) return cached; + } + + try { + const results = await searchUsersByLogin(octokit, term, safeLimit); + if (cache) { + await cache.set(cacheKey, results, USER_SEARCH_CACHE_TTL); + } + return results; + } catch (error) { + return handleApiError(error, term); + } + }; + const postVouchAction = async ( repo: RepoContext, issueNumber: number, @@ -521,6 +547,7 @@ export const createGitBazClient = (options: GitBazClientOptions = {}): GitBazCli getBlameAnalysis, getVouchStatus, isCollaborator, + searchUsers, postVouchAction, }; }; diff --git a/packages/core/src/github/rest-queries.ts b/packages/core/src/github/rest-queries.ts index 1040a92..45551b2 100644 --- a/packages/core/src/github/rest-queries.ts +++ b/packages/core/src/github/rest-queries.ts @@ -1,5 +1,5 @@ import type { Octokit } from "@octokit/core"; -import type { RepoContext } from "../types.js"; +import type { RepoContext, UserSearchResult } from "../types.js"; interface SearchResult { readonly total_count: number; @@ -96,3 +96,27 @@ export const postVouchComment = async ( ); return response.data as { id: number; html_url: string }; }; + +interface RestUserSearchItem { + readonly login: string; + readonly avatar_url: string; + readonly type: string; +} + +/** Searches GitHub users by login prefix/substring (for autocomplete suggestions) */ +export const searchUsersByLogin = async ( + octokit: Octokit, + query: string, + limit: number, +): Promise => { + const response = await octokit.request("GET /search/users", { + q: `${query} in:login`, + per_page: limit, + }); + const items = (response.data as { items?: RestUserSearchItem[] }).items ?? []; + return items.map((item) => ({ + login: item.login, + avatarUrl: item.avatar_url, + type: item.type === "Organization" || item.type === "Bot" ? item.type : "User", + })); +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 00b6a3c..81942e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,7 @@ export type { SizeCategory, Tier, TierId, + UserSearchResult, UserStats, VouchAction, VouchActionResult, @@ -56,6 +57,7 @@ export { DEFAULT_CACHE_TTL, HALF_POINTS, TIER_THRESHOLDS, + USER_SEARCH_CACHE_TTL, VOUCH_CACHE_TTL, } from "./constants.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8d65cf2..ef54807 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -336,6 +336,13 @@ export interface GitBazClientOptions { readonly cacheTtlMs?: number; } +/** A lightweight GitHub user match returned by user search (for autocomplete) */ +export interface UserSearchResult { + readonly login: string; + readonly avatarUrl: string; + readonly type: "User" | "Organization" | "Bot"; +} + export interface GitBazClient { getScore(username: string, repo?: RepoContext): Promise; getStats(username: string, repo?: RepoContext): Promise; @@ -349,6 +356,7 @@ export interface GitBazClient { getBlameAnalysis(repo: RepoContext, filePaths: readonly string[]): Promise; getVouchStatus(username: string, repo: RepoContext): Promise; isCollaborator(repo: RepoContext): Promise; + searchUsers(query: string, limit?: number): Promise; postVouchAction( repo: RepoContext, issueNumber: number, diff --git a/packages/extension/entrypoints/background.ts b/packages/extension/entrypoints/background.ts index ccf74fd..01512d7 100644 --- a/packages/extension/entrypoints/background.ts +++ b/packages/extension/entrypoints/background.ts @@ -36,6 +36,17 @@ export default defineBackground(() => { error: errorMessage, }); } + } else if (message.type === "SEARCH_USERS") { + try { + const client = await createClient(); + const result = await client.searchUsers(message.query, message.limit); + sendResponse({ type: "SEARCH_USERS_RESULT", result }); + } catch (error) { + sendResponse({ + type: "SEARCH_USERS_RESULT", + error: (error as Error).message, + }); + } } else if (message.type === "GET_PULL_REQUEST") { try { const client = await createClient(); diff --git a/packages/extension/entrypoints/popup.html b/packages/extension/entrypoints/popup.html index 6cc09a4..47a0a2d 100644 --- a/packages/extension/entrypoints/popup.html +++ b/packages/extension/entrypoints/popup.html @@ -34,6 +34,43 @@ button:hover { background: #eef0f2; } button.primary { background: #2da44e; color: #fff; border-color: #2da44e; } button.primary:hover { background: #218838; } + .search { position: relative; } + .suggestions { + position: absolute; + top: calc(100% - 4px); + left: 0; + right: 0; + z-index: 10; + list-style: none; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2); + max-height: 216px; + overflow-y: auto; + padding: 4px 0; + } + .suggestions[hidden] { display: none; } + .suggestion { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + cursor: pointer; + } + .suggestion[aria-selected="true"], .suggestion:hover { background: #f6f8fa; } + .suggestion img { + width: 20px; + height: 20px; + border-radius: 50%; + flex-shrink: 0; + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; + } + .suggestion.org img { border-radius: 4px; } + .suggestion .login { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .suggestion .kind { color: #656d76; font-size: 11px; } + .suggestions .empty { padding: 6px 10px; color: #656d76; } #result { margin-top: 12px; white-space: pre-wrap; font-size: 12px; } @@ -41,7 +78,20 @@

GitBaz

Checking...
- +
diff --git a/packages/extension/entrypoints/popup/main.ts b/packages/extension/entrypoints/popup/main.ts index 367e0d4..d5e553f 100644 --- a/packages/extension/entrypoints/popup/main.ts +++ b/packages/extension/entrypoints/popup/main.ts @@ -1,9 +1,20 @@ -import type { ScoreResponse, TokenResponse } from "../../utils/messaging.js"; +import type { UserSearchResult } from "@happyhackingspace/gitbaz"; +import type { ScoreResponse, SearchUsersResponse, TokenResponse } from "../../utils/messaging.js"; + +const SEARCH_DEBOUNCE_MS = 250; +const MIN_QUERY_LENGTH = 2; +const SUGGESTION_LIMIT = 6; const authStatus = document.getElementById("auth-status") as HTMLDivElement; const usernameInput = document.getElementById("username") as HTMLInputElement; const lookupBtn = document.getElementById("lookup-btn") as HTMLButtonElement; const resultDiv = document.getElementById("result") as HTMLDivElement; +const suggestionsList = document.getElementById("suggestions") as HTMLUListElement; + +let suggestions: readonly UserSearchResult[] = []; +let activeIndex = -1; +let searchTimer: ReturnType | undefined; +let searchSeq = 0; const checkAuth = async (): Promise => { const response: TokenResponse = await chrome.runtime.sendMessage({ type: "GET_TOKEN" }); @@ -15,10 +26,142 @@ const checkAuth = async (): Promise => { } }; +// --- Suggestions --- + +const hideSuggestions = (): void => { + suggestions = []; + activeIndex = -1; + suggestionsList.hidden = true; + suggestionsList.replaceChildren(); + usernameInput.setAttribute("aria-expanded", "false"); + usernameInput.removeAttribute("aria-activedescendant"); +}; + +const setActive = (index: number): void => { + activeIndex = index; + const items = suggestionsList.querySelectorAll(".suggestion"); + items.forEach((item, i) => { + const selected = i === index; + item.setAttribute("aria-selected", String(selected)); + if (selected) { + usernameInput.setAttribute("aria-activedescendant", item.id); + item.scrollIntoView({ block: "nearest" }); + } + }); + if (index < 0) usernameInput.removeAttribute("aria-activedescendant"); +}; + +const kindLabel = (type: UserSearchResult["type"]): string => + type === "Organization" ? "org" : type === "Bot" ? "bot" : ""; + +const renderSuggestions = (items: readonly UserSearchResult[], message?: string): void => { + suggestions = items; + activeIndex = -1; + suggestionsList.replaceChildren(); + + if (items.length === 0) { + if (!message) { + hideSuggestions(); + return; + } + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = message; + suggestionsList.append(empty); + } + + items.forEach((user, i) => { + const li = document.createElement("li"); + li.id = `suggestion-${i}`; + li.className = user.type === "Organization" ? "suggestion org" : "suggestion"; + li.setAttribute("role", "option"); + li.setAttribute("aria-selected", "false"); + + const img = document.createElement("img"); + img.src = `${user.avatarUrl}${user.avatarUrl.includes("?") ? "&" : "?"}s=40`; + img.alt = ""; + img.loading = "lazy"; + + const login = document.createElement("span"); + login.className = "login"; + login.textContent = user.login; + + li.append(img, login); + + const kind = kindLabel(user.type); + if (kind) { + const kindEl = document.createElement("span"); + kindEl.className = "kind"; + kindEl.textContent = kind; + li.append(kindEl); + } + + // mousedown (not click) so it fires before the input's blur hides the list + li.addEventListener("mousedown", (e) => { + e.preventDefault(); + selectSuggestion(i); + }); + li.addEventListener("mousemove", () => { + if (activeIndex !== i) setActive(i); + }); + + suggestionsList.append(li); + }); + + suggestionsList.hidden = false; + usernameInput.setAttribute("aria-expanded", "true"); +}; + +const searchUsers = async (query: string): Promise => { + const seq = ++searchSeq; + const response: SearchUsersResponse = await chrome.runtime.sendMessage({ + type: "SEARCH_USERS", + query, + limit: SUGGESTION_LIMIT, + }); + + // Ignore stale responses (user kept typing) or input that no longer matches + if (seq !== searchSeq || usernameInput.value.trim() !== query) return; + if (document.activeElement !== usernameInput) return; + + if (response.error) { + renderSuggestions([], "Couldn't load suggestions"); + } else { + renderSuggestions(response.result ?? [], "No matching users"); + } +}; + +const scheduleSearch = (): void => { + clearTimeout(searchTimer); + const query = usernameInput.value.trim(); + if (query.replace(/^@/, "").length < MIN_QUERY_LENGTH) { + searchSeq++; + hideSuggestions(); + return; + } + searchTimer = setTimeout(() => searchUsers(query), SEARCH_DEBOUNCE_MS); +}; + +function selectSuggestion(index: number): void { + const user = suggestions[index]; + if (!user) return; + usernameInput.value = user.login; + clearTimeout(searchTimer); + searchSeq++; + hideSuggestions(); + lookupUser(); +} + +// --- Lookup --- + const lookupUser = async (): Promise => { - const username = usernameInput.value.trim(); + const username = usernameInput.value.trim().replace(/^@/, ""); if (!username) return; + clearTimeout(searchTimer); + searchSeq++; + hideSuggestions(); + resultDiv.textContent = "Loading..."; lookupBtn.disabled = true; @@ -38,8 +181,32 @@ const lookupUser = async (): Promise => { }; lookupBtn.addEventListener("click", lookupUser); +usernameInput.addEventListener("input", scheduleSearch); +usernameInput.addEventListener("blur", () => { + searchSeq++; + hideSuggestions(); +}); usernameInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") lookupUser(); + const open = !suggestionsList.hidden && suggestions.length > 0; + + if (e.key === "ArrowDown" && open) { + e.preventDefault(); + setActive((activeIndex + 1) % suggestions.length); + } else if (e.key === "ArrowUp" && open) { + e.preventDefault(); + setActive(activeIndex <= 0 ? suggestions.length - 1 : activeIndex - 1); + } else if (e.key === "Escape" && !suggestionsList.hidden) { + e.preventDefault(); + searchSeq++; + hideSuggestions(); + } else if (e.key === "Enter") { + e.preventDefault(); + if (open && activeIndex >= 0) { + selectSuggestion(activeIndex); + } else { + lookupUser(); + } + } }); checkAuth(); diff --git a/packages/extension/utils/messaging.ts b/packages/extension/utils/messaging.ts index 683132e..9ce2d8e 100644 --- a/packages/extension/utils/messaging.ts +++ b/packages/extension/utils/messaging.ts @@ -9,6 +9,7 @@ import type { RepoContext, RepositoryContext, ScoreResult, + UserSearchResult, VouchAction, VouchActionResult, VouchLookupResult, @@ -26,6 +27,18 @@ export interface ScoreResponse { error?: string; } +export interface SearchUsersRequest { + type: "SEARCH_USERS"; + query: string; + limit?: number; +} + +export interface SearchUsersResponse { + type: "SEARCH_USERS_RESULT"; + result?: readonly UserSearchResult[]; + error?: string; +} + export interface PullRequestRequest { type: "GET_PULL_REQUEST"; ref: ContributionRef; @@ -159,6 +172,7 @@ export interface OpenSettingsRequest { export type ExtensionMessage = | ScoreRequest + | SearchUsersRequest | PullRequestRequest | IssueRequest | DiscussionRequest @@ -174,6 +188,7 @@ export type ExtensionMessage = export type ExtensionResponse = | ScoreResponse + | SearchUsersResponse | PullRequestResponse | IssueResponse | DiscussionResponse From 43cf8437cd7202fe4e9706a8b7ac4ba439822c54 Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 5 Nov 2026 21:20:07 +0300 Subject: [PATCH 2/2] add more maintainers to CODEOWNERS --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5e05ce2..c388542 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # Default owners for everything in the repo. # Synced into .github/VOUCHED.td by the "Vouch - Sync CODEOWNERS" workflow. -* @dogancanbakir +* @omarkurt @dogancanbakir @recepgunes1 @ArjinAlbay @baranylcn