GerritUrlReader: Implemented "readTree"

"readTree" has been implemented for the "GerritUrlReader". Gerrit have
a REST API's to download repo contents but there are a number of
limitations that makes it unusable.

This implementation works as follows:
* The project and branch is parsed from the url.
* The current revision is fetched from the Gerrit REST API.
* The revision string is used as "etag".
* If the etag has changed a temporary directory is created.
* The project is cloned to the temporary directory.
* The cloned content is read into a "Readable Stream".
* The temporary directory is removed.
* "readTree" returns a response using "fromTarArchive" as read from the
  temporary directory.

Also added an option to specify the base "cloneUrl" has been added to the
gerrit integration config.

Signed-off-by: Niklas Aronsson <niklasar@axis.com>
This commit is contained in:
Niklas Aronsson
2022-04-12 10:50:58 +02:00
parent 74bf95277a
commit 6673babab9
14 changed files with 414 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Gerrit integration: Added optional `cloneUrl` string to config.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': minor
---
Gerrit UrlReader: Implemented `readTree`
+6 -3
View File
@@ -19,7 +19,8 @@ To use this integration, add at least one Gerrit configuration to your root `app
integrations:
gerrit:
- host: gerrit.company.com
apiBaseUrl: https://gerrit.company.com/gerrit
baseUrl: https://gerrit.company.com/gerrit
cloneUrl: https://gerrit.company.com/clone
gitilesBaseUrl: https://gerrit.company.com/gitiles
username: ${GERRIT_USERNAME}
password: ${GERRIT_PASSWORD}
@@ -27,12 +28,14 @@ integrations:
Directly under the `gerrit` key is a list of provider configurations, where
you can list the Gerrit instances you want to fetch data from. Each entry is
a structure with up to four elements:
a structure with up to six elements:
- `host`: The host of the Gerrit instance, e.g. `gerrit.company.com`.
- `apiBaseUrl` (optional): Needed if the Gerrit instance is not reachable at
- `baseUrl` (optional): Needed if the Gerrit instance is not reachable at
the base of the `host` option (e.g. `https://gerrit.company.com`) set the
address here. This is the address that you would open in a browser.
- `cloneUrl` (optional): The base url for HTTP clones. Will default to `baseUrl` if
not set. The address used to clone a repo is the `cloneUrl` plus the repo name.
- `gitilesBaseUrl` (optional): This is needed for creating a valid user-friendly url
that can be used for browsing the content of the provider. If not set a default
value will be created in the same way as the "baseUrl" option. There is no
+8 -2
View File
@@ -302,13 +302,19 @@ export type FromReadableArrayOptions = Array<{
// @public
export class GerritUrlReader implements UrlReader {
constructor(integration: GerritIntegration);
constructor(
integration: GerritIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
workDir: string,
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(): Promise<ReadTreeResponse>;
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
@@ -14,24 +14,36 @@
* limitations under the License.
*/
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { JsonObject } from '@backstage/types';
import { getVoidLogger } from '../logging';
import { DefaultReadTreeResponseFactory } from './tree';
import { UrlReaderPredicateTuple } from './types';
import { NotModifiedError, NotFoundError } from '@backstage/errors';
import {
GerritIntegration,
readGerritIntegrationConfig,
} from '@backstage/integration';
import { JsonObject } from '@backstage/types';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import mockFs from 'mock-fs';
import fs from 'fs-extra';
import path from 'path';
import { getVoidLogger } from '../logging';
import { UrlReaderPredicateTuple } from './types';
import { DefaultReadTreeResponseFactory } from './tree';
import { GerritUrlReader } from './GerritUrlReader';
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
jest.mock('../scm', () => ({
Git: {
fromAuth: () => ({
clone: jest.fn(() => Promise.resolve({})),
}),
},
}));
const gerritProcessor = new GerritUrlReader(
new GerritIntegration(
readGerritIntegrationConfig(
@@ -40,6 +52,8 @@ const gerritProcessor = new GerritUrlReader(
}),
),
),
{ treeResponseFactory },
'/tmp',
);
const createReader = (config: JsonObject): UrlReaderPredicateTuple[] => {
@@ -54,6 +68,10 @@ describe('GerritUrlReader', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
afterAll(() => {
jest.clearAllMocks();
});
describe('reader factory', () => {
it('creates a reader.', () => {
const readers = createReader({
@@ -103,7 +121,7 @@ describe('GerritUrlReader', () => {
expect(predicate(new URL('https://gerrit-review.com/path'))).toBe(false);
});
it('returns false for host.', () => {
it('returns false for host.', () => {
expect(predicate(new URL('https://gerrit.com/path'))).toBe(true);
});
});
@@ -170,4 +188,104 @@ describe('GerritUrlReader', () => {
);
});
});
describe('readTree', () => {
const branchAPIUrl =
'https://gerrit.com/projects/app%2Fweb/branches/master';
const branchAPIresponse = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/gerrit/branch-info-response.txt'),
);
const treeUrl = 'https://gerrit.com/app/web/+/refs/heads/master/';
const etag = '52432507a70b677b5674b019c9a46b2e9f29d0a1';
const mkdocsContent = 'great content';
const mdContent = 'doc';
beforeEach(() => {
mockFs({
'/tmp/': mockFs.directory(),
'/tmp/gerrit-clone-123abc/repo/mkdocs.yml': mkdocsContent,
'/tmp/gerrit-clone-123abc/repo/docs/first.md': mdContent,
});
const spy = jest.spyOn(fs, 'mkdtemp');
spy.mockImplementation(() => '/tmp/gerrit-clone-123abc');
});
afterEach(() => {
mockFs.restore();
jest.clearAllMocks();
});
it('reads the wanted files correctly.', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(200), ctx.body(branchAPIresponse));
}),
);
const response = await gerritProcessor.readTree(treeUrl);
expect(response.etag).toBe(etag);
const files = await response.files();
expect(files.length).toBe(2);
const docsYaml = await files[0].content();
expect(docsYaml.toString()).toBe(mkdocsContent);
const mdFile = await files[1].content();
expect(mdFile.toString()).toBe(mdContent);
});
it('throws NotModifiedError for matching etags.', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(200), ctx.body(branchAPIresponse));
}),
);
await expect(gerritProcessor.readTree(treeUrl, { etag })).rejects.toThrow(
NotModifiedError,
);
});
it('throws NotFoundError if branch info not found.', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(404, 'Not found.'));
}),
);
await expect(gerritProcessor.readTree(treeUrl)).rejects.toThrow(
NotFoundError,
);
});
it('should throw on failures while getting branch info.', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(500, 'Error'));
}),
);
await expect(gerritProcessor.readTree(treeUrl)).rejects.toThrow(Error);
});
it('should returns wanted files with a subpath', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(200), ctx.body(branchAPIresponse));
}),
);
const response = await gerritProcessor.readTree(`${treeUrl}/docs`);
expect(response.etag).toBe(etag);
const files = await response.files();
expect(files.length).toBe(1);
const mdFile = await files[0].content();
expect(mdFile.toString()).toBe(mdContent);
});
});
});
@@ -14,16 +14,30 @@
* limitations under the License.
*/
import { NotFoundError } from '@backstage/errors';
import { Git } from '../scm';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import {
GerritIntegration,
getGerritCloneRepoUrl,
getGerritBranchApiUrl,
getGerritFileContentsApiUrl,
getGerritRequestOptions,
parseGerritJsonResponse,
parseGerritGitilesUrl,
} from '@backstage/integration';
import concatStream from 'concat-stream';
import fs from 'fs-extra';
import fetch, { Response } from 'node-fetch';
import os from 'os';
import { join as joinPath } from 'path';
import tar from 'tar';
import { pipeline as pipelineCb, Readable } from 'stream';
import { promisify } from 'util';
import {
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
ReadTreeResponseFactory,
ReadUrlOptions,
ReadUrlResponse,
SearchResponse,
@@ -31,6 +45,11 @@ import {
} from './types';
import { ScmIntegrations } from '@backstage/integration';
const pipeline = promisify(pipelineCb);
const createTemporaryDirectory = async (workDir: string): Promise<string> =>
await fs.mkdtemp(joinPath(workDir, '/gerrit-clone-'));
/**
* Implements a {@link UrlReader} for files in Gerrit.
*
@@ -50,16 +69,22 @@ import { ScmIntegrations } from '@backstage/integration';
* @public
*/
export class GerritUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config }) => {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
if (!integrations.gerrit) {
return [];
}
const workDir =
config.getOptionalString('backend.workingDirectory') ?? os.tmpdir();
return integrations.gerrit.list().map(integration => {
const reader = new GerritUrlReader(integration);
const reader = new GerritUrlReader(
integration,
{ treeResponseFactory },
workDir,
);
const predicate = (url: URL) => {
const gitilesUrl = new URL(integration.config.gitilesBaseUrl!);
// If gitilesUrl is not specfified it will default to
// If gitilesUrl is not specified it will default to
// "integration.config.host".
return url.host === gitilesUrl.host;
};
@@ -67,7 +92,11 @@ export class GerritUrlReader implements UrlReader {
});
};
constructor(private readonly integration: GerritIntegration) {}
constructor(
private readonly integration: GerritIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
private readonly workDir: string,
) {}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
@@ -109,8 +138,72 @@ export class GerritUrlReader implements UrlReader {
);
}
async readTree(): Promise<ReadTreeResponse> {
throw new Error('GerritReader does not implement readTree');
async readTree(
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const { filePath } = parseGerritGitilesUrl(this.integration.config, url);
const apiUrl = getGerritBranchApiUrl(this.integration.config, url);
let response: Response;
try {
response = await fetch(apiUrl, {
method: 'GET',
...getGerritRequestOptions(this.integration.config),
});
} catch (e) {
throw new Error(`Unable to read branch state ${url}, ${e}`);
}
if (response.status === 404) {
throw new NotFoundError(`Not found: ${url}`);
}
if (!response.ok) {
throw new Error(
`${url} could not be read as ${apiUrl}, ${response.status} ${response.statusText}`,
);
}
const branchInfo = (await parseGerritJsonResponse(response as any)) as {
revision: string;
};
if (options?.etag === branchInfo.revision) {
throw new NotModifiedError();
}
const git = Git.fromAuth({
username: this.integration.config.username,
password: this.integration.config.password,
});
const tempDir = await createTemporaryDirectory(this.workDir);
const cloneUrl = getGerritCloneRepoUrl(this.integration.config, url);
try {
// The "fromTarArchive" function will strip the top level directory so
// an additional directory level is created when we clone.
await git.clone({
url: cloneUrl,
dir: joinPath(tempDir, 'repo'),
ref: branchInfo.revision,
depth: 1,
});
const data = await new Promise<Buffer>(async resolve => {
await pipeline(
tar.create({ cwd: tempDir }, ['']),
concatStream(resolve),
);
});
const tarArchive = Readable.from(data);
return await this.deps.treeResponseFactory.fromTarArchive({
stream: tarArchive as unknown as Readable,
subpath: filePath === '/' ? undefined : filePath,
etag: branchInfo.revision,
filter: options?.filter,
});
} catch (error) {
throw new Error(`Could not clone ${cloneUrl}: ${error}`);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async search(): Promise<SearchResponse> {
@@ -0,0 +1,2 @@
)]}'
{"web_links":[{"name":"browse","url":"https://gerrit.googlesource.com/app/web/+/refs/heads/master","target":"_blank"}],"ref":"refs/heads/master","revision":"52432507a70b677b5674b019c9a46b2e9f29d0a1"}
+23
View File
@@ -195,6 +195,7 @@ export class GerritIntegration implements ScmIntegration {
export type GerritIntegrationConfig = {
host: string;
baseUrl?: string;
cloneUrl?: string;
gitilesBaseUrl?: string;
username?: string;
password?: string;
@@ -292,6 +293,18 @@ export function getBitbucketServerRequestOptions(
headers: Record<string, string>;
};
// @public
export function getGerritBranchApiUrl(
config: GerritIntegrationConfig,
url: string,
): string;
// @public
export function getGerritCloneRepoUrl(
config: GerritIntegrationConfig,
url: string,
): string;
// @public
export function getGerritFileContentsApiUrl(
config: GerritIntegrationConfig,
@@ -458,6 +471,16 @@ export interface IntegrationsByType {
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
}
// @public
export function parseGerritGitilesUrl(
config: GerritIntegrationConfig,
url: string,
): {
branch: string;
filePath: string;
project: string;
};
// @public
export function parseGerritJsonResponse(response: Response): Promise<unknown>;
+5
View File
@@ -108,6 +108,11 @@ export interface Config {
* @visibility frontend
*/
baseUrl?: string;
/**
* The base url for cloning repos.
* @visibility frontend
*/
cloneUrl?: string;
/**
* The username to use for authenticated requests.
* @visibility secret
@@ -56,6 +56,7 @@ describe('readGerritIntegrationConfig', () => {
buildConfig({
host: 'a.com',
baseUrl: 'https://a.com/api',
cloneUrl: 'https:a.com/clone',
gitilesBaseUrl: 'https://a.com/git',
username: 'u',
password: 'p',
@@ -64,6 +65,7 @@ describe('readGerritIntegrationConfig', () => {
expect(output).toEqual({
host: 'a.com',
baseUrl: 'https://a.com/api',
cloneUrl: 'https:a.com/clone',
gitilesBaseUrl: 'https://a.com/git',
username: 'u',
password: 'p',
@@ -79,6 +81,7 @@ describe('readGerritIntegrationConfig', () => {
expect(output).toEqual({
host: 'a.com',
baseUrl: 'https://a.com',
cloneUrl: 'https://a.com',
gitilesBaseUrl: 'https://a.com',
username: undefined,
password: undefined,
@@ -110,6 +113,7 @@ describe('readGerritIntegrationConfig', () => {
).toEqual({
host: 'a.com',
baseUrl: 'https://a.com/gerrit',
cloneUrl: 'https://a.com/gerrit',
gitilesBaseUrl: 'https://a.com',
});
});
@@ -139,6 +143,7 @@ describe('readGerritIntegrationConfigs', () => {
{
host: 'a.com',
baseUrl: 'https://a.com/api',
cloneUrl: 'https://a.com/api',
gitilesBaseUrl: 'https://a.com',
username: 'u',
password: 'p',
@@ -146,6 +151,7 @@ describe('readGerritIntegrationConfigs', () => {
{
host: 'b.com',
baseUrl: 'https://b.com/api',
cloneUrl: 'https://b.com/api',
gitilesBaseUrl: 'https://b.com',
username: undefined,
password: undefined,
+17
View File
@@ -38,6 +38,12 @@ export type GerritIntegrationConfig = {
*/
baseUrl?: string;
/**
* The optional base url to use for cloning a repository. If not set the
* baseUrl will be used.
*/
cloneUrl?: string;
/**
* Optional base url for Gitiles. This is needed for creating a valid
* user-friendly url that can be used for browsing the content of the
@@ -69,6 +75,7 @@ export function readGerritIntegrationConfig(
): GerritIntegrationConfig {
const host = config.getString('host');
let baseUrl = config.getOptionalString('baseUrl');
let cloneUrl = config.getOptionalString('cloneUrl');
let gitilesBaseUrl = config.getOptionalString('gitilesBaseUrl');
const username = config.getOptionalString('username');
const password = config.getOptionalString('password');
@@ -81,6 +88,10 @@ export function readGerritIntegrationConfig(
throw new Error(
`Invalid Gerrit integration config, '${baseUrl}' is not a valid baseUrl`,
);
} else if (cloneUrl && !isValidUrl(cloneUrl)) {
throw new Error(
`Invalid Gerrit integration config, '${cloneUrl}' is not a valid cloneUrl`,
);
} else if (gitilesBaseUrl && !isValidUrl(gitilesBaseUrl)) {
throw new Error(
`Invalid Gerrit integration config, '${gitilesBaseUrl}' is not a valid gitilesBaseUrl`,
@@ -96,10 +107,16 @@ export function readGerritIntegrationConfig(
} else {
gitilesBaseUrl = `https://${host}`;
}
if (cloneUrl) {
cloneUrl = trimEnd(cloneUrl, '/');
} else {
cloneUrl = baseUrl;
}
return {
host,
baseUrl,
cloneUrl,
gitilesBaseUrl,
username,
password,
+72 -5
View File
@@ -20,9 +20,11 @@ import fetch from 'cross-fetch';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { GerritIntegrationConfig } from './config';
import {
getGerritBranchApiUrl,
getGerritCloneRepoUrl,
getGerritRequestOptions,
parseGerritJsonResponse,
parseGitilesUrl,
parseGerritGitilesUrl,
getGerritFileContentsApiUrl,
} from './core';
@@ -55,7 +57,7 @@ describe('gerrit core', () => {
host: 'gerrit.com',
gitilesBaseUrl: 'https://gerrit.com/gitiles',
};
const { branch, filePath, project } = parseGitilesUrl(
const { branch, filePath, project } = parseGerritGitilesUrl(
config,
'https://gerrit.com/gitiles/web/project/+/refs/heads/master/README.md',
);
@@ -63,7 +65,7 @@ describe('gerrit core', () => {
expect(branch).toEqual('master');
expect(filePath).toEqual('README.md');
const { filePath: rootPath } = parseGitilesUrl(
const { filePath: rootPath } = parseGerritGitilesUrl(
config,
'https://gerrit.com/gitiles/web/project/+/refs/heads/master',
);
@@ -75,13 +77,13 @@ describe('gerrit core', () => {
gitilesBaseUrl: 'https://gerrit.com',
};
expect(() =>
parseGitilesUrl(
parseGerritGitilesUrl(
config,
'https://gerrit.com/+/refs/heads/master/README.md',
),
).toThrow(/project/);
expect(() =>
parseGitilesUrl(
parseGerritGitilesUrl(
config,
'https://gerrit.com/web/project/+/refs/changes/1/11/master/README.md',
),
@@ -89,6 +91,71 @@ describe('gerrit core', () => {
});
});
describe('getGerritBranchApiUrl', () => {
it('can create an url for anonymous access.', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com',
gitilesBaseUrl: 'https://gerrit.com',
};
const fileContentUrl = getGerritBranchApiUrl(
config,
'https://gerrit.com/web/project/+/refs/heads/master/README.md',
);
expect(fileContentUrl).toEqual(
'https://gerrit.com/projects/web%2Fproject/branches/master',
);
});
it('can create an url for authenticated access.', () => {
const authConfig: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com',
gitilesBaseUrl: 'https://gerrit.com',
username: 'u',
password: 'u',
};
const authFileContentUrl = getGerritBranchApiUrl(
authConfig,
'https://gerrit.com/web/project/+/refs/heads/master/README.md',
);
expect(authFileContentUrl).toEqual(
'https://gerrit.com/a/projects/web%2Fproject/branches/master',
);
});
});
describe('getGerritCloneRepoUrl', () => {
it('can create an url for anonymous clone.', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
cloneUrl: 'https://gerrit.com/clone',
gitilesBaseUrl: 'https://gerrit.com',
};
const fileContentUrl = getGerritCloneRepoUrl(
config,
'https://gerrit.com/web/project/+/refs/heads/master/README.md',
);
expect(fileContentUrl).toEqual('https://gerrit.com/clone/web/project');
});
it('can create an url for authenticated clone.', () => {
const authConfig: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com',
cloneUrl: 'https://gerrit.com/clone',
gitilesBaseUrl: 'https://gerrit.com',
username: 'u',
password: 'u',
};
const authFileContentUrl = getGerritCloneRepoUrl(
authConfig,
'https://gerrit.com/web/project/+/refs/heads/master/README.md',
);
expect(authFileContentUrl).toEqual(
'https://gerrit.com/clone/a/web/project',
);
});
});
describe('getGerritFileContentsApiUrl', () => {
it('can create an url for anonymous access to the file fetch api.', () => {
const config: GerritIntegrationConfig = {
+37 -10
View File
@@ -18,12 +18,6 @@ import { GerritIntegrationConfig } from '.';
const GERRIT_BODY_PREFIX = ")]}'";
type GitFile = {
branch: string;
filePath: string;
project: string;
};
/**
* Parse a Gitiles URL and return branch, file path and project.
*
@@ -41,17 +35,17 @@ type GitFile = {
* the urls point to an actual Gitiles installation.
*
* Gitiles url:
* https://g.com/optional_path/{project}/+/refs/heads/{branch}/{filePath}
* https://g.com/optional_path/\{project\}/+/refs/heads/\{branch\}/\{filePath\}
*
*
* @param url - An URL pointing to a file stored in git.
* @public
*/
export function parseGitilesUrl(
export function parseGerritGitilesUrl(
config: GerritIntegrationConfig,
url: string,
): GitFile {
): { branch: string; filePath: string; project: string } {
const urlPath = url.replace(config.gitilesBaseUrl!, '');
const parts = urlPath.split('/').filter(p => !!p);
@@ -94,6 +88,39 @@ export function getAuthenticationPrefix(
return config.password ? '/a/' : '/';
}
/**
* Return the url to get branch info from the Gerrit API.
*
* @param config - A Gerrit provider config.
* @param url - An url pointing to a file in git.
* @public
*/
export function getGerritBranchApiUrl(
config: GerritIntegrationConfig,
url: string,
) {
const { branch, project } = parseGerritGitilesUrl(config, url);
return `${config.baseUrl}${getAuthenticationPrefix(
config,
)}projects/${encodeURIComponent(project)}/branches/${branch}`;
}
/**
* Return the url to clone the repo that is referenced by the url.
*
* @param url - An url pointing to a file in git.
* @public
*/
export function getGerritCloneRepoUrl(
config: GerritIntegrationConfig,
url: string,
) {
const { project } = parseGerritGitilesUrl(config, url);
return `${config.cloneUrl}${getAuthenticationPrefix(config)}${project}`;
}
/**
* Return the url to fetch the contents of a file using the Gerrit API.
*
@@ -105,7 +132,7 @@ export function getGerritFileContentsApiUrl(
config: GerritIntegrationConfig,
url: string,
) {
const { branch, filePath, project } = parseGitilesUrl(config, url);
const { branch, filePath, project } = parseGerritGitilesUrl(config, url);
return `${config.baseUrl}${getAuthenticationPrefix(
config,
+3
View File
@@ -19,10 +19,13 @@ export {
readGerritIntegrationConfigs,
} from './config';
export {
getGerritBranchApiUrl,
getGerritCloneRepoUrl,
getGerritFileContentsApiUrl,
getGerritProjectsApiUrl,
getGerritRequestOptions,
parseGerritJsonResponse,
parseGerritGitilesUrl,
} from './core';
export type { GerritIntegrationConfig } from './config';