Skip to content

Commit 4d358f9

Browse files
Add topics and exclude.topics to GitHub & GitLab config (#121)
1 parent 3dd4a16 commit 4d358f9

File tree

9 files changed

+286
-7
lines changed

9 files changed

+286
-7
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added config support for filtering GitLab & GitHub repositories by topic. ([#121](https://github.com/sourcebot-dev/sourcebot/pull/121))
13+
1014
### Changed
1115

1216
- Made language suggestions case insensitive. ([#124](https://github.com/sourcebot-dev/sourcebot/pull/124))

configs/filter.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,39 @@
6363
]
6464
}
6565
},
66+
// Include all repos in my-org that have the topic
67+
// "TypeScript" and do not have a topic that starts
68+
// with "test-"
69+
{
70+
"type": "github",
71+
"orgs": [
72+
"my-org"
73+
],
74+
"topics": [
75+
"TypeScript"
76+
],
77+
"exclude": {
78+
"topics": [
79+
"test-**"
80+
]
81+
}
82+
},
83+
// Include all repos in my-group that have the topic
84+
// "TypeScript" and do not have a topic that starts
85+
// with "test-"
86+
{
87+
"type": "gitlab",
88+
"groups": [
89+
"my-group"
90+
],
91+
"topics": [
92+
"TypeScript"
93+
],
94+
"exclude": {
95+
"topics": [
96+
"test-**"
97+
]
98+
}
99+
}
66100
]
67101
}

packages/backend/src/github.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { GitHubConfig } from "./schemas/v2.js";
33
import { createLogger } from "./logger.js";
44
import { AppContext, GitRepository } from "./types.js";
55
import path from 'path';
6-
import { excludeArchivedRepos, excludeForkedRepos, excludeReposByName, getTokenFromConfig, marshalBool, measure } from "./utils.js";
6+
import { excludeArchivedRepos, excludeForkedRepos, excludeReposByName, excludeReposByTopic, getTokenFromConfig, includeReposByTopic, marshalBool, measure } from "./utils.js";
77
import micromatch from "micromatch";
88

99
const logger = createLogger("GitHub");
@@ -21,6 +21,7 @@ type OctokitRepository = {
2121
subscribers_count?: number,
2222
forks_count?: number,
2323
archived?: boolean,
24+
topics?: string[],
2425
}
2526

2627
export const getGitHubReposFromConfig = async (config: GitHubConfig, signal: AbortSignal, ctx: AppContext) => {
@@ -80,6 +81,7 @@ export const getGitHubReposFromConfig = async (config: GitHubConfig, signal: Abo
8081
isStale: false,
8182
isFork: repo.fork,
8283
isArchived: !!repo.archived,
84+
topics: repo.topics ?? [],
8385
gitConfigMetadata: {
8486
'zoekt.web-url-type': 'github',
8587
'zoekt.web-url': repo.html_url,
@@ -97,6 +99,10 @@ export const getGitHubReposFromConfig = async (config: GitHubConfig, signal: Abo
9799
} satisfies GitRepository;
98100
});
99101

102+
if (config.topics) {
103+
repos = includeReposByTopic(repos, config.topics, logger);
104+
}
105+
100106
if (config.exclude) {
101107
if (!!config.exclude.forks) {
102108
repos = excludeForkedRepos(repos, logger);
@@ -109,6 +115,10 @@ export const getGitHubReposFromConfig = async (config: GitHubConfig, signal: Abo
109115
if (config.exclude.repos) {
110116
repos = excludeReposByName(repos, config.exclude.repos, logger);
111117
}
118+
119+
if (config.exclude.topics) {
120+
repos = excludeReposByTopic(repos, config.exclude.topics, logger);
121+
}
112122
}
113123

114124
logger.debug(`Found ${repos.length} total repositories.`);

packages/backend/src/gitlab.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Gitlab, ProjectSchema } from "@gitbeaker/rest";
22
import { GitLabConfig } from "./schemas/v2.js";
3-
import { excludeArchivedRepos, excludeForkedRepos, excludeReposByName, getTokenFromConfig, marshalBool, measure } from "./utils.js";
3+
import { excludeArchivedRepos, excludeForkedRepos, excludeReposByName, excludeReposByTopic, getTokenFromConfig, includeReposByTopic, marshalBool, measure } from "./utils.js";
44
import { createLogger } from "./logger.js";
55
import { AppContext, GitRepository } from "./types.js";
66
import path from 'path';
@@ -98,6 +98,7 @@ export const getGitLabReposFromConfig = async (config: GitLabConfig, ctx: AppCon
9898
isStale: false,
9999
isFork,
100100
isArchived: project.archived,
101+
topics: project.topics ?? [],
101102
gitConfigMetadata: {
102103
'zoekt.web-url-type': 'gitlab',
103104
'zoekt.web-url': project.web_url,
@@ -113,6 +114,10 @@ export const getGitLabReposFromConfig = async (config: GitLabConfig, ctx: AppCon
113114
} satisfies GitRepository;
114115
});
115116

117+
if (config.topics) {
118+
repos = includeReposByTopic(repos, config.topics, logger);
119+
}
120+
116121
if (config.exclude) {
117122
if (!!config.exclude.forks) {
118123
repos = excludeForkedRepos(repos, logger);
@@ -125,6 +130,10 @@ export const getGitLabReposFromConfig = async (config: GitLabConfig, ctx: AppCon
125130
if (config.exclude.projects) {
126131
repos = excludeReposByName(repos, config.exclude.projects, logger);
127132
}
133+
134+
if (config.exclude.topics) {
135+
repos = excludeReposByTopic(repos, config.exclude.topics, logger);
136+
}
128137
}
129138

130139
logger.debug(`Found ${repos.length} total repositories.`);

packages/backend/src/schemas/v2.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export interface GitHubConfig {
5454
* List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'.
5555
*/
5656
repos?: string[];
57+
/**
58+
* List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.
59+
*
60+
* @minItems 1
61+
*/
62+
topics?: [string, ...string[]];
5763
exclude?: {
5864
/**
5965
* Exclude forked repositories from syncing.
@@ -67,6 +73,10 @@ export interface GitHubConfig {
6773
* List of individual repositories to exclude from syncing. Glob patterns are supported.
6874
*/
6975
repos?: string[];
76+
/**
77+
* List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.
78+
*/
79+
topics?: string[];
7080
};
7181
revisions?: GitRevisions;
7282
}
@@ -119,6 +129,12 @@ export interface GitLabConfig {
119129
* List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/
120130
*/
121131
projects?: string[];
132+
/**
133+
* List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.
134+
*
135+
* @minItems 1
136+
*/
137+
topics?: [string, ...string[]];
122138
exclude?: {
123139
/**
124140
* Exclude forked projects from syncing.
@@ -132,6 +148,10 @@ export interface GitLabConfig {
132148
* List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/
133149
*/
134150
projects?: string[];
151+
/**
152+
* List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.
153+
*/
154+
topics?: string[];
135155
};
136156
revisions?: GitRevisions;
137157
}

packages/backend/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface BaseRepository {
88
isFork?: boolean;
99
isArchived?: boolean;
1010
codeHost?: string;
11+
topics?: string[];
1112
}
1213

1314
export interface GitRepository extends BaseRepository {

packages/backend/src/utils.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { arraysEqualShallow, isRemotePath, excludeReposByName } from './utils';
2+
import { arraysEqualShallow, isRemotePath, excludeReposByName, includeReposByTopic, excludeReposByTopic } from './utils';
33
import { Repository } from './types';
44

55
const testNames: string[] = [
@@ -125,3 +125,135 @@ test('isRemotePath should return false for non HTTP paths', () => {
125125
expect(isRemotePath('')).toBe(false);
126126
expect(isRemotePath(' ')).toBe(false);
127127
});
128+
129+
130+
test('includeReposByTopic should return repos with matching topics', () => {
131+
const repos = [
132+
{ id: '1', topics: ['javascript', 'typescript'] },
133+
{ id: '2', topics: ['python', 'django'] },
134+
{ id: '3', topics: ['typescript', 'react'] }
135+
].map(r => ({
136+
...createRepository(r.id),
137+
...r,
138+
} satisfies Repository));
139+
140+
const result = includeReposByTopic(repos, ['typescript']);
141+
expect(result.length).toBe(2);
142+
expect(result.map(r => r.id)).toEqual(['1', '3']);
143+
});
144+
145+
test('includeReposByTopic should handle glob patterns in topic matching', () => {
146+
const repos = [
147+
{ id: '1', topics: ['frontend-app', 'backend-app'] },
148+
{ id: '2', topics: ['mobile-app', 'web-app'] },
149+
{ id: '3', topics: ['desktop-app', 'cli-app'] }
150+
].map(r => ({
151+
...createRepository(r.id),
152+
...r,
153+
} satisfies Repository));
154+
155+
const result = includeReposByTopic(repos, ['*-app']);
156+
expect(result.length).toBe(3);
157+
});
158+
159+
test('includeReposByTopic should handle repos with no topics', () => {
160+
const repos = [
161+
{ id: '1', topics: ['javascript'] },
162+
{ id: '2', topics: undefined },
163+
{ id: '3', topics: [] }
164+
].map(r => ({
165+
...createRepository(r.id),
166+
...r,
167+
} satisfies Repository));
168+
169+
const result = includeReposByTopic(repos, ['javascript']);
170+
expect(result.length).toBe(1);
171+
expect(result[0].id).toBe('1');
172+
});
173+
174+
test('includeReposByTopic should return empty array when no repos match topics', () => {
175+
const repos = [
176+
{ id: '1', topics: ['frontend'] },
177+
{ id: '2', topics: ['backend'] }
178+
].map(r => ({
179+
...createRepository(r.id),
180+
...r,
181+
} satisfies Repository));
182+
183+
const result = includeReposByTopic(repos, ['mobile']);
184+
expect(result).toEqual([]);
185+
});
186+
187+
188+
test('excludeReposByTopic should exclude repos with matching topics', () => {
189+
const repos = [
190+
{ id: '1', topics: ['javascript', 'typescript'] },
191+
{ id: '2', topics: ['python', 'django'] },
192+
{ id: '3', topics: ['typescript', 'react'] }
193+
].map(r => ({
194+
...createRepository(r.id),
195+
...r,
196+
} satisfies Repository));
197+
198+
const result = excludeReposByTopic(repos, ['typescript']);
199+
expect(result.length).toBe(1);
200+
expect(result[0].id).toBe('2');
201+
});
202+
203+
test('excludeReposByTopic should handle glob patterns', () => {
204+
const repos = [
205+
{ id: '1', topics: ['test-lib', 'test-app'] },
206+
{ id: '2', topics: ['prod-lib', 'prod-app'] },
207+
{ id: '3', topics: ['dev-tool'] }
208+
].map(r => ({
209+
...createRepository(r.id),
210+
...r,
211+
} satisfies Repository));
212+
213+
const result = excludeReposByTopic(repos, ['test-*']);
214+
expect(result.length).toBe(2);
215+
expect(result.map(r => r.id)).toEqual(['2', '3']);
216+
});
217+
218+
test('excludeReposByTopic should handle multiple exclude patterns', () => {
219+
const repos = [
220+
{ id: '1', topics: ['frontend', 'react'] },
221+
{ id: '2', topics: ['backend', 'node'] },
222+
{ id: '3', topics: ['mobile', 'react-native'] }
223+
].map(r => ({
224+
...createRepository(r.id),
225+
...r,
226+
} satisfies Repository));
227+
228+
const result = excludeReposByTopic(repos, ['*end', '*native']);
229+
expect(result.length).toBe(0);
230+
});
231+
232+
test('excludeReposByTopic should not exclude repos when no topics match', () => {
233+
const repos = [
234+
{ id: '1', topics: ['frontend'] },
235+
{ id: '2', topics: ['backend'] },
236+
{ id: '3', topics: undefined }
237+
].map(r => ({
238+
...createRepository(r.id),
239+
...r,
240+
} satisfies Repository));
241+
242+
const result = excludeReposByTopic(repos, ['mobile']);
243+
expect(result.length).toBe(3);
244+
expect(result.map(r => r.id)).toEqual(['1', '2', '3']);
245+
});
246+
247+
test('excludeReposByTopic should handle empty exclude patterns array', () => {
248+
const repos = [
249+
{ id: '1', topics: ['frontend'] },
250+
{ id: '2', topics: ['backend'] }
251+
].map(r => ({
252+
...createRepository(r.id),
253+
...r,
254+
} satisfies Repository));
255+
256+
const result = excludeReposByTopic(repos, []);
257+
expect(result.length).toBe(2);
258+
expect(result).toEqual(repos);
259+
});

0 commit comments

Comments
 (0)