Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Default owners for everything in the repo.
# Synced into .github/VOUCHED.td by the "Vouch - Sync CODEOWNERS" workflow.
* @omarkurt @dogancanbakir @recepgunes1 @ArjinAlbay @baranylcn
75 changes: 75 additions & 0 deletions packages/core/__tests__/github/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,78 @@ describe("getRepositoryContext", () => {
).rejects.toThrow(RepositoryNotFoundError);
});
});

describe("searchUsers", () => {
let mockOctokit: { graphql: ReturnType<typeof vi.fn>; request: ReturnType<typeof vi.fn> };

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);
});
});
1 change: 1 addition & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const BADGE_DEFINITIONS: Readonly<Record<BadgeId, Badge>> = {

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)));
Expand Down
29 changes: 28 additions & 1 deletion packages/core/src/github/client.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -29,6 +29,7 @@ import type {
RepoContext,
RepositoryContext,
ScoreResult,
UserSearchResult,
UserStats,
VouchAction,
VouchActionResult,
Expand Down Expand Up @@ -64,6 +65,7 @@ import {
countRepoPRsTotal,
fetchVouchFile,
postVouchComment,
searchUsersByLogin,
} from "./rest-queries.js";
import { fetchScorecard } from "./scorecard.js";

Expand Down Expand Up @@ -429,6 +431,30 @@ export const createGitBazClient = (options: GitBazClientOptions = {}): GitBazCli
return result;
};

const searchUsers = async (query: string, limit = 6): Promise<readonly UserSearchResult[]> => {
// 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<UserSearchResult[]>(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,
Expand Down Expand Up @@ -521,6 +547,7 @@ export const createGitBazClient = (options: GitBazClientOptions = {}): GitBazCli
getBlameAnalysis,
getVouchStatus,
isCollaborator,
searchUsers,
postVouchAction,
};
};
26 changes: 25 additions & 1 deletion packages/core/src/github/rest-queries.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<UserSearchResult[]> => {
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",
}));
};
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type {
SizeCategory,
Tier,
TierId,
UserSearchResult,
UserStats,
VouchAction,
VouchActionResult,
Expand All @@ -56,6 +57,7 @@ export {
DEFAULT_CACHE_TTL,
HALF_POINTS,
TIER_THRESHOLDS,
USER_SEARCH_CACHE_TTL,
VOUCH_CACHE_TTL,
} from "./constants.js";

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScoreResult>;
getStats(username: string, repo?: RepoContext): Promise<UserStats>;
Expand All @@ -349,6 +356,7 @@ export interface GitBazClient {
getBlameAnalysis(repo: RepoContext, filePaths: readonly string[]): Promise<KnowledgeSiloResult>;
getVouchStatus(username: string, repo: RepoContext): Promise<VouchLookupResult>;
isCollaborator(repo: RepoContext): Promise<boolean>;
searchUsers(query: string, limit?: number): Promise<readonly UserSearchResult[]>;
postVouchAction(
repo: RepoContext,
issueNumber: number,
Expand Down
11 changes: 11 additions & 0 deletions packages/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
52 changes: 51 additions & 1 deletion packages/extension/entrypoints/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,64 @@
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; }
</style>
</head>
<body>
<h1>GitBaz</h1>
<div id="auth-status" class="status">Checking...</div>
<div id="lookup">
<input type="text" id="username" placeholder="GitHub username" />
<div class="search">
<input
type="text"
id="username"
placeholder="GitHub username"
autocomplete="off"
spellcheck="false"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="suggestions"
/>
<ul id="suggestions" class="suggestions" role="listbox" hidden></ul>
</div>
<button class="primary" id="lookup-btn">Get Contributor Score</button>
</div>
<div id="result"></div>
Expand Down
Loading
Loading