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
51 changes: 51 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,57 @@ test('writes a report when the GitHub calls succeed', async () => {
expect(JSON.stringify(analytics.captureCalls)).not.toContain('widgets');
});

test('excludes forked repos from the crawl', async () => {
const { ctx, githubClient, analytics } = createFakeContext();

githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([
{
name: 'widgets',
owner: { login: 'acme' },
private: true,
visibility: 'private',
archived: false,
fork: false,
default_branch: 'main',
language: 'TypeScript',
pushed_at: '2026-04-01T00:00:00Z',
},
{
name: 'upstream-fork',
owner: { login: 'acme' },
private: false,
visibility: 'public',
archived: false,
fork: true,
default_branch: 'main',
language: 'Go',
pushed_at: '2026-04-01T00:00:00Z',
},
]);
githubClient.onRequest('GET /repos/{owner}/{repo}/languages', {}).resolves({ TypeScript: 1000 });
githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({
content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'),
encoding: 'base64',
});
githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]);
githubClient
.onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {})
.fails({ kind: 'not-found', message: 'no protection' });
githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]);
githubClient.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([]);
githubClient.onGraphql('DependabotPrs').resolves({
search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
});

const result = await main(ctx, ['acme']);
expect(result.kind).toBe('completed');

expect(analytics.capturedEvents('run_completed')[0]?.properties).toMatchObject({
repos_total: 2,
repos_included: 1,
});
});

test('captures run_failed when listOrgRepos fails', async () => {
const { ctx, githubClient, analytics } = createFakeContext();
githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' });
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ export function parseCli(argv: readonly string[]): ParseCliResult {
}

function filterRepos(repos: RepoMeta[], opts: ResolvedOptions): RepoMeta[] {
let out = repos.filter((r) => !r.archived);
let out = repos.filter((r) => !r.archived && !r.fork);
const includeSet = opts.include === null ? null : new Set(opts.include);
const excludeSet = new Set(opts.exclude);
if (includeSet !== null) out = out.filter((r) => includeSet.has(r.name));
Expand Down
43 changes: 40 additions & 3 deletions src/collectors/dependabotPrs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ test('maps a single page of search results to DependabotPr', async () => {
rawPullRequest.build({
state: 'MERGED',
mergedAt: '2026-04-05T00:00:00Z',
mergedBy: { login: 'alice' },
reviews: { nodes: [{ author: { login: 'bob' } }, { author: { login: 'alice' } }] },
comments: { nodes: [{ author: { login: 'alice' } }] },
mergedBy: { __typename: 'User', login: 'alice' },
reviews: {
nodes: [
{ author: { __typename: 'User', login: 'bob' } },
{ author: { __typename: 'User', login: 'alice' } },
],
},
comments: { nodes: [{ author: { __typename: 'User', login: 'alice' } }] },
}),
],
},
Expand All @@ -35,6 +40,38 @@ test('maps a single page of search results to DependabotPr', async () => {
});
});

test('drops bot actors from mergers, reviewers, and commenters', async () => {
const client = new FakeGithubClient();
client.onGraphql('DependabotPrs').resolves({
search: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [
rawPullRequest.build({
state: 'MERGED',
mergedAt: '2026-04-05T00:00:00Z',
// A GitHub App that merged the PR: Bot typename, no [bot] suffix.
mergedBy: { __typename: 'Bot', login: 'auto-merge-app' },
reviews: {
nodes: [
{ author: { __typename: 'Bot', login: 'greptile-apps' } },
{ author: { __typename: 'User', login: 'carol' } },
],
},
comments: { nodes: [{ author: { __typename: 'Bot', login: 'dependabot' } }] },
}),
],
},
});

const result = await listDependabotPrs(client, 'acme', '2026-01-01T00:00:00Z');
const prs = result.unwrapOr([]);
expect(prs[0]).toMatchObject({
mergedBy: null,
reviewers: ['carol'],
commenters: [],
});
});

test('pages through results when hasNextPage is true', async () => {
const client = new FakeGithubClient();
// First page returns cursor; second returns no more.
Expand Down
37 changes: 26 additions & 11 deletions src/collectors/dependabotPrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ interface GraphqlSearchResponse {
};
}

// GitHub's GraphQL Actor interface. `__typename` is the source of truth for
// whether an actor is a bot: GitHub App accounts (e.g. greptile-apps) surface
// here as `Bot` and — unlike the REST API — carry no `[bot]` login suffix.
export interface RawActor {
__typename: string;
login: string;
}

export interface RawPullRequest {
number: number;
title: string;
Expand All @@ -20,11 +28,11 @@ export interface RawPullRequest {
url: string;
baseRefName: string;
headRefName: string;
mergedBy: { login: string } | null;
mergedBy: RawActor | null;
autoMergeRequest: { enabledAt: string | null } | null;
repository: { owner: { login: string }; name: string };
reviews: { nodes: Array<{ author: { login: string } | null } | null> };
comments: { nodes: Array<{ author: { login: string } | null } | null> };
reviews: { nodes: Array<{ author: RawActor | null } | null> };
comments: { nodes: Array<{ author: RawActor | null } | null> };
commits: {
nodes: Array<{
commit: {
Expand Down Expand Up @@ -61,6 +69,7 @@ const SEARCH_QUERY = /* GraphQL */ `
baseRefName
headRefName
mergedBy {
__typename
login
}
autoMergeRequest {
Expand All @@ -75,13 +84,15 @@ const SEARCH_QUERY = /* GraphQL */ `
reviews(first: 50) {
nodes {
author {
__typename
login
}
}
}
comments(first: 50) {
nodes {
author {
__typename
login
}
}
Expand Down Expand Up @@ -145,8 +156,8 @@ function pageThrough(
function toDependabotPr(raw: RawPullRequest): DependabotPr {
const state: PrState = raw.state === 'OPEN' ? 'open' : 'closed';
const merged = raw.state === 'MERGED';
const reviewers = uniqueLogins(raw.reviews.nodes.map((n) => n?.author?.login));
const commenters = uniqueLogins(raw.comments.nodes.map((n) => n?.author?.login));
const reviewers = uniqueLogins(raw.reviews.nodes);
const commenters = uniqueLogins(raw.comments.nodes);
return {
owner: raw.repository.owner.login,
name: raw.repository.name,
Expand All @@ -157,7 +168,7 @@ function toDependabotPr(raw: RawPullRequest): DependabotPr {
createdAt: raw.createdAt,
closedAt: raw.closedAt,
mergedAt: raw.mergedAt,
mergedBy: raw.mergedBy?.login ?? null,
mergedBy: raw.mergedBy && !isBotActor(raw.mergedBy) ? raw.mergedBy.login : null,
headRef: raw.headRefName,
baseRef: raw.baseRefName,
htmlUrl: raw.url,
Expand Down Expand Up @@ -204,12 +215,16 @@ function summarizeChecks(raw: RawPullRequest): CheckSummary {
return summary;
}

function uniqueLogins(values: Array<string | null | undefined>): string[] {
function uniqueLogins(nodes: Array<{ author: RawActor | null } | null>): string[] {
const seen = new Set<string>();
for (const v of values) {
if (!v) continue;
if (v.endsWith('[bot]')) continue;
seen.add(v);
for (const node of nodes) {
const author = node?.author;
if (!author || isBotActor(author)) continue;
seen.add(author.login);
}
return [...seen].sort();
}

function isBotActor(actor: RawActor): boolean {
return actor.__typename === 'Bot' || actor.login.endsWith('[bot]');
}
5 changes: 5 additions & 0 deletions src/collectors/repos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ test('maps raw repo payloads into RepoMeta and infers visibility', async () => {
private: false,
visibility: 'public',
archived: false,
fork: false,
default_branch: 'main',
language: 'TypeScript',
pushed_at: '2026-04-01T00:00:00Z',
Expand All @@ -22,6 +23,7 @@ test('maps raw repo payloads into RepoMeta and infers visibility', async () => {
private: true,
visibility: 'internal',
archived: true,
fork: true,
default_branch: 'main',
language: null,
pushed_at: null,
Expand All @@ -38,11 +40,13 @@ test('maps raw repo payloads into RepoMeta and infers visibility', async () => {
name: 'widgets',
visibility: 'public',
archived: false,
fork: false,
dependabotSecurityUpdates: true,
});
expect(repos[1]).toMatchObject({
visibility: 'internal',
archived: true,
fork: true,
dependabotSecurityUpdates: null,
});
}
Expand All @@ -58,6 +62,7 @@ test('falls back to the user endpoint when the org endpoint 404s', async () => {
private: false,
visibility: 'public',
archived: false,
fork: false,
default_branch: 'main',
language: 'TypeScript',
pushed_at: '2026-04-01T00:00:00Z',
Expand Down
2 changes: 2 additions & 0 deletions src/collectors/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface RawRepo {
private: boolean;
visibility?: string;
archived: boolean;
fork: boolean;
default_branch: string;
language: string | null;
pushed_at: string | null;
Expand Down Expand Up @@ -53,6 +54,7 @@ function toRepoMeta(raw: RawRepo): RepoMeta {
name: raw.name,
visibility,
archived: raw.archived,
fork: raw.fork,
defaultBranch: raw.default_branch,
primaryLanguage: raw.language,
pushedAt: raw.pushed_at,
Expand Down
1 change: 1 addition & 0 deletions src/testFactories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const repoMeta = Factory.define<RepoMeta>(() => ({
name: 'widgets',
visibility: 'private',
archived: false,
fork: false,
defaultBranch: 'main',
primaryLanguage: 'TypeScript',
pushedAt: '2026-04-01T00:00:00Z',
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface RepoRef {
export interface RepoMeta extends RepoRef {
visibility: Visibility;
archived: boolean;
fork: boolean;
defaultBranch: string;
primaryLanguage: string | null;
pushedAt: string | null;
Expand Down