This commit is contained in:
2025-08-25 20:24:23 +08:00
parent 30106e0129
commit 0ae8d7a709
1044 changed files with 321581 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.3.0](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.5...decap-cms-backend-gitea@3.3.0) (2025-07-15)
### Features
- add logo to header ([#7487](https://github.com/decaporg/decap-cms/issues/7487)) ([b540ace](https://github.com/decaporg/decap-cms/commit/b540acec943eb231df6aac7b1d515d9b4b84fa5d))
# [3.2.0](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.5...decap-cms-backend-gitea@3.2.0) (2025-06-26)
**Note:** Version bump only for package decap-cms-backend-gitea
## [3.1.5](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.4...decap-cms-backend-gitea@3.1.5) (2024-08-13)
### Reverts
- Revert "Update dependencies (#7264)" ([22d483a](https://github.com/decaporg/decap-cms/commit/22d483a5b0c654071ae05735ac4f49abdc13d38c)), closes [#7264](https://github.com/decaporg/decap-cms/issues/7264)
## [3.1.4](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.3...decap-cms-backend-gitea@3.1.4) (2024-08-13)
**Note:** Version bump only for package decap-cms-backend-gitea
## [3.1.3](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.2...decap-cms-backend-gitea@3.1.3) (2024-03-21)
**Note:** Version bump only for package decap-cms-backend-gitea
## [3.1.2](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.1...decap-cms-backend-gitea@3.1.2) (2024-03-08)
**Note:** Version bump only for package decap-cms-backend-gitea
## [3.1.1](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.0-beta.1...decap-cms-backend-gitea@3.1.1) (2024-02-21)
### Bug Fixes
- gitlab pkce auth error ([#7110](https://github.com/decaporg/decap-cms/issues/7110)) ([bcd58d6](https://github.com/decaporg/decap-cms/commit/bcd58d6e117b4654b3e0dca173f7f8aaca8dabdf))
# [3.1.0](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.0-beta.1...decap-cms-backend-gitea@3.1.0) (2024-02-01)
**Note:** Version bump only for package decap-cms-backend-gitea
# [3.1.0-beta.1](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.0-beta.0...decap-cms-backend-gitea@3.1.0-beta.1) (2024-01-31)
**Note:** Version bump only for package decap-cms-backend-gitea
# [3.1.0-beta.0](https://github.com/decaporg/decap-cms/compare/decap-cms-backend-gitea@3.1.0...decap-cms-backend-gitea@3.1.0-beta.0) (2023-10-20)
### Reverts
- Revert "chore(release): publish" ([b89fc89](https://github.com/decaporg/decap-cms/commit/b89fc894dfbb5f4136b2e5427fd25a29378a58c6))
## 3.0.4 (2023-10-20)
### Features
- **backend:** add gitea backend ([#6808](https://github.com/decaporg/decap-cms/issues/6808)) ([0d89a58](https://github.com/decaporg/decap-cms/commit/0d89a58e93f64f868ff3e4e8f0945ccf166ad738))
## 3.0.3 (2023-10-20)
### Features
- **backend:** add gitea backend ([#6808](https://github.com/decaporg/decap-cms/issues/6808)) ([0d89a58](https://github.com/decaporg/decap-cms/commit/0d89a58e93f64f868ff3e4e8f0945ccf166ad738))

View File

@@ -0,0 +1,36 @@
{
"name": "decap-cms-backend-gitea",
"description": "Gitea backend for Decap CMS",
"version": "3.3.0",
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-backend-gitea",
"bugs": "https://github.com/decaporg/decap-cms/issues",
"license": "MIT",
"module": "dist/esm/index.js",
"main": "dist/decap-cms-backend-gitea.js",
"keywords": [
"decap-cms",
"backend",
"gitea"
],
"sideEffects": false,
"scripts": {
"develop": "npm run build:esm -- --watch",
"build": "cross-env NODE_ENV=production webpack",
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
},
"dependencies": {
"js-base64": "^3.0.0",
"semaphore": "^1.1.0"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"decap-cms-lib-auth": "^3.0.0",
"decap-cms-lib-util": "^3.0.0",
"decap-cms-ui-default": "^3.0.0",
"immutable": "^3.7.6",
"lodash": "^4.17.11",
"prop-types": "^15.7.2",
"react": "^19.1.0"
}
}

View File

@@ -0,0 +1,468 @@
import { Base64 } from 'js-base64';
import trimStart from 'lodash/trimStart';
import trim from 'lodash/trim';
import result from 'lodash/result';
import partial from 'lodash/partial';
import last from 'lodash/last';
import initial from 'lodash/initial';
import {
APIError,
basename,
generateContentKey,
getAllResponses,
localForage,
parseContentKey,
readFileMetadata,
requestWithBackoff,
unsentRequest,
} from 'decap-cms-lib-util';
import type {
DataFile,
PersistOptions,
AssetProxy,
ApiRequest,
FetchError,
} from 'decap-cms-lib-util';
import type { Semaphore } from 'semaphore';
import type {
FilesResponse,
GitGetBlobResponse,
GitGetTreeResponse,
GiteaUser,
GiteaRepository,
ReposListCommitsResponse,
} from './types';
export const API_NAME = 'Gitea';
export interface Config {
apiRoot?: string;
token?: string;
branch?: string;
repo?: string;
originRepo?: string;
}
enum FileOperation {
CREATE = 'create',
DELETE = 'delete',
UPDATE = 'update',
}
export interface ChangeFileOperation {
content?: string;
from_path?: string;
path: string;
operation: FileOperation;
sha?: string;
}
interface MetaDataObjects {
entry: { path: string; sha: string };
files: MediaFile[];
}
export interface Metadata {
type: string;
objects: MetaDataObjects;
branch: string;
status: string;
collection: string;
commitMessage: string;
version?: string;
user: string;
title?: string;
description?: string;
timeStamp: string;
}
export interface BlobArgs {
sha: string;
repoURL: string;
parseText: boolean;
}
type Param = string | number | undefined;
export type Options = RequestInit & {
params?: Record<string, Param | Record<string, Param> | string[]>;
};
type MediaFile = {
sha: string;
path: string;
};
export default class API {
apiRoot: string;
token: string;
branch: string;
repo: string;
originRepo: string;
repoOwner: string;
repoName: string;
originRepoOwner: string;
originRepoName: string;
repoURL: string;
originRepoURL: string;
_userPromise?: Promise<GiteaUser>;
_metadataSemaphore?: Semaphore;
commitAuthor?: {};
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://try.gitea.io/api/v1';
this.token = config.token || '';
this.branch = config.branch || 'master';
this.repo = config.repo || '';
this.originRepo = config.originRepo || this.repo;
this.repoURL = `/repos/${this.repo}`;
this.originRepoURL = `/repos/${this.originRepo}`;
const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
this.repoOwner = repoParts[0];
this.repoName = repoParts[1];
this.originRepoOwner = originRepoParts[0];
this.originRepoName = originRepoParts[1];
}
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
user(): Promise<{ full_name: string; login: string; avatar_url: string }> {
if (!this._userPromise) {
this._userPromise = this.getUser();
}
return this._userPromise;
}
getUser() {
return this.request('/user') as Promise<GiteaUser>;
}
async hasWriteAccess() {
try {
const result: GiteaRepository = await this.request(this.repoURL);
// update config repoOwner to avoid case sensitivity issues with Gitea
this.repoOwner = result.owner.login;
return result.permissions.push;
} catch (error) {
console.error('Problem fetching repo data from Gitea');
throw error;
}
}
reset() {
// no op
}
requestHeaders(headers = {}) {
const baseHeader: Record<string, string> = {
'Content-Type': 'application/json; charset=utf-8',
...headers,
};
if (this.token) {
baseHeader.Authorization = `token ${this.token}`;
return Promise.resolve(baseHeader);
}
return Promise.resolve(baseHeader);
}
async parseJsonResponse(response: Response) {
const json = await response.json();
if (!response.ok) {
return Promise.reject(json);
}
return json;
}
urlFor(path: string, options: Options) {
const params = [];
if (options.params) {
for (const key in options.params) {
params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
}
}
if (params.length) {
path += `?${params.join('&')}`;
}
return this.apiRoot + path;
}
parseResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
const textPromise = response.text().then(text => {
if (!response.ok) {
return Promise.reject(text);
}
return text;
});
return textPromise;
}
handleRequestError(error: FetchError, responseStatus: number) {
throw new APIError(error.message, responseStatus, API_NAME);
}
buildRequest(req: ApiRequest) {
return req;
}
async request(
path: string,
options: Options = {},
parser = (response: Response) => this.parseResponse(response),
) {
options = { cache: 'no-cache', ...options };
const headers = await this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
let responseStatus = 500;
try {
const req = unsentRequest.fromFetchArguments(url, {
...options,
headers,
}) as unknown as ApiRequest;
const response = await requestWithBackoff(this, req);
responseStatus = response.status;
const parsedResponse = await parser(response);
return parsedResponse;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
return this.handleRequestError(error, responseStatus);
}
}
nextUrlProcessor() {
return (url: string) => url;
}
async requestAllPages<T>(url: string, options: Options = {}) {
options = { cache: 'no-cache', ...options };
const headers = await this.requestHeaders(options.headers || {});
const processedURL = this.urlFor(url, options);
const allResponses = await getAllResponses(
processedURL,
{ ...options, headers },
'next',
this.nextUrlProcessor(),
);
const pages: T[][] = await Promise.all(
allResponses.map((res: Response) => this.parseResponse(res)),
);
return ([] as T[]).concat(...pages);
}
generateContentKey(collectionName: string, slug: string) {
return generateContentKey(collectionName, slug);
}
parseContentKey(contentKey: string) {
return parseContentKey(contentKey);
}
async readFile(
path: string,
sha?: string | null,
{
branch = this.branch,
repoURL = this.repoURL,
parseText = true,
}: {
branch?: string;
repoURL?: string;
parseText?: boolean;
} = {},
) {
if (!sha) {
sha = await this.getFileSha(path, { repoURL, branch });
}
const content = await this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
return content;
}
async readFileMetadata(path: string, sha: string | null | undefined) {
const fetchFileMetadata = async () => {
try {
const result: ReposListCommitsResponse = await this.request(
`${this.originRepoURL}/commits`,
{
params: { path, sha: this.branch, stat: 'false' },
},
);
const { commit } = result[0];
return {
author: commit.author.name || commit.author.email,
updatedOn: commit.author.date,
};
} catch (e) {
return { author: '', updatedOn: '' };
}
};
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
return fileMetadata;
}
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
const result: GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, {
cache: 'force-cache',
});
if (parseText) {
// treat content as a utf-8 string
const content = Base64.decode(result.content);
return content;
} else {
// treat content as binary and convert to blob
const content = Base64.atob(result.content);
const byteArray = new Uint8Array(content.length);
for (let i = 0; i < content.length; i++) {
byteArray[i] = content.charCodeAt(i);
}
const blob = new Blob([byteArray]);
return blob;
}
}
async listFiles(
path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
folderSupport?: boolean,
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/');
try {
const result: GitGetTreeResponse = await this.request(
`${repoURL}/git/trees/${branch}:${encodeURIComponent(folder)}`,
{
// Gitea API supports recursive=1 for getting the entire recursive tree
// or omitting it to get the non-recursive tree
params: depth > 1 ? { recursive: 1 } : {},
},
);
return (
result.tree
// filter only files and/or folders up to the required depth
.filter(
file =>
(!folderSupport ? file.type === 'blob' : true) &&
decodeURIComponent(file.path).split('/').length <= depth,
)
.map(file => ({
type: file.type,
id: file.sha,
name: basename(file.path),
path: `${folder}/${file.path}`,
size: file.size!,
}))
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err && err.status === 404) {
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
return [];
} else {
throw err;
}
}
}
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
const operations = await this.getChangeFileOperations(files, this.branch);
return this.changeFiles(operations, options);
}
async changeFiles(operations: ChangeFileOperation[], options: PersistOptions) {
return (await this.request(`${this.repoURL}/contents`, {
method: 'POST',
body: JSON.stringify({
branch: this.branch,
files: operations,
message: options.commitMessage,
}),
})) as FilesResponse;
}
async getChangeFileOperations(files: { path: string; newPath?: string }[], branch: string) {
const items: ChangeFileOperation[] = await Promise.all(
files.map(async file => {
const content = await result(
file,
'toBase64',
partial(this.toBase64, (file as DataFile).raw),
);
let sha;
let operation;
let from_path;
let path = trimStart(file.path, '/');
try {
sha = await this.getFileSha(file.path, { branch });
operation = FileOperation.UPDATE;
from_path = file.newPath && path;
path = file.newPath ? trimStart(file.newPath, '/') : path;
} catch {
sha = undefined;
operation = FileOperation.CREATE;
}
return {
operation,
content,
path,
from_path,
sha,
} as ChangeFileOperation;
}),
);
return items;
}
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
/**
* We need to request the tree first to get the SHA. We use extended SHA-1
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
* through the tree.
*/
const pathArray = path.split('/');
const filename = last(pathArray);
const directory = initial(pathArray).join('/');
const fileDataPath = encodeURIComponent(directory);
const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
const result: GitGetTreeResponse = await this.request(fileDataURL);
const file = result.tree.find(file => file.path === filename);
if (file) {
return file.sha;
} else {
throw new APIError('Not Found', 404, API_NAME);
}
}
async deleteFiles(paths: string[], message: string) {
const operations: ChangeFileOperation[] = await Promise.all(
paths.map(async path => {
const sha = await this.getFileSha(path);
return {
operation: FileOperation.DELETE,
path,
sha,
} as ChangeFileOperation;
}),
);
this.changeFiles(operations, { commitMessage: message });
}
toBase64(str: string) {
return Promise.resolve(Base64.encode(str));
}
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { PkceAuthenticator } from 'decap-cms-lib-auth';
import { AuthenticationPage, Icon } from 'decap-cms-ui-default';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
export default class GiteaAuthenticationPage extends React.Component {
static propTypes = {
inProgress: PropTypes.bool,
config: PropTypes.object.isRequired,
onLogin: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
state = {};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(
GiteaAuthenticationPage.propTypes,
this.props,
'prop',
'GiteaAuthenticationPage',
);
const { base_url = 'https://try.gitea.io', app_id = '' } = this.props.config.backend;
this.auth = new PkceAuthenticator({
base_url,
auth_endpoint: 'login/oauth/authorize',
app_id,
auth_token_endpoint: 'login/oauth/access_token',
auth_token_endpoint_content_type: 'application/json; charset=utf-8',
});
// Complete authentication if we were redirected back to from the provider.
this.auth.completeAuth((err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
} else if (data) {
this.props.onLogin(data);
}
});
}
handleLogin = e => {
e.preventDefault();
this.auth.authenticate({ scope: 'repository' }, (err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
};
render() {
const { inProgress, config, t } = this.props;
return (
<AuthenticationPage
onLogin={this.handleLogin}
loginDisabled={inProgress}
loginErrorMessage={this.state.loginError}
logoUrl={config.logo_url} // Deprecated, replaced by `logo.src`
logo={config.logo}
siteUrl={config.site_url}
renderButtonContent={() => (
<React.Fragment>
<LoginButtonIcon type="gitea" />{' '}
{inProgress ? t('auth.loggingIn') : t('auth.loginWithGitea')}
</React.Fragment>
)}
t={t}
/>
);
}
}

View File

@@ -0,0 +1,388 @@
import { Base64 } from 'js-base64';
import API from '../API';
global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
describe('gitea API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mockAPI(api, responses) {
api.request = jest.fn().mockImplementation((path, options = {}) => {
const normalizedPath = path.indexOf('?') !== -1 ? path.slice(0, path.indexOf('?')) : path;
const response = responses[normalizedPath];
return typeof response === 'function'
? Promise.resolve(response(options))
: Promise.reject(new Error(`No response for path '${normalizedPath}'`));
});
}
describe('request', () => {
const fetch = jest.fn();
beforeEach(() => {
global.fetch = fetch;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch url with authorization header', async () => {
const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' });
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue('some response'),
ok: true,
status: 200,
headers: { get: () => '' },
});
const result = await api.request('/some-path');
expect(result).toEqual('some response');
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://try.gitea.io/api/v1/some-path', {
cache: 'no-cache',
headers: {
Authorization: 'token token',
'Content-Type': 'application/json; charset=utf-8',
},
signal: expect.any(AbortSignal),
});
});
it('should throw error on not ok response', async () => {
const api = new API({ branch: 'gt-pages', repo: 'my-repo', token: 'token' });
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue({ message: 'some error' }),
ok: false,
status: 404,
headers: { get: () => '' },
});
await expect(api.request('some-path')).rejects.toThrow(
expect.objectContaining({
message: 'some error',
name: 'API_ERROR',
status: 404,
api: 'Gitea',
}),
);
});
it('should allow overriding requestHeaders to return a promise ', async () => {
const api = new API({ branch: 'gt-pages', repo: 'my-repo', token: 'token' });
api.requestHeaders = jest.fn().mockResolvedValue({
Authorization: 'promise-token',
'Content-Type': 'application/json; charset=utf-8',
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue('some response'),
ok: true,
status: 200,
headers: { get: () => '' },
});
const result = await api.request('/some-path');
expect(result).toEqual('some response');
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://try.gitea.io/api/v1/some-path', {
cache: 'no-cache',
headers: {
Authorization: 'promise-token',
'Content-Type': 'application/json; charset=utf-8',
},
signal: expect.any(AbortSignal),
});
});
});
describe('persistFiles', () => {
it('should create a new commit', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const responses = {
'/repos/owner/repo/git/trees/master:content%2Fposts': () => {
return { tree: [{ path: 'update-post.md', sha: 'old-sha' }] };
},
'/repos/owner/repo/contents': () => ({
commit: { sha: 'new-sha' },
files: [
{
path: 'content/posts/new-post.md',
},
{
path: 'content/posts/update-post.md',
},
],
}),
};
mockAPI(api, responses);
const entry = {
dataFiles: [
{
slug: 'entry',
path: 'content/posts/new-post.md',
raw: 'content',
},
{
slug: 'entry',
sha: 'old-sha',
path: 'content/posts/update-post.md',
raw: 'content',
},
],
assets: [],
};
await expect(
api.persistFiles(entry.dataFiles, entry.assets, {
commitMessage: 'commitMessage',
newEntry: true,
}),
).resolves.toEqual({
commit: { sha: 'new-sha' },
files: [
{
path: 'content/posts/new-post.md',
},
{
path: 'content/posts/update-post.md',
},
],
});
expect(api.request).toHaveBeenCalledTimes(3);
expect(api.request.mock.calls[0]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect(api.request.mock.calls[1]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect(api.request.mock.calls[2]).toEqual([
'/repos/owner/repo/contents',
{
method: 'POST',
body: JSON.stringify({
branch: 'master',
files: [
{
operation: 'create',
content: Base64.encode(entry.dataFiles[0].raw),
path: entry.dataFiles[0].path,
},
{
operation: 'update',
content: Base64.encode(entry.dataFiles[1].raw),
path: entry.dataFiles[1].path,
sha: entry.dataFiles[1].sha,
},
],
message: 'commitMessage',
}),
},
]);
});
});
describe('deleteFiles', () => {
it('should check if files exist and delete them', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const responses = {
'/repos/owner/repo/git/trees/master:content%2Fposts': () => {
return {
tree: [
{ path: 'delete-post-1.md', sha: 'old-sha-1' },
{ path: 'delete-post-2.md', sha: 'old-sha-2' },
],
};
},
'/repos/owner/repo/contents': () => ({
commit: { sha: 'new-sha' },
files: [
{
path: 'content/posts/delete-post-1.md',
},
{
path: 'content/posts/delete-post-2.md',
},
],
}),
};
mockAPI(api, responses);
const deleteFiles = ['content/posts/delete-post-1.md', 'content/posts/delete-post-2.md'];
await api.deleteFiles(deleteFiles, 'commitMessage');
expect(api.request).toHaveBeenCalledTimes(3);
expect(api.request.mock.calls[0]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect(api.request.mock.calls[1]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect(api.request.mock.calls[2]).toEqual([
'/repos/owner/repo/contents',
{
method: 'POST',
body: JSON.stringify({
branch: 'master',
files: [
{
operation: 'delete',
path: deleteFiles[0],
sha: 'old-sha-1',
},
{
operation: 'delete',
path: deleteFiles[1],
sha: 'old-sha-2',
},
],
message: 'commitMessage',
}),
},
]);
});
});
describe('listFiles', () => {
it('should get files by depth', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const tree = [
{
path: 'post.md',
type: 'blob',
},
{
path: 'dir1',
type: 'tree',
},
{
path: 'dir1/nested-post.md',
type: 'blob',
},
{
path: 'dir1/dir2',
type: 'tree',
},
{
path: 'dir1/dir2/nested-post.md',
type: 'blob',
},
];
api.request = jest.fn().mockResolvedValue({ tree });
await expect(api.listFiles('posts', { depth: 1 })).resolves.toEqual([
{
path: 'posts/post.md',
type: 'blob',
name: 'post.md',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', {
params: {},
});
jest.clearAllMocks();
await expect(api.listFiles('posts', { depth: 2 })).resolves.toEqual([
{
path: 'posts/post.md',
type: 'blob',
name: 'post.md',
},
{
path: 'posts/dir1/nested-post.md',
type: 'blob',
name: 'nested-post.md',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', {
params: { recursive: 1 },
});
jest.clearAllMocks();
await expect(api.listFiles('posts', { depth: 3 })).resolves.toEqual([
{
path: 'posts/post.md',
type: 'blob',
name: 'post.md',
},
{
path: 'posts/dir1/nested-post.md',
type: 'blob',
name: 'nested-post.md',
},
{
path: 'posts/dir1/dir2/nested-post.md',
type: 'blob',
name: 'nested-post.md',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', {
params: { recursive: 1 },
});
});
it('should get files and folders', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const tree = [
{
path: 'image.png',
type: 'blob',
},
{
path: 'dir1',
type: 'tree',
},
{
path: 'dir1/nested-image.png',
type: 'blob',
},
{
path: 'dir1/dir2',
type: 'tree',
},
{
path: 'dir1/dir2/nested-image.png',
type: 'blob',
},
];
api.request = jest.fn().mockResolvedValue({ tree });
await expect(api.listFiles('media', {}, true)).resolves.toEqual([
{
path: 'media/image.png',
type: 'blob',
name: 'image.png',
},
{
path: 'media/dir1',
type: 'tree',
name: 'dir1',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
params: {},
});
});
});
});

View File

@@ -0,0 +1,284 @@
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'decap-cms-lib-util';
import GiteaImplementation from '../implementation';
jest.spyOn(console, 'error').mockImplementation(() => {});
describe('gitea backend implementation', () => {
const config = {
backend: {
repo: 'owner/repo',
api_root: 'https://try.gitea.io/api/v1',
},
};
const createObjectURL = jest.fn();
global.URL = {
createObjectURL,
};
createObjectURL.mockReturnValue('displayURL');
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
});
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
describe('persistMedia', () => {
const persistFiles = jest.fn();
const mockAPI = {
persistFiles,
};
persistFiles.mockImplementation((_, files) => {
files.forEach((file, index) => {
file.sha = index;
});
});
it('should persist media file', async () => {
const giteaImplementation = new GiteaImplementation(config);
giteaImplementation.api = mockAPI;
const mediaFile = {
fileObj: { size: 100, name: 'image.png' },
path: '/media/image.png',
};
expect.assertions(5);
await expect(
giteaImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }),
).resolves.toEqual({
id: 0,
name: 'image.png',
size: 100,
displayURL: 'displayURL',
path: 'media/image.png',
});
expect(persistFiles).toHaveBeenCalledTimes(1);
expect(persistFiles).toHaveBeenCalledWith([], [mediaFile], {
commitMessage: 'Persisting media',
});
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalledWith(mediaFile.fileObj);
});
it('should log and throw error on "persistFiles" error', async () => {
const giteaImplementation = new GiteaImplementation(config);
giteaImplementation.api = mockAPI;
const error = new Error('failed to persist files');
persistFiles.mockRejectedValue(error);
const mediaFile = {
fileObj: { size: 100 },
path: '/media/image.png',
};
expect.assertions(5);
await expect(
giteaImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }),
).rejects.toThrowError(error);
expect(persistFiles).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalledTimes(0);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(error);
});
});
describe('entriesByFolder', () => {
const listFiles = jest.fn();
const readFile = jest.fn();
const readFileMetadata = jest.fn(() => Promise.resolve({ author: '', updatedOn: '' }));
const mockAPI = {
listFiles,
readFile,
readFileMetadata,
originRepoURL: 'originRepoURL',
};
it('should return entries and cursor', async () => {
const giteaImplementation = new GiteaImplementation(config);
giteaImplementation.api = mockAPI;
const files = [];
const count = 1501;
for (let i = 0; i < count; i++) {
const id = `${i}`.padStart(`${count}`.length, '0');
files.push({
id,
path: `posts/post-${id}.md`,
});
}
listFiles.mockResolvedValue(files);
readFile.mockImplementation((_path, id) => Promise.resolve(`${id}`));
const expectedEntries = files
.slice(0, 20)
.map(({ id, path }) => ({ data: id, file: { path, id, author: '', updatedOn: '' } }));
const expectedCursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectedEntries[CURSOR_COMPATIBILITY_SYMBOL] = expectedCursor;
const result = await giteaImplementation.entriesByFolder('posts', 'md', 1);
expect(result).toEqual(expectedEntries);
expect(listFiles).toHaveBeenCalledTimes(1);
expect(listFiles).toHaveBeenCalledWith('posts', { depth: 1, repoURL: 'originRepoURL' });
expect(readFile).toHaveBeenCalledTimes(20);
});
});
describe('traverseCursor', () => {
const listFiles = jest.fn();
const readFile = jest.fn((_path, id) => Promise.resolve(`${id}`));
const readFileMetadata = jest.fn(() => Promise.resolve({}));
const mockAPI = {
listFiles,
readFile,
originRepoURL: 'originRepoURL',
readFileMetadata,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const files = [];
const count = 1501;
for (let i = 0; i < count; i++) {
const id = `${i}`.padStart(`${count}`.length, '0');
files.push({
id,
path: `posts/post-${id}.md`,
});
}
it('should handle next action', async () => {
const giteaImplementation = new GiteaImplementation(config);
giteaImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(20, 40)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['prev', 'first', 'next', 'last'],
meta: { page: 2, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await giteaImplementation.traverseCursor(cursor, 'next');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
it('should handle prev action', async () => {
const giteaImplementation = new GiteaImplementation(config);
giteaImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['prev', 'first', 'next', 'last'],
meta: { page: 2, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(0, 20)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await giteaImplementation.traverseCursor(cursor, 'prev');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
it('should handle last action', async () => {
const giteaImplementation = new GiteaImplementation(config);
giteaImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(1500)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['prev', 'first'],
meta: { page: 76, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await giteaImplementation.traverseCursor(cursor, 'last');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
it('should handle first action', async () => {
const giteaImplementation = new GiteaImplementation(config);
giteaImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['prev', 'first'],
meta: { page: 76, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(0, 20)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await giteaImplementation.traverseCursor(cursor, 'first');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
});
});

View File

@@ -0,0 +1,450 @@
import { stripIndent } from 'common-tags';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import {
asyncLock,
basename,
blobToFileObj,
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
entriesByFiles,
entriesByFolder,
filterByExtension,
getBlobSHA,
getMediaAsBlob,
getMediaDisplayURL,
runWithLock,
unsentRequest,
} from 'decap-cms-lib-util';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import type {
AssetProxy,
AsyncLock,
Config,
Credentials,
DisplayURL,
Entry,
Implementation,
ImplementationFile,
PersistOptions,
User,
} from 'decap-cms-lib-util';
import type { Semaphore } from 'semaphore';
import type { GiteaUser } from './types';
const MAX_CONCURRENT_DOWNLOADS = 10;
type ApiFile = { id: string; type: string; name: string; path: string; size: number };
const { fetchWithTimeout: fetch } = unsentRequest;
export default class Gitea implements Implementation {
lock: AsyncLock;
api: API | null;
options: {
proxied: boolean;
API: API | null;
useWorkflow?: boolean;
};
originRepo: string;
repo?: string;
branch: string;
apiRoot: string;
mediaFolder?: string;
token: string | null;
_currentUserPromise?: Promise<GiteaUser>;
_userIsOriginMaintainerPromises?: {
[key: string]: Promise<boolean>;
};
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
this.options = {
proxied: false,
API: null,
useWorkflow: false,
...options,
};
if (
!this.options.proxied &&
(config.backend.repo === null || config.backend.repo === undefined)
) {
throw new Error('The Gitea backend needs a "repo" in the backend configuration.');
}
if (this.options.useWorkflow) {
throw new Error('The Gitea backend does not support editorial workflow.');
}
this.api = this.options.API || null;
this.repo = this.originRepo = config.backend.repo || '';
this.branch = config.backend.branch?.trim() || 'master';
this.apiRoot = config.backend.api_root || 'https://try.gitea.io/api/v1';
this.token = '';
this.mediaFolder = config.media_folder;
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const auth =
(await this.api
?.user()
.then(user => !!user)
.catch(e => {
console.warn('[StaticCMS] Failed getting Gitea user', e);
return false;
})) || false;
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user: User) {
return this.authenticate(user);
}
async currentUser({ token }: { token: string }) {
if (!this._currentUserPromise) {
this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
headers: {
Authorization: `token ${token}`,
},
}).then(res => res.json());
}
return this._currentUserPromise;
}
async userIsOriginMaintainer({
username: usernameArg,
token,
}: {
username?: string;
token: string;
}) {
const username = usernameArg || (await this.currentUser({ token })).login;
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
if (!this._userIsOriginMaintainerPromises[username]) {
this._userIsOriginMaintainerPromises[username] = fetch(
`${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`,
{
headers: {
Authorization: `token ${token}`,
},
},
)
.then(res => res.json())
.then(({ permission }) => permission === 'admin' || permission === 'write');
}
return this._userIsOriginMaintainerPromises[username];
}
async authenticate(state: Credentials) {
this.token = state.token as string;
const apiCtor = API;
this.api = new apiCtor({
token: this.token,
branch: this.branch,
repo: this.repo,
originRepo: this.originRepo,
apiRoot: this.apiRoot,
});
const user = await this.api!.user();
const isCollab = await this.api!.hasWriteAccess().catch(error => {
error.message = stripIndent`
Repo "${this.repo}" not found.
Please ensure the repo information is spelled correctly.
If the repo is private, make sure you're logged into a Gitea account with access.
If your repo is under an organization, ensure the organization has granted access to Static
CMS.
`;
throw error;
});
// Unauthorized user
if (!isCollab) {
throw new Error('Your Gitea user account does not have access to this repo.');
}
// Authorized user
return {
name: user.full_name,
login: user.login,
avatar_url: user.avatar_url,
token: state.token as string,
};
}
logout() {
this.token = null;
if (this.api && this.api.reset && typeof this.api.reset === 'function') {
return this.api.reset();
}
}
getToken() {
return Promise.resolve(this.token);
}
getCursorAndFiles = (files: ApiFile[], page: number) => {
const pageSize = 20;
const count = files.length;
const pageCount = Math.ceil(files.length / pageSize);
const actions = [] as string[];
if (page > 1) {
actions.push('prev');
actions.push('first');
}
if (page < pageCount) {
actions.push('next');
actions.push('last');
}
const cursor = Cursor.create({
actions,
meta: { page, count, pageSize, pageCount },
data: { files },
});
const pageFiles = files.slice((page - 1) * pageSize, page * pageSize);
return { cursor, files: pageFiles };
};
async entriesByFolder(folder: string, extension: string, depth: number) {
const repoURL = this.api!.originRepoURL;
let cursor: Cursor;
const listFiles = () =>
this.api!.listFiles(folder, {
repoURL,
depth,
}).then(files => {
const filtered = files.filter(file => filterByExtension(file, extension));
const result = this.getCursorAndFiles(filtered, 1);
cursor = result.cursor;
return result.files;
});
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }) as Promise<string>;
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return files;
}
async allEntriesByFolder(folder: string, extension: string, depth: number) {
const repoURL = this.api!.originRepoURL;
const listFiles = () =>
this.api!.listFiles(folder, {
repoURL,
depth,
}).then(files => files.filter(file => filterByExtension(file, extension)));
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { repoURL }) as Promise<string>;
};
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return files;
}
entriesByFiles(files: ImplementationFile[]) {
const repoURL = this.api!.repoURL;
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise<string>;
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
}
// Fetches a single entry.
getEntry(path: string) {
const repoURL = this.api!.originRepoURL;
return this.api!.readFile(path, null, { repoURL })
.then(data => ({
file: { path, id: null },
data: data as string,
}))
.catch(() => ({ file: { path, id: null }, data: '' }));
}
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) {
return [];
}
return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
files.map(({ id, name, size, path, type }) => {
return { id, name, size, displayURL: { id, path }, path, isDirectory: type === 'tree' };
}),
);
}
async getMediaFile(path: string) {
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const name = basename(path);
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
persistEntry(entry: Entry, options: PersistOptions) {
// persistEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
'Failed to acquire persist entry lock',
);
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
try {
await this.api!.persistFiles([], [mediaFile], options);
const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string };
const displayURL = URL.createObjectURL(fileObj as Blob);
return {
id: sha,
name: fileObj!.name,
size: fileObj!.size,
displayURL,
path: trimStart(path, '/'),
};
} catch (error) {
console.error(error);
throw error;
}
}
deleteFiles(paths: string[], commitMessage: string) {
return this.api!.deleteFiles(paths, commitMessage);
}
async traverseCursor(cursor: Cursor, action: string) {
const meta = cursor.meta!;
const files = cursor.data!.get('files')!.toJS() as ApiFile[];
let result: { cursor: Cursor; files: ApiFile[] };
switch (action) {
case 'first': {
result = this.getCursorAndFiles(files, 1);
break;
}
case 'last': {
result = this.getCursorAndFiles(files, meta.get('pageCount'));
break;
}
case 'next': {
result = this.getCursorAndFiles(files, meta.get('page') + 1);
break;
}
case 'prev': {
result = this.getCursorAndFiles(files, meta.get('page') - 1);
break;
}
default: {
result = this.getCursorAndFiles(files, 1);
break;
}
}
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL: this.api!.originRepoURL }).catch(
() => '',
) as Promise<string>;
const entries = await entriesByFiles(
result.files,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return {
entries,
cursor: result.cursor,
};
}
async unpublishedEntries() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {} as any;
}
async unpublishedEntry() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {} as any;
}
async unpublishedEntryDataFile() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {} as any;
}
async unpublishedEntryMediaFile() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {} as any;
}
async updateUnpublishedEntryStatus() {
return;
}
async publishUnpublishedEntry() {
return;
}
async deleteUnpublishedEntry() {
return;
}
async getDeployPreview() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {} as any;
}
}

View File

@@ -0,0 +1,3 @@
export { default as GiteaBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View File

@@ -0,0 +1,260 @@
export type GiteaUser = {
active: boolean;
avatar_url: string;
created: string;
description: string;
email: string;
followers_count: number;
following_count: number;
full_name: string;
id: number;
is_admin: boolean;
language: string;
last_login: string;
location: string;
login: string;
login_name?: string;
prohibit_login: boolean;
restricted: boolean;
starred_repos_count: number;
visibility: string;
website: string;
};
export type GiteaTeam = {
can_create_org_repo: boolean;
description: string;
id: number;
includes_all_repositories: boolean;
name: string;
organization: GiteaOrganization;
permission: string;
units: Array<string>;
units_map: Map<string, string>;
};
export type GiteaOrganization = {
avatar_url: string;
description: string;
full_name: string;
id: number;
location: string;
name: string;
repo_admin_change_team_access: boolean;
username: string;
visibility: string;
website: string;
};
type CommitUser = {
date: string;
email: string;
name: string;
};
type CommitMeta = {
created: string;
sha: string;
url: string;
};
type PayloadUser = {
email: string;
name: string;
username: string;
};
type PayloadCommitVerification = {
payload: string;
reason: string;
signature: string;
signer: PayloadUser;
verified: boolean;
};
type ReposListCommitsResponseItemCommit = {
author: CommitUser;
committer: CommitUser;
message: string;
tree: CommitMeta;
url: string;
verification: PayloadCommitVerification;
};
type GiteaRepositoryPermissions = {
admin: boolean;
pull: boolean;
push: boolean;
};
type GiteaRepositoryExternalTracker = {
external_tracker_format: string;
external_tracker_regexp_pattern: string;
external_tracker_style: string;
external_tracker_url: string;
};
type GiteaRepositoryExternalWiki = {
external_wiki_url: string;
};
type GiteaRepositoryInternalTracker = {
allow_only_contributors_to_track_time: boolean;
enable_issue_dependencies: boolean;
enable_time_tracker: boolean;
};
type GiteaRepositoryRepoTransfer = {
description: string;
doer: GiteaUser;
recipient: GiteaUser;
teams: Array<GiteaTeam>;
enable_issue_dependencies: boolean;
enable_time_tracker: boolean;
};
export type GiteaRepository = {
allow_merge_commits: boolean;
allow_rebase: boolean;
allow_rebase_explicit: boolean;
allow_rebase_update: boolean;
allow_squash_merge: boolean;
archived: boolean;
avatar_url: string;
clone_url: string;
created_at: string;
default_branch: string;
default_delete_branch_after_merge: boolean;
default_merge_style: boolean;
description: string;
empty: boolean;
external_tracker: GiteaRepositoryExternalTracker;
external_wiki: GiteaRepositoryExternalWiki;
fork: boolean;
forks_count: number;
full_name: string;
has_issues: boolean;
has_projects: boolean;
has_pull_requests: boolean;
has_wiki: boolean;
html_url: string;
id: number;
ignore_whitespace_conflicts: boolean;
internal: boolean;
internal_tracker: GiteaRepositoryInternalTracker;
language: string;
languages_url: string;
mirror: boolean;
mirror_interval: string;
mirror_updated: string;
name: string;
open_issues_count: number;
open_pr_counter: number;
original_url: string;
owner: GiteaUser;
parent: null;
permissions: GiteaRepositoryPermissions;
private: boolean;
release_counter: number;
repo_transfer: GiteaRepositoryRepoTransfer;
size: number;
ssh_url: string;
stars_count: number;
template: boolean;
updated_at: string;
watchers_count: number;
website: string;
};
type ReposListCommitsResponseItemCommitAffectedFiles = {
filename: string;
};
type ReposListCommitsResponseItemCommitStats = {
additions: number;
deletions: number;
total: number;
};
type ReposListCommitsResponseItem = {
author: GiteaUser;
commit: ReposListCommitsResponseItemCommit;
committer: GiteaUser;
created: string;
files: Array<ReposListCommitsResponseItemCommitAffectedFiles>;
html_url: string;
parents: Array<CommitMeta>;
sha: string;
stats: ReposListCommitsResponseItemCommitStats;
url: string;
};
export type ReposListCommitsResponse = Array<ReposListCommitsResponseItem>;
export type GitGetBlobResponse = {
content: string;
encoding: string;
sha: string;
size: number;
url: string;
};
type GitGetTreeResponseTreeItem = {
mode: string;
path: string;
sha: string;
size?: number;
type: string;
url: string;
};
export type GitGetTreeResponse = {
page: number;
sha: string;
total_count: number;
tree: Array<GitGetTreeResponseTreeItem>;
truncated: boolean;
url: string;
};
type FileLinksResponse = {
git: string;
html: string;
self: string;
};
type ContentsResponse = {
_links: FileLinksResponse;
content?: string | null;
download_url: string;
encoding?: string | null;
git_url: string;
html_url: string;
last_commit_sha: string;
name: string;
path: string;
sha: string;
size: number;
submodule_git_url?: string | null;
target?: string | null;
type: string;
url: string;
};
type FileCommitResponse = {
author: CommitUser;
committer: CommitUser;
created: string;
html_url: string;
message: string;
parents: Array<CommitMeta>;
sha: string;
tree: CommitMeta;
url: string;
};
export type FilesResponse = {
commit: FileCommitResponse;
content: Array<ContentsResponse>;
verification: PayloadCommitVerification;
};

View File

@@ -0,0 +1,3 @@
const { getConfig } = require('../../scripts/webpack.js');
module.exports = getConfig();