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