Skip to content

Commit 82730f1

Browse files
Gitea support (#45)
1 parent b1e0ab0 commit 82730f1

File tree

14 files changed

+414
-9
lines changed

14 files changed

+414
-9
lines changed

.github/images/gitea-pat-creation.png

188 KB
Loading

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+
- Gitea support ([#45](https://github.com/sourcebot-dev/sourcebot/pull/45))
13+
1014
## [2.0.2] - 2024-10-18
1115

1216
### Added

README.md

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ https://github.com/user-attachments/assets/98d46192-5469-430f-ad9e-5c042adbb10d
3030

3131
## Features
3232
- 💻 **One-command deployment**: Get started instantly using Docker on your own machine.
33-
- 🔍 **Multi-repo search**: Effortlessly index and search through multiple public and private repositories in GitHub or GitLab.
33+
- 🔍 **Multi-repo search**: Effortlessly index and search through multiple public and private repositories in GitHub, GitLab, or Gitea.
3434
-**Lightning fast performance**: Built on top of the powerful [Zoekt](https://github.com/sourcegraph/zoekt) search engine.
3535
- 📂 **Full file visualization**: Instantly view the entire file when selecting any search result.
3636
- 🎨 **Modern web app**: Enjoy a sleek interface with features like syntax highlighting, light/dark mode, and vim-style navigation
@@ -62,7 +62,7 @@ Sourcebot supports indexing and searching through public and private repositorie
6262
<picture>
6363
<source media="(prefers-color-scheme: dark)" srcset=".github/images/github-favicon-inverted.png">
6464
<img src="https://github.com/favicon.ico" width="16" height="16" alt="GitHub icon">
65-
</picture> GitHub and <img src="https://gitlab.com/favicon.ico" width="16" height="16" /> GitLab. This section will guide you through configuring the repositories that Sourcebot indexes.
65+
</picture> GitHub, <img src="https://gitlab.com/favicon.ico" width="16" height="16" /> GitLab and <img src="https://gitea.com/favicon.ico" width="16" height="16"> Gitea. This section will guide you through configuring the repositories that Sourcebot indexes.
6666

6767
1. Create a new folder on your machine that stores your configs and `.sourcebot` cache, and navigate into it:
6868
```sh
@@ -214,6 +214,53 @@ docker run -e <b>GITLAB_TOKEN=glpat-mytoken</b> /* additional args */ ghcr.io/so
214214

215215
</details>
216216

217+
<details>
218+
<summary><img src="https://gitea.com/favicon.ico" width="16" height="16"> Gitea</summary>
219+
220+
Generate a Gitea access token [here](http://gitea.com/user/settings/applications). At minimum, you'll need to select the `read:repository` scope, but `read:user` and `read:organization` are required for the `user` and `org` fields of your config file:
221+
222+
![Gitea Access token creation](.github/images/gitea-pat-creation.png)
223+
224+
Next, update your configuration with the `token` field:
225+
```json
226+
{
227+
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v2/index.json",
228+
"repos": [
229+
{
230+
"type": "gitea",
231+
"token": "my-secret-token",
232+
...
233+
}
234+
]
235+
}
236+
```
237+
238+
You can also pass tokens as environment variables:
239+
```json
240+
{
241+
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v2/index.json",
242+
"repos": [
243+
{
244+
"type": "gitea",
245+
"token": {
246+
// note: this env var can be named anything. It
247+
// doesn't need to be `GITEA_TOKEN`.
248+
"env": "GITEA_TOKEN"
249+
},
250+
...
251+
}
252+
]
253+
}
254+
```
255+
256+
You'll need to pass this environment variable each time you run Sourcebot:
257+
258+
<pre>
259+
docker run -e <b>GITEA_TOKEN=my-secret-token</b> /* additional args */ ghcr.io/sourcebot-dev/sourcebot:latest
260+
</pre>
261+
262+
</details>
263+
217264
</div>
218265

219266
## Using a self-hosted GitLab / GitHub instance
@@ -226,7 +273,7 @@ If you're using a self-hosted GitLab or GitHub instance with a custom domain, yo
226273
227274
1. Install <a href="https://go.dev/doc/install"><img src="https://go.dev/favicon.ico" width="16" height="16"> go</a> and <a href="https://nodejs.org/"><img src="https://nodejs.org/favicon.ico" width="16" height="16"> NodeJS</a>. Note that a NodeJS version of at least `21.1.0` is required.
228275

229-
2. Install [ctags](https://github.com/universal-ctags/ctags) (required by zoekt-indexserver)
276+
2. Install [ctags](https://github.com/universal-ctags/ctags) (required by zoekt)
230277
```sh
231278
// macOS:
232279
brew install universal-ctags

packages/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"@gitbeaker/rest": "^40.5.1",
2323
"@octokit/rest": "^21.0.2",
2424
"argparse": "^2.0.1",
25+
"cross-fetch": "^4.0.0",
26+
"gitea-js": "^1.22.0",
2527
"lowdb": "^7.0.1",
2628
"simple-git": "^3.27.0",
2729
"strip-json-comments": "^5.0.1",

packages/backend/src/gitea.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Api, giteaApi, HttpResponse, Repository as GiteaRepository } from 'gitea-js';
2+
import { GiteaConfig } from './schemas/v2.js';
3+
import { excludeArchivedRepos, excludeForkedRepos, excludeReposByName, getTokenFromConfig, marshalBool, measure } from './utils.js';
4+
import { AppContext, Repository } from './types.js';
5+
import fetch from 'cross-fetch';
6+
import { createLogger } from './logger.js';
7+
import path from 'path';
8+
9+
const logger = createLogger('Gitea');
10+
11+
export const getGiteaReposFromConfig = async (config: GiteaConfig, ctx: AppContext) => {
12+
const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined;
13+
14+
const api = giteaApi(config.url ?? 'https://gitea.com', {
15+
token,
16+
customFetch: fetch,
17+
});
18+
19+
let allRepos: GiteaRepository[] = [];
20+
21+
if (config.orgs) {
22+
const _repos = await getReposForOrgs(config.orgs, api);
23+
allRepos = allRepos.concat(_repos);
24+
}
25+
26+
if (config.repos) {
27+
const _repos = await getRepos(config.repos, api);
28+
allRepos = allRepos.concat(_repos);
29+
}
30+
31+
if (config.users) {
32+
const _repos = await getReposOwnedByUsers(config.users, api);
33+
allRepos = allRepos.concat(_repos);
34+
}
35+
36+
let repos: Repository[] = allRepos
37+
.map((repo) => {
38+
const hostname = config.url ? new URL(config.url).hostname : 'gitea.com';
39+
const repoId = `${hostname}/${repo.full_name!}`;
40+
const repoPath = path.resolve(path.join(ctx.reposPath, `${repoId}.git`));
41+
42+
const cloneUrl = new URL(repo.clone_url!);
43+
if (token) {
44+
cloneUrl.username = token;
45+
}
46+
47+
return {
48+
name: repo.full_name!,
49+
id: repoId,
50+
cloneUrl: cloneUrl.toString(),
51+
path: repoPath,
52+
isStale: false,
53+
isFork: repo.fork!,
54+
isArchived: !!repo.archived,
55+
gitConfigMetadata: {
56+
'zoekt.web-url-type': 'gitea',
57+
'zoekt.web-url': repo.html_url!,
58+
'zoekt.name': repoId,
59+
'zoekt.archived': marshalBool(repo.archived),
60+
'zoekt.fork': marshalBool(repo.fork!),
61+
'zoekt.public': marshalBool(repo.internal === false && repo.private === false),
62+
}
63+
} satisfies Repository;
64+
});
65+
66+
if (config.exclude) {
67+
if (!!config.exclude.forks) {
68+
repos = excludeForkedRepos(repos, logger);
69+
}
70+
71+
if (!!config.exclude.archived) {
72+
repos = excludeArchivedRepos(repos, logger);
73+
}
74+
75+
if (config.exclude.repos) {
76+
repos = excludeReposByName(repos, config.exclude.repos, logger);
77+
}
78+
}
79+
80+
return repos;
81+
}
82+
83+
const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
84+
const repos = (await Promise.all(users.map(async (user) => {
85+
logger.debug(`Fetching repos for user ${user}...`);
86+
87+
const { durationMs, data } = await measure(() =>
88+
paginate((page) => api.users.userListRepos(user, {
89+
page,
90+
}))
91+
);
92+
93+
logger.debug(`Found ${data.length} repos owned by user ${user} in ${durationMs}ms.`);
94+
return data;
95+
}))).flat();
96+
97+
return repos;
98+
}
99+
100+
const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
101+
return (await Promise.all(orgs.map(async (org) => {
102+
logger.debug(`Fetching repos for org ${org}...`);
103+
104+
const { durationMs, data } = await measure(() =>
105+
paginate((page) => api.orgs.orgListRepos(org, {
106+
limit: 100,
107+
page,
108+
}))
109+
);
110+
111+
logger.debug(`Found ${data.length} repos for org ${org} in ${durationMs}ms.`);
112+
return data;
113+
}))).flat();
114+
}
115+
116+
const getRepos = async <T>(repos: string[], api: Api<T>) => {
117+
return Promise.all(repos.map(async (repo) => {
118+
logger.debug(`Fetching repository info for ${repo}...`);
119+
120+
const [owner, repoName] = repo.split('/');
121+
const { durationMs, data: response } = await measure(() =>
122+
api.repos.repoGet(owner, repoName),
123+
);
124+
125+
logger.debug(`Found repo ${repo} in ${durationMs}ms.`);
126+
127+
return response.data;
128+
}));
129+
}
130+
131+
// @see : https://docs.gitea.com/development/api-usage#pagination
132+
const paginate = async <T>(request: (page: number) => Promise<HttpResponse<T[], any>>) => {
133+
let page = 1;
134+
const result = await request(page);
135+
const output: T[] = result.data;
136+
137+
const totalCountString = result.headers.get('x-total-count');
138+
if (!totalCountString) {
139+
throw new Error("Header 'x-total-count' not found");
140+
}
141+
const totalCount = parseInt(totalCountString);
142+
143+
while (output.length < totalCount) {
144+
page++;
145+
const result = await request(page);
146+
output.push(...result.data);
147+
}
148+
149+
return output;
150+
}

packages/backend/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from 'path';
66
import { SourcebotConfigurationSchema } from "./schemas/v2.js";
77
import { getGitHubReposFromConfig } from "./github.js";
88
import { getGitLabReposFromConfig } from "./gitlab.js";
9+
import { getGiteaReposFromConfig } from "./gitea.js";
910
import { AppContext, Repository } from "./types.js";
1011
import { cloneRepository, fetchRepository } from "./git.js";
1112
import { createLogger } from "./logger.js";
@@ -75,6 +76,11 @@ const syncConfig = async (configPath: string, db: Database, signal: AbortSignal,
7576
configRepos.push(...gitLabRepos);
7677
break;
7778
}
79+
case 'gitea': {
80+
const giteaRepos = await getGiteaReposFromConfig(repoConfig, ctx);
81+
configRepos.push(...giteaRepos);
82+
break;
83+
}
7884
}
7985
}
8086

@@ -180,7 +186,8 @@ const syncConfig = async (configPath: string, db: Database, signal: AbortSignal,
180186
// since it implies another sync is in progress.
181187
} else {
182188
isSyncing = false;
183-
logger.error(`Failed to sync configuration file ${args.configPath} with error:\n`, err);
189+
logger.error(`Failed to sync configuration file ${args.configPath} with error:`);
190+
console.log(err);
184191
}
185192
});
186193
}

packages/backend/src/schemas/v2.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
22

3-
export type Repos = GitHubConfig | GitLabConfig;
3+
export type Repos = GitHubConfig | GitLabConfig | GiteaConfig;
44

55
/**
66
* A Sourcebot configuration file outlines which repositories Sourcebot should sync and index.
@@ -106,3 +106,50 @@ export interface GitLabConfig {
106106
projects?: string[];
107107
};
108108
}
109+
export interface GiteaConfig {
110+
/**
111+
* Gitea Configuration
112+
*/
113+
type: "gitea";
114+
/**
115+
* An access token.
116+
*/
117+
token?:
118+
| string
119+
| {
120+
/**
121+
* The name of the environment variable that contains the token.
122+
*/
123+
env: string;
124+
};
125+
/**
126+
* The URL of the Gitea host. Defaults to https://gitea.com
127+
*/
128+
url?: string;
129+
/**
130+
* List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope.
131+
*/
132+
orgs?: string[];
133+
/**
134+
* List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'.
135+
*/
136+
repos?: string[];
137+
/**
138+
* List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope.
139+
*/
140+
users?: string[];
141+
exclude?: {
142+
/**
143+
* Exlcude forked repositories from syncing.
144+
*/
145+
forks?: boolean;
146+
/**
147+
* Exlcude archived repositories from syncing.
148+
*/
149+
archived?: boolean;
150+
/**
151+
* List of individual repositories to exclude from syncing. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'.
152+
*/
153+
repos?: string[];
154+
};
155+
}

packages/web/public/gitea.svg

Lines changed: 1 addition & 0 deletions
Loading

packages/web/public/gitlab.svg

Lines changed: 1 addition & 1 deletion
Loading

packages/web/src/app/repositoryCarousel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const RepositoryBadge = ({
6464
repoIcon: <Image
6565
src={info.icon}
6666
alt={info.costHostName}
67-
className="w-4 h-4 dark:invert"
67+
className={`w-4 h-4 ${info.iconClassname}`}
6868
/>,
6969
repoName: info.repoName,
7070
repoLink: info.repoLink,

packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const FileMatchContainer = ({
6363
repoIcon: <Image
6464
src={info.icon}
6565
alt={info.costHostName}
66-
className="w-4 h-4 dark:invert"
66+
className={`w-4 h-4 ${info.iconClassname}`}
6767
/>
6868
}
6969
}

0 commit comments

Comments
 (0)