decap-cms-backend-forgejo 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/API.ts ADDED
@@ -0,0 +1,1054 @@
1
+ import { Base64 } from 'js-base64';
2
+ import trimStart from 'lodash/trimStart';
3
+ import trim from 'lodash/trim';
4
+ import result from 'lodash/result';
5
+ import partial from 'lodash/partial';
6
+ import {
7
+ APIError,
8
+ basename,
9
+ branchFromContentKey,
10
+ CMS_BRANCH_PREFIX,
11
+ DEFAULT_PR_BODY,
12
+ EditorialWorkflowError,
13
+ generateContentKey,
14
+ getAllResponses,
15
+ isCMSLabel,
16
+ labelToStatus,
17
+ localForage,
18
+ MERGE_COMMIT_MESSAGE,
19
+ parseContentKey,
20
+ readFileMetadata,
21
+ requestWithBackoff,
22
+ statusToLabel,
23
+ unsentRequest,
24
+ } from 'decap-cms-lib-util';
25
+
26
+ import type {
27
+ ApiRequest,
28
+ AssetProxy,
29
+ DataFile,
30
+ FetchError,
31
+ PersistOptions,
32
+ } from 'decap-cms-lib-util';
33
+ import type { Semaphore } from 'semaphore';
34
+ import type {
35
+ FilesResponse,
36
+ ForgejoBranch,
37
+ ForgejoChangedFile,
38
+ ForgejoCompareResponse,
39
+ ForgejoLabel,
40
+ ForgejoPullRequest,
41
+ ForgejoRepository,
42
+ ForgejoUser,
43
+ GitGetBlobResponse,
44
+ GitGetTreeResponse,
45
+ ReposListCommitsResponse,
46
+ } from './types';
47
+
48
+ export const API_NAME = 'Forgejo';
49
+
50
+ export const MOCK_PULL_REQUEST = -1;
51
+
52
+ export interface Config {
53
+ apiRoot?: string;
54
+ token?: string;
55
+ branch?: string;
56
+ repo?: string;
57
+ originRepo?: string;
58
+ useOpenAuthoring?: boolean;
59
+ cmsLabelPrefix?: string;
60
+ initialWorkflowStatus?: string;
61
+ }
62
+
63
+ enum FileOperation {
64
+ CREATE = 'create',
65
+ DELETE = 'delete',
66
+ UPDATE = 'update',
67
+ }
68
+
69
+ export interface ChangeFileOperation {
70
+ content?: string;
71
+ from_path?: string;
72
+ path: string;
73
+ operation: FileOperation;
74
+ sha?: string;
75
+ }
76
+
77
+ interface MetaDataObjects {
78
+ entry: { path: string; sha: string };
79
+ files: MediaFile[];
80
+ }
81
+
82
+ export interface Metadata {
83
+ type: string;
84
+ objects: MetaDataObjects;
85
+ branch: string;
86
+ status: string;
87
+ collection: string;
88
+ commitMessage: string;
89
+ version?: string;
90
+ user: string;
91
+ title?: string;
92
+ description?: string;
93
+ timeStamp: string;
94
+ }
95
+
96
+ export interface BlobArgs {
97
+ sha: string;
98
+ repoURL: string;
99
+ parseText: boolean;
100
+ }
101
+
102
+ type Param = string | number | undefined;
103
+
104
+ export type Options = RequestInit & {
105
+ params?: Record<string, Param | Record<string, Param> | string[]>;
106
+ };
107
+
108
+ type MediaFile = {
109
+ sha: string;
110
+ path: string;
111
+ };
112
+
113
+ export default class API {
114
+ apiRoot: string;
115
+ token: string;
116
+ branch: string;
117
+ repo: string;
118
+ originRepo: string;
119
+ repoOwner: string;
120
+ repoName: string;
121
+ originRepoOwner: string;
122
+ originRepoName: string;
123
+ repoURL: string;
124
+ originRepoURL: string;
125
+ useOpenAuthoring: boolean;
126
+ cmsLabelPrefix: string;
127
+ initialWorkflowStatus: string;
128
+
129
+ _userPromise?: Promise<ForgejoUser>;
130
+ _metadataSemaphore?: Semaphore;
131
+
132
+ commitAuthor?: {};
133
+
134
+ constructor(config: Config) {
135
+ if (!config.apiRoot) {
136
+ throw new Error('API root is required');
137
+ }
138
+
139
+ this.apiRoot = config.apiRoot;
140
+ this.token = config.token || '';
141
+ this.branch = config.branch || 'master';
142
+ this.repo = config.repo || '';
143
+ this.originRepo = config.originRepo || this.repo;
144
+ this.useOpenAuthoring = !!config.useOpenAuthoring;
145
+ this.cmsLabelPrefix = config.cmsLabelPrefix || '';
146
+ this.initialWorkflowStatus = config.initialWorkflowStatus || 'draft';
147
+ this.repoURL = `/repos/${this.repo}`;
148
+ this.originRepoURL = `/repos/${this.originRepo}`;
149
+
150
+ const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
151
+ this.repoOwner = repoParts[0];
152
+ this.repoName = repoParts[1];
153
+
154
+ this.originRepoOwner = originRepoParts[0];
155
+ this.originRepoName = originRepoParts[1];
156
+ }
157
+
158
+ static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
159
+
160
+ user(): Promise<{ full_name: string; login: string; avatar_url: string; email: string }> {
161
+ if (!this._userPromise) {
162
+ this._userPromise = this.getUser();
163
+ }
164
+ return this._userPromise;
165
+ }
166
+
167
+ getUser() {
168
+ return this.request('/user') as Promise<ForgejoUser>;
169
+ }
170
+
171
+ async hasWriteAccess() {
172
+ try {
173
+ const result: ForgejoRepository = await this.request(this.repoURL);
174
+ // update config repoOwner to avoid case sensitivity issues with Forgejo
175
+ this.repoOwner = result.owner.login;
176
+ return result.permissions.push;
177
+ } catch (error) {
178
+ console.error('Problem fetching repo data from Forgejo');
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ reset() {
184
+ // no op
185
+ }
186
+
187
+ requestHeaders(headers = {}) {
188
+ const baseHeader: Record<string, string> = {
189
+ 'Content-Type': 'application/json; charset=utf-8',
190
+ ...headers,
191
+ };
192
+
193
+ if (this.token) {
194
+ baseHeader.Authorization = `token ${this.token}`;
195
+ return Promise.resolve(baseHeader);
196
+ }
197
+
198
+ return Promise.resolve(baseHeader);
199
+ }
200
+
201
+ async parseJsonResponse(response: Response) {
202
+ const json = await response.json();
203
+ if (!response.ok) {
204
+ return Promise.reject(json);
205
+ }
206
+ return json;
207
+ }
208
+
209
+ urlFor(path: string, options: Options) {
210
+ const params = [];
211
+ if (options.params) {
212
+ for (const key in options.params) {
213
+ params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
214
+ }
215
+ }
216
+ if (params.length) {
217
+ path += `?${params.join('&')}`;
218
+ }
219
+ return this.apiRoot + path;
220
+ }
221
+
222
+ parseResponse(response: Response) {
223
+ const contentType = response.headers.get('Content-Type');
224
+ if (contentType && contentType.match(/json/)) {
225
+ return this.parseJsonResponse(response);
226
+ }
227
+ const textPromise = response.text().then(text => {
228
+ if (!response.ok) {
229
+ return Promise.reject(text);
230
+ }
231
+ return text;
232
+ });
233
+ return textPromise;
234
+ }
235
+
236
+ handleRequestError(error: FetchError, responseStatus: number) {
237
+ throw new APIError(error.message, responseStatus, API_NAME);
238
+ }
239
+
240
+ buildRequest(req: ApiRequest) {
241
+ return req;
242
+ }
243
+
244
+ async request(
245
+ path: string,
246
+ options: Options = {},
247
+ parser = (response: Response) => this.parseResponse(response),
248
+ ) {
249
+ options = { cache: 'no-cache', ...options };
250
+ const headers = await this.requestHeaders(options.headers || {});
251
+ const url = this.urlFor(path, options);
252
+ let responseStatus = 500;
253
+
254
+ try {
255
+ const req = unsentRequest.fromFetchArguments(url, {
256
+ ...options,
257
+ headers,
258
+ }) as unknown as ApiRequest;
259
+ const response = await requestWithBackoff(this, req);
260
+ responseStatus = response.status;
261
+ const parsedResponse = await parser(response);
262
+ return parsedResponse;
263
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
+ } catch (error: any) {
265
+ return this.handleRequestError(error, responseStatus);
266
+ }
267
+ }
268
+
269
+ nextUrlProcessor() {
270
+ return (url: string) => url;
271
+ }
272
+
273
+ async requestAllPages<T>(url: string, options: Options = {}) {
274
+ options = { cache: 'no-cache', ...options };
275
+ const headers = await this.requestHeaders(options.headers || {});
276
+ const processedURL = this.urlFor(url, options);
277
+ const allResponses = await getAllResponses(
278
+ processedURL,
279
+ { ...options, headers },
280
+ 'next',
281
+ this.nextUrlProcessor(),
282
+ );
283
+ const pages: T[][] = await Promise.all(
284
+ allResponses.map((res: Response) => this.parseResponse(res)),
285
+ );
286
+ return ([] as T[]).concat(...pages);
287
+ }
288
+
289
+ generateContentKey(collectionName: string, slug: string) {
290
+ const contentKey = generateContentKey(collectionName, slug);
291
+ if (!this.useOpenAuthoring) {
292
+ return contentKey;
293
+ }
294
+ return `${this.repo}/${contentKey}`;
295
+ }
296
+
297
+ parseContentKey(contentKey: string) {
298
+ if (!this.useOpenAuthoring) {
299
+ return parseContentKey(contentKey);
300
+ }
301
+
302
+ const repoPrefix = `${this.repo}/`;
303
+ // Some content keys may be prefixed with the origin repo instead of the fork repo.
304
+ const originRepoPrefix = this.originRepo ? `${this.originRepo}/` : null;
305
+
306
+ let keyToParse = contentKey;
307
+
308
+ if (contentKey.startsWith(repoPrefix)) {
309
+ keyToParse = contentKey.slice(repoPrefix.length);
310
+ } else if (originRepoPrefix && contentKey.startsWith(originRepoPrefix)) {
311
+ keyToParse = contentKey.slice(originRepoPrefix.length);
312
+ }
313
+
314
+ return parseContentKey(keyToParse);
315
+ }
316
+
317
+ async readFile(
318
+ path: string,
319
+ sha?: string | null,
320
+ {
321
+ branch = this.branch,
322
+ repoURL = this.repoURL,
323
+ parseText = true,
324
+ }: {
325
+ branch?: string;
326
+ repoURL?: string;
327
+ parseText?: boolean;
328
+ } = {},
329
+ ) {
330
+ if (!sha) {
331
+ sha = await this.getFileSha(path, { repoURL, branch });
332
+ }
333
+ const content = await this.fetchBlobContent({
334
+ sha: sha as string,
335
+ repoURL,
336
+ parseText,
337
+ });
338
+ return content;
339
+ }
340
+
341
+ async readFileMetadata(path: string, sha: string | null | undefined) {
342
+ const fetchFileMetadata = async () => {
343
+ try {
344
+ const result: ReposListCommitsResponse = await this.request(
345
+ `${this.originRepoURL}/commits`,
346
+ {
347
+ params: { path, sha: this.branch, stat: 'false' },
348
+ },
349
+ );
350
+ const { commit } = result[0];
351
+ return {
352
+ author: commit.author.name || commit.author.email,
353
+ updatedOn: commit.author.date,
354
+ };
355
+ } catch (e) {
356
+ return { author: '', updatedOn: '' };
357
+ }
358
+ };
359
+ const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
360
+ return fileMetadata;
361
+ }
362
+
363
+ async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
364
+ const result: GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, {
365
+ cache: 'force-cache',
366
+ });
367
+
368
+ if (parseText) {
369
+ // treat content as a utf-8 string
370
+ const content = Base64.decode(result.content);
371
+ return content;
372
+ } else {
373
+ // treat content as binary and convert to blob
374
+ const content = Base64.atob(result.content);
375
+ const byteArray = new Uint8Array(content.length);
376
+ for (let i = 0; i < content.length; i++) {
377
+ byteArray[i] = content.charCodeAt(i);
378
+ }
379
+ const blob = new Blob([byteArray]);
380
+ return blob;
381
+ }
382
+ }
383
+
384
+ async listFiles(
385
+ path: string,
386
+ { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
387
+ folderSupport?: boolean,
388
+ ): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
389
+ const folder = trim(path, '/');
390
+ const hasFolder = Boolean(folder);
391
+ try {
392
+ const branchInfo = (await this.request(
393
+ `${repoURL}/branches/${encodeURIComponent(branch)}`,
394
+ )) as ForgejoBranch;
395
+ const treeSha = branchInfo.commit.id;
396
+ const useRecursive = depth > 1 || hasFolder;
397
+ const result: GitGetTreeResponse = await this.request(
398
+ `${repoURL}/git/trees/${encodeURIComponent(treeSha)}`,
399
+ {
400
+ // Use recursive tree when we need to filter by folder or deeper depth.
401
+ params: useRecursive ? { recursive: 1 } : {},
402
+ },
403
+ );
404
+ return (
405
+ result.tree
406
+ // filter only files and/or folders up to the required depth
407
+ .filter(file => {
408
+ if ((!folderSupport ? file.type === 'blob' : true) && file.path) {
409
+ if (!hasFolder) {
410
+ return file.path.split('/').length <= depth;
411
+ }
412
+
413
+ if (!file.path.startsWith(`${folder}/`)) {
414
+ return false;
415
+ }
416
+
417
+ const relativePath = file.path.slice(folder.length + 1);
418
+ if (!relativePath) {
419
+ return false;
420
+ }
421
+ return relativePath.split('/').length <= depth;
422
+ }
423
+ return false;
424
+ })
425
+ .map(file => {
426
+ const relativePath =
427
+ hasFolder && file.path.startsWith(`${folder}/`)
428
+ ? file.path.slice(folder.length + 1)
429
+ : file.path;
430
+ return {
431
+ type: file.type,
432
+ id: file.sha,
433
+ name: basename(file.path),
434
+ path: hasFolder ? `${folder}/${relativePath}` : file.path,
435
+ size: file.size!,
436
+ };
437
+ })
438
+ );
439
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
440
+ } catch (err: any) {
441
+ if (err && err.status === 404) {
442
+ console.info('[StaticCMS] This 404 was expected and handled appropriately.');
443
+ return [];
444
+ } else {
445
+ throw err;
446
+ }
447
+ }
448
+ }
449
+
450
+ async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
451
+ const files: (DataFile | AssetProxy)[] = [...mediaFiles, ...dataFiles];
452
+ const operations = await this.getChangeFileOperations(files, this.branch);
453
+ return this.changeFiles(operations, options);
454
+ }
455
+
456
+ async changeFiles(operations: ChangeFileOperation[], options: PersistOptions) {
457
+ return (await this.request(`${this.repoURL}/contents`, {
458
+ method: 'POST',
459
+ body: JSON.stringify({
460
+ branch: this.branch,
461
+ files: operations,
462
+ message: options.commitMessage,
463
+ }),
464
+ })) as FilesResponse;
465
+ }
466
+
467
+ async getChangeFileOperations(files: (DataFile | AssetProxy)[], branch: string) {
468
+ const items: ChangeFileOperation[] = await Promise.all(
469
+ files.map(async file => {
470
+ const content = await result(
471
+ file,
472
+ 'toBase64',
473
+ partial(this.toBase64, (file as DataFile).raw),
474
+ );
475
+ let sha;
476
+ let operation;
477
+ let from_path;
478
+ let path = trimStart(file.path, '/');
479
+ try {
480
+ sha = await this.getFileSha(file.path, { branch });
481
+ operation = FileOperation.UPDATE;
482
+ const newPath = 'newPath' in file ? (file as DataFile).newPath : undefined;
483
+ from_path = newPath && path;
484
+ path = newPath ? trimStart(newPath, '/') : path;
485
+ } catch {
486
+ sha = undefined;
487
+ operation = FileOperation.CREATE;
488
+ }
489
+
490
+ return {
491
+ operation,
492
+ content,
493
+ path,
494
+ from_path,
495
+ sha,
496
+ } as ChangeFileOperation;
497
+ }),
498
+ );
499
+ return items;
500
+ }
501
+
502
+ async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
503
+ // Normalize path by removing leading slash if present
504
+ const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
505
+ const encodedPath = normalizedPath
506
+ .split('/')
507
+ .map(segment => encodeURIComponent(segment))
508
+ .join('/');
509
+ const result = (await this.request(`${repoURL}/contents/${encodedPath}`, {
510
+ params: { ref: branch },
511
+ })) as { sha?: string };
512
+
513
+ if (result?.sha) {
514
+ return result.sha;
515
+ }
516
+
517
+ throw new APIError('Not Found', 404, API_NAME);
518
+ }
519
+
520
+ async deleteFiles(paths: string[], message: string) {
521
+ if (this.useOpenAuthoring) {
522
+ throw new APIError(
523
+ 'Cannot delete published entries as an Open Authoring user!',
524
+ 403,
525
+ API_NAME,
526
+ );
527
+ }
528
+
529
+ const operations: ChangeFileOperation[] = await Promise.all(
530
+ paths.map(async path => {
531
+ const sha = await this.getFileSha(path);
532
+
533
+ return {
534
+ operation: FileOperation.DELETE,
535
+ path,
536
+ sha,
537
+ } as ChangeFileOperation;
538
+ }),
539
+ );
540
+ return this.changeFiles(operations, { commitMessage: message });
541
+ }
542
+
543
+ toBase64(str: string) {
544
+ return Promise.resolve(Base64.encode(str));
545
+ }
546
+
547
+ async getBranch(branchName: string): Promise<ForgejoBranch> {
548
+ return this.request(`${this.repoURL}/branches/${encodeURIComponent(branchName)}`);
549
+ }
550
+
551
+ async getDefaultBranch(): Promise<ForgejoBranch> {
552
+ return this.getBranch(this.branch);
553
+ }
554
+
555
+ async createBranch(
556
+ branchName: string,
557
+ oldBranchName: string = this.branch,
558
+ ): Promise<ForgejoBranch> {
559
+ return this.request(`${this.repoURL}/branches`, {
560
+ method: 'POST',
561
+ body: JSON.stringify({
562
+ new_branch_name: branchName,
563
+ old_ref_name: oldBranchName,
564
+ }),
565
+ });
566
+ }
567
+
568
+ async deleteBranch(branchName: string): Promise<void> {
569
+ await this.request(`${this.repoURL}/branches/${encodeURIComponent(branchName)}`, {
570
+ method: 'DELETE',
571
+ });
572
+ }
573
+
574
+ async getPullRequests(
575
+ state: 'open' | 'closed' | 'all' = 'open',
576
+ head?: string,
577
+ ): Promise<ForgejoPullRequest[]> {
578
+ const pullRequests = await this.requestAllPages<ForgejoPullRequest>(
579
+ `${this.originRepoURL}/pulls`,
580
+ {
581
+ params: { state, base: this.branch, limit: 100 },
582
+ },
583
+ );
584
+ if (!head) {
585
+ return pullRequests;
586
+ }
587
+
588
+ return pullRequests.filter(pr => {
589
+ const label = pr.head?.label;
590
+ if (label) {
591
+ return label === head;
592
+ }
593
+ const repoOwner = pr.head?.repo?.owner?.login;
594
+ const ref = pr.head?.ref;
595
+ if (repoOwner && ref) {
596
+ return `${repoOwner}:${ref}` === head;
597
+ }
598
+ return false;
599
+ });
600
+ }
601
+
602
+ async getOpenAuthoringPullRequest(
603
+ branch: string,
604
+ pullRequests: ForgejoPullRequest[],
605
+ ): Promise<{ pullRequest: ForgejoPullRequest; branch: ForgejoBranch }> {
606
+ // we can't use labels when using open authoring
607
+ // since the contributor doesn't have access to set labels
608
+ // a branch without a pr (or a closed pr) means a 'draft' entry
609
+ // a branch with an opened pr means a 'pending_review' entry
610
+ const data = await this.getBranch(branch).catch(() => {
611
+ throw new EditorialWorkflowError('content is not under editorial workflow', true);
612
+ });
613
+ // since we get all (open and closed) pull requests by branch name, make sure to filter by head sha
614
+ const pullRequest = pullRequests.filter(pr => pr.head.sha === data.commit.id)[0];
615
+ if (!pullRequest) {
616
+ // if no pull request is found for the branch we return a mocked one
617
+ const mockPR: ForgejoPullRequest = {
618
+ number: MOCK_PULL_REQUEST,
619
+ state: 'open',
620
+ labels: [
621
+ {
622
+ name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix),
623
+ } as ForgejoLabel,
624
+ ],
625
+ head: { ref: branch, sha: data.commit.id },
626
+ };
627
+ return {
628
+ pullRequest: mockPR,
629
+ branch: data,
630
+ };
631
+ }
632
+
633
+ // Filter out CMS labels for open authoring
634
+ const nonCmsLabels = pullRequest.labels.filter(l => !isCMSLabel(l.name, this.cmsLabelPrefix));
635
+
636
+ // Add synthetic CMS label based on PR state
637
+ const cmsLabel =
638
+ pullRequest.state === 'closed'
639
+ ? { name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }
640
+ : { name: statusToLabel('pending_review', this.cmsLabelPrefix) };
641
+
642
+ const updatedPullRequest: ForgejoPullRequest = {
643
+ ...pullRequest,
644
+ labels: [...nonCmsLabels, cmsLabel as ForgejoLabel],
645
+ };
646
+
647
+ return { pullRequest: updatedPullRequest, branch: data };
648
+ }
649
+
650
+ async getBranchPullRequest(branchName: string): Promise<ForgejoPullRequest> {
651
+ if (this.useOpenAuthoring) {
652
+ const headRef = await this.getHeadReference(branchName);
653
+ const pullRequests = await this.getPullRequests('all', headRef);
654
+ const result = await this.getOpenAuthoringPullRequest(branchName, pullRequests);
655
+ return result.pullRequest;
656
+ }
657
+
658
+ const pullRequests = await this.getPullRequests('open', `${this.repoOwner}:${branchName}`);
659
+ const cmsPullRequests = pullRequests.filter(pr =>
660
+ pr.labels.some(l => isCMSLabel(l.name, this.cmsLabelPrefix)),
661
+ );
662
+ if (cmsPullRequests.length > 0) {
663
+ return cmsPullRequests[0];
664
+ }
665
+ throw new EditorialWorkflowError('content is not under editorial workflow', true);
666
+ }
667
+
668
+ async getHeadReference(head: string) {
669
+ return `${this.repoOwner}:${head}`;
670
+ }
671
+
672
+ async createPR(
673
+ title: string,
674
+ head: string,
675
+ body: string = DEFAULT_PR_BODY,
676
+ ): Promise<ForgejoPullRequest> {
677
+ return this.request(`${this.originRepoURL}/pulls`, {
678
+ method: 'POST',
679
+ body: JSON.stringify({
680
+ title,
681
+ head: await this.getHeadReference(head),
682
+ base: this.branch,
683
+ body,
684
+ }),
685
+ });
686
+ }
687
+
688
+ async updatePR(number: number, state: 'open' | 'closed'): Promise<ForgejoPullRequest> {
689
+ return this.request(`${this.originRepoURL}/pulls/${number}`, {
690
+ method: 'PATCH',
691
+ body: JSON.stringify({ state }),
692
+ });
693
+ }
694
+
695
+ async closePR(number: number): Promise<ForgejoPullRequest> {
696
+ return this.updatePR(number, 'closed');
697
+ }
698
+
699
+ async mergePR(pullRequest: ForgejoPullRequest): Promise<void> {
700
+ await this.request(`${this.originRepoURL}/pulls/${pullRequest.number}/merge`, {
701
+ method: 'POST',
702
+ body: JSON.stringify({
703
+ Do: 'merge',
704
+ MergeMessageField: MERGE_COMMIT_MESSAGE,
705
+ }),
706
+ });
707
+ }
708
+
709
+ async getPullRequestFiles(number: number): Promise<ForgejoChangedFile[]> {
710
+ if (number === MOCK_PULL_REQUEST) {
711
+ return [];
712
+ }
713
+ return this.request(`${this.originRepoURL}/pulls/${number}/files`);
714
+ }
715
+
716
+ async getDifferences(from: string, to: string): Promise<ForgejoCompareResponse> {
717
+ // For OA, try the fork repo first, then fall back to origin
718
+ const repoURL = this.useOpenAuthoring ? this.repoURL : this.originRepoURL;
719
+ try {
720
+ return await this.request(
721
+ `${repoURL}/compare/${encodeURIComponent(from)}...${encodeURIComponent(to)}`,
722
+ );
723
+ } catch (e) {
724
+ if (this.useOpenAuthoring) {
725
+ // Retry with origin repo
726
+ return this.request(
727
+ `${this.originRepoURL}/compare/${encodeURIComponent(from)}...${encodeURIComponent(to)}`,
728
+ );
729
+ }
730
+ throw e;
731
+ }
732
+ }
733
+
734
+ async updatePullRequestLabels(number: number, labels: number[]): Promise<ForgejoLabel[]> {
735
+ return this.request(`${this.originRepoURL}/issues/${number}/labels`, {
736
+ method: 'PUT',
737
+ body: JSON.stringify({ labels }),
738
+ });
739
+ }
740
+
741
+ async getLabels(): Promise<ForgejoLabel[]> {
742
+ return this.requestAllPages<ForgejoLabel>(`${this.originRepoURL}/labels`, {
743
+ params: { limit: 100 },
744
+ });
745
+ }
746
+
747
+ async createLabel(name: string, color = '0052cc'): Promise<ForgejoLabel> {
748
+ return this.request(`${this.originRepoURL}/labels`, {
749
+ method: 'POST',
750
+ body: JSON.stringify({ name, color }),
751
+ });
752
+ }
753
+
754
+ async getOrCreateLabel(name: string): Promise<ForgejoLabel> {
755
+ const labels = await this.getLabels();
756
+ const existing = labels.find(l => l.name === name);
757
+ if (existing) {
758
+ return existing;
759
+ }
760
+ return this.createLabel(name);
761
+ }
762
+
763
+ async setPullRequestStatus(pullRequest: ForgejoPullRequest, status: string): Promise<void> {
764
+ // Skip label updates for open authoring as contributors don't have permission
765
+ // Also skip for mock PRs (no real PR exists yet)
766
+ if (this.useOpenAuthoring || pullRequest.number === MOCK_PULL_REQUEST) {
767
+ return;
768
+ }
769
+
770
+ const newLabel = statusToLabel(status, this.cmsLabelPrefix);
771
+
772
+ // Get or create the new status label
773
+ const label = await this.getOrCreateLabel(newLabel);
774
+
775
+ if (typeof label.id !== 'number') {
776
+ throw new Error(
777
+ `Status label "${label.name}" returned from getOrCreateLabel is missing a numeric id`,
778
+ );
779
+ }
780
+
781
+ // Get current labels and filter out old CMS labels and labels without ids
782
+ const currentLabels = pullRequest.labels
783
+ .filter(l => !isCMSLabel(l.name, this.cmsLabelPrefix))
784
+ .filter(l => typeof l.id === 'number')
785
+ .map(l => l.id as number);
786
+
787
+ // Add the new status label
788
+ await this.updatePullRequestLabels(pullRequest.number, [...currentLabels, label.id]);
789
+ }
790
+
791
+ async getOpenAuthoringBranches(): Promise<ForgejoBranch[]> {
792
+ const branches: ForgejoBranch[] = await this.requestAllPages(`${this.repoURL}/branches`);
793
+ const prefix = `${CMS_BRANCH_PREFIX}/${this.repo}/`;
794
+ return branches.filter(b => b.name.startsWith(prefix));
795
+ }
796
+
797
+ filterOpenAuthoringBranches = async (branch: string) => {
798
+ try {
799
+ const pullRequest = await this.getBranchPullRequest(branch);
800
+ const { state: currentState, merged_at: mergedAt } = pullRequest as ForgejoPullRequest;
801
+ if (pullRequest.number !== MOCK_PULL_REQUEST && currentState === 'closed' && mergedAt) {
802
+ // PR was merged, delete the branch
803
+ await this.deleteBranch(branch);
804
+ return { branch, filter: false };
805
+ } else {
806
+ return { branch, filter: true };
807
+ }
808
+ } catch (e) {
809
+ // Only filter out branches for expected "not found / not under workflow" errors.
810
+ // For other errors (e.g. transient network/API issues), keep the branch.
811
+ if (e instanceof APIError && e.status === 404) {
812
+ return { branch, filter: false };
813
+ }
814
+ if (e instanceof EditorialWorkflowError) {
815
+ return { branch, filter: false };
816
+ }
817
+ return { branch, filter: true };
818
+ }
819
+ };
820
+
821
+ async listUnpublishedBranches(): Promise<string[]> {
822
+ if (this.useOpenAuthoring) {
823
+ // OA branches can exist without a PR
824
+ const cmsBranches = await this.getOpenAuthoringBranches();
825
+ let branches = cmsBranches.map(b => b.name);
826
+ const branchesWithFilter = await Promise.all(
827
+ branches.map(b => this.filterOpenAuthoringBranches(b)),
828
+ );
829
+ branches = branchesWithFilter.filter(b => b.filter).map(b => b.branch);
830
+ return branches;
831
+ }
832
+
833
+ // Standard mode: filter PRs by CMS labels
834
+ const pullRequests = await this.getPullRequests('open');
835
+ const cmsBranches = pullRequests
836
+ .filter(
837
+ pr =>
838
+ pr.head.ref.startsWith(`${CMS_BRANCH_PREFIX}/`) &&
839
+ pr.labels.some(l => isCMSLabel(l.name, this.cmsLabelPrefix)),
840
+ )
841
+ .map(pr => pr.head.ref);
842
+
843
+ return cmsBranches;
844
+ }
845
+
846
+ async retrieveUnpublishedEntryData(contentKey: string) {
847
+ const branch = branchFromContentKey(contentKey);
848
+ let pullRequest: ForgejoPullRequest;
849
+ let branchData: ForgejoBranch | null = null;
850
+
851
+ if (this.useOpenAuthoring) {
852
+ const headRef = await this.getHeadReference(branch);
853
+ const pullRequests = await this.getPullRequests('all', headRef);
854
+ const openAuthoringResult = await this.getOpenAuthoringPullRequest(branch, pullRequests);
855
+ pullRequest = openAuthoringResult.pullRequest;
856
+ branchData = openAuthoringResult.branch;
857
+ } else {
858
+ pullRequest = await this.getBranchPullRequest(branch);
859
+ }
860
+
861
+ // Try getDifferences first (provides SHAs), fall back to getPullRequestFiles
862
+ let diffs: { path: string; newFile: boolean; id: string }[];
863
+ try {
864
+ const headRef = await this.getHeadReference(branch);
865
+ const compareResult = await this.getDifferences(this.branch, headRef);
866
+ diffs = compareResult.files.map(file => ({
867
+ path: file.filename,
868
+ newFile: file.status === 'added',
869
+ id: file.sha || '',
870
+ }));
871
+ } catch (e) {
872
+ const files = await this.getPullRequestFiles(pullRequest.number);
873
+ diffs = files.map(file => ({
874
+ path: file.filename,
875
+ newFile: file.status === 'added',
876
+ id: '',
877
+ }));
878
+ }
879
+
880
+ // Both OA and standard PRs now have synthetic CMS labels, so use unified label-based lookup
881
+ const statusLabel = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix));
882
+ const status = statusLabel
883
+ ? labelToStatus(statusLabel.name, this.cmsLabelPrefix)
884
+ : this.initialWorkflowStatus;
885
+
886
+ const { collection, slug } = this.parseContentKey(contentKey);
887
+
888
+ return {
889
+ collection,
890
+ slug,
891
+ status,
892
+ diffs,
893
+ updatedAt:
894
+ pullRequest?.updated_at ||
895
+ branchData?.commit?.author?.date ||
896
+ branchData?.commit?.committer?.date ||
897
+ new Date().toISOString(),
898
+ pullRequestAuthor: pullRequest?.user?.login || branchData?.commit?.author?.name || 'Unknown',
899
+ };
900
+ }
901
+
902
+ async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
903
+ const contentKey = this.generateContentKey(collection, slug);
904
+ const branch = branchFromContentKey(contentKey);
905
+ const pullRequest = await this.getBranchPullRequest(branch);
906
+
907
+ if (!this.useOpenAuthoring) {
908
+ await this.setPullRequestStatus(pullRequest, newStatus);
909
+ return;
910
+ }
911
+
912
+ // Open authoring path
913
+ if (newStatus === 'pending_publish') {
914
+ throw new Error('Open Authoring entries may not be set to the status "pending_publish".');
915
+ }
916
+
917
+ if (pullRequest.number !== MOCK_PULL_REQUEST) {
918
+ const { state } = pullRequest;
919
+ if (state === 'open' && newStatus === 'draft') {
920
+ await this.closePR(pullRequest.number);
921
+ }
922
+ if (state === 'closed' && newStatus === 'pending_review') {
923
+ await this.updatePR(pullRequest.number, 'open');
924
+ }
925
+ } else if (newStatus === 'pending_review') {
926
+ // Mock PR: create a real PR
927
+ const diff = await this.getDifferences(this.branch, await this.getHeadReference(branch));
928
+ const title = diff.commits[0]?.commit?.message || API.DEFAULT_COMMIT_MESSAGE;
929
+ await this.createPR(title, branch);
930
+ }
931
+ }
932
+
933
+ async deleteUnpublishedEntry(collection: string, slug: string) {
934
+ const contentKey = this.generateContentKey(collection, slug);
935
+ const branch = branchFromContentKey(contentKey);
936
+
937
+ try {
938
+ const pullRequest = await this.getBranchPullRequest(branch);
939
+ if (pullRequest.number !== MOCK_PULL_REQUEST) {
940
+ await this.closePR(pullRequest.number);
941
+ }
942
+ } catch (e) {
943
+ // Only ignore expected errors (e.g. no PR / not under editorial workflow).
944
+ if (e instanceof EditorialWorkflowError || (e instanceof APIError && e.status === 404)) {
945
+ // PR might not exist or entry is not under editorial workflow; continue to delete branch.
946
+ } else {
947
+ // Unexpected error: rethrow so we don't delete the branch in an unknown state.
948
+ throw e;
949
+ }
950
+ }
951
+
952
+ await this.deleteBranch(branch);
953
+ }
954
+
955
+ async publishUnpublishedEntry(collection: string, slug: string) {
956
+ const contentKey = this.generateContentKey(collection, slug);
957
+ const branch = branchFromContentKey(contentKey);
958
+
959
+ const pullRequest = await this.getBranchPullRequest(branch);
960
+ if (pullRequest.number === MOCK_PULL_REQUEST) {
961
+ throw new APIError('Cannot publish entry without a pull request', 400, API_NAME);
962
+ }
963
+ await this.mergePR(pullRequest);
964
+ await this.deleteBranch(branch);
965
+ }
966
+
967
+ async editorialWorkflowGit(
968
+ files: (DataFile | AssetProxy)[],
969
+ slug: string,
970
+ collection: string,
971
+ options: PersistOptions,
972
+ ) {
973
+ const contentKey = this.generateContentKey(collection, slug);
974
+ const branch = branchFromContentKey(contentKey);
975
+
976
+ let branchExists = false;
977
+ try {
978
+ await this.getBranch(branch);
979
+ branchExists = true;
980
+ } catch (e) {
981
+ // Only treat a 404 "not found" as the branch not existing; rethrow other errors.
982
+ if (!(e instanceof APIError && e.status === 404)) {
983
+ throw e;
984
+ }
985
+ }
986
+
987
+ if (!branchExists) {
988
+ // Create the branch from the default branch
989
+ await this.createBranch(branch, this.branch);
990
+ }
991
+
992
+ // Persist files to the branch
993
+ const operations = await this.getChangeFileOperations(files, branch);
994
+ await this.changeFilesOnBranch(operations, options, branch);
995
+
996
+ // For open authoring, don't create a PR - entries start as branch-only (draft).
997
+ // PRs are created later via updateUnpublishedEntryStatus when moving to pending_review.
998
+ if (!branchExists && !this.useOpenAuthoring) {
999
+ const pr = await this.createPR(options.commitMessage, branch);
1000
+ const status = options.status || this.initialWorkflowStatus;
1001
+ await this.setPullRequestStatus(pr, status);
1002
+ }
1003
+ }
1004
+
1005
+ async changeFilesOnBranch(
1006
+ operations: ChangeFileOperation[],
1007
+ options: PersistOptions,
1008
+ branch: string,
1009
+ ) {
1010
+ return (await this.request(`${this.repoURL}/contents`, {
1011
+ method: 'POST',
1012
+ body: JSON.stringify({
1013
+ branch,
1014
+ files: operations,
1015
+ message: options.commitMessage,
1016
+ }),
1017
+ })) as FilesResponse;
1018
+ }
1019
+
1020
+ // Open Authoring (Fork) Support
1021
+ async forkExists(): Promise<boolean> {
1022
+ try {
1023
+ const repoName = this.originRepo.split('/')[1];
1024
+ const userRepoPath = `/repos/${this.repoOwner}/${repoName}`;
1025
+ const repo = (await this.request(userRepoPath)) as ForgejoRepository;
1026
+
1027
+ // Check if it's a fork and the parent is the origin repo
1028
+ const forkExists: boolean =
1029
+ repo.fork === true &&
1030
+ !!repo.parent &&
1031
+ repo.parent.full_name.toLowerCase() === this.originRepo.toLowerCase();
1032
+ return forkExists;
1033
+ } catch {
1034
+ return false;
1035
+ }
1036
+ }
1037
+
1038
+ async createFork(): Promise<ForgejoRepository> {
1039
+ return this.request(`${this.originRepoURL}/forks`, {
1040
+ method: 'POST',
1041
+ }) as Promise<ForgejoRepository>;
1042
+ }
1043
+
1044
+ async mergeUpstream(): Promise<void> {
1045
+ try {
1046
+ await this.request(`${this.repoURL}/sync_fork`, {
1047
+ method: 'POST',
1048
+ });
1049
+ } catch (error) {
1050
+ // continue without syncing - user will need to sync manually
1051
+ console.warn('Failed to sync fork with upstream:', error);
1052
+ }
1053
+ }
1054
+ }