decap-cms-backend-github 2.15.0-beta.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,1468 @@
1
+ import { Base64 } from 'js-base64';
2
+ import semaphore from 'semaphore';
3
+ import { initial, last, partial, result, trimStart, trim } from 'lodash';
4
+ import { oneLine } from 'common-tags';
5
+ import {
6
+ getAllResponses,
7
+ APIError,
8
+ EditorialWorkflowError,
9
+ localForage,
10
+ basename,
11
+ readFileMetadata,
12
+ CMS_BRANCH_PREFIX,
13
+ generateContentKey,
14
+ DEFAULT_PR_BODY,
15
+ MERGE_COMMIT_MESSAGE,
16
+ PreviewState,
17
+ parseContentKey,
18
+ branchFromContentKey,
19
+ isCMSLabel,
20
+ labelToStatus,
21
+ statusToLabel,
22
+ contentKeyFromBranch,
23
+ requestWithBackoff,
24
+ unsentRequest,
25
+ throwOnConflictingBranches,
26
+ } from 'decap-cms-lib-util';
27
+ import { dirname } from 'path';
28
+
29
+ import type {
30
+ AssetProxy,
31
+ DataFile,
32
+ PersistOptions,
33
+ FetchError,
34
+ ApiRequest,
35
+ } from 'decap-cms-lib-util';
36
+ import type { Semaphore } from 'semaphore';
37
+ import type { Octokit } from '@octokit/rest';
38
+
39
+ type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
40
+ type GitCreateTreeParamsTree = Octokit.GitCreateTreeParamsTree;
41
+ type GitHubCompareCommit = Octokit.ReposCompareCommitsResponseCommitsItem;
42
+ type GitHubAuthor = Octokit.GitCreateCommitResponseAuthor;
43
+ type GitHubCommitter = Octokit.GitCreateCommitResponseCommitter;
44
+ type GitHubPull = Octokit.PullsListResponseItem;
45
+
46
+ export const API_NAME = 'GitHub';
47
+
48
+ export const MOCK_PULL_REQUEST = -1;
49
+
50
+ export interface Config {
51
+ apiRoot?: string;
52
+ token?: string;
53
+ branch?: string;
54
+ useOpenAuthoring?: boolean;
55
+ repo?: string;
56
+ originRepo?: string;
57
+ squashMerges: boolean;
58
+ initialWorkflowStatus: string;
59
+ cmsLabelPrefix: string;
60
+ }
61
+
62
+ interface TreeFile {
63
+ type: 'blob' | 'tree';
64
+ sha: string;
65
+ path: string;
66
+ raw?: string;
67
+ }
68
+
69
+ type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
70
+
71
+ type TreeEntry = Override<GitCreateTreeParamsTree, { sha: string | null }>;
72
+
73
+ type GitHubCompareCommits = GitHubCompareCommit[];
74
+
75
+ type GitHubCompareFile = Octokit.ReposCompareCommitsResponseFilesItem & {
76
+ previous_filename?: string;
77
+ };
78
+
79
+ type GitHubCompareFiles = GitHubCompareFile[];
80
+
81
+ enum GitHubCommitStatusState {
82
+ Error = 'error',
83
+ Failure = 'failure',
84
+ Pending = 'pending',
85
+ Success = 'success',
86
+ }
87
+
88
+ export enum PullRequestState {
89
+ Open = 'open',
90
+ Closed = 'closed',
91
+ All = 'all',
92
+ }
93
+
94
+ type GitHubCommitStatus = Octokit.ReposListStatusesForRefResponseItem & {
95
+ state: GitHubCommitStatusState;
96
+ };
97
+
98
+ interface MetaDataObjects {
99
+ entry: { path: string; sha: string };
100
+ files: MediaFile[];
101
+ }
102
+
103
+ export interface Metadata {
104
+ type: string;
105
+ objects: MetaDataObjects;
106
+ branch: string;
107
+ status: string;
108
+ pr?: {
109
+ number: number;
110
+ head: string | { sha: string };
111
+ };
112
+ collection: string;
113
+ commitMessage: string;
114
+ version?: string;
115
+ user: string;
116
+ title?: string;
117
+ description?: string;
118
+ timeStamp: string;
119
+ }
120
+
121
+ export interface BlobArgs {
122
+ sha: string;
123
+ repoURL: string;
124
+ parseText: boolean;
125
+ }
126
+
127
+ type Param = string | number | undefined;
128
+
129
+ type Options = RequestInit & { params?: Record<string, Param | Record<string, Param> | string[]> };
130
+
131
+ type MediaFile = {
132
+ sha: string;
133
+ path: string;
134
+ };
135
+
136
+ function withCmsLabel(pr: GitHubPull, cmsLabelPrefix: string) {
137
+ return pr.labels.some(l => isCMSLabel(l.name, cmsLabelPrefix));
138
+ }
139
+
140
+ function withoutCmsLabel(pr: GitHubPull, cmsLabelPrefix: string) {
141
+ return pr.labels.every(l => !isCMSLabel(l.name, cmsLabelPrefix));
142
+ }
143
+
144
+ function getTreeFiles(files: GitHubCompareFiles) {
145
+ const treeFiles = files.reduce((arr, file) => {
146
+ if (file.status === 'removed') {
147
+ // delete the file
148
+ arr.push({ sha: null, path: file.filename });
149
+ } else if (file.status === 'renamed') {
150
+ // delete the previous file
151
+ arr.push({ sha: null, path: file.previous_filename as string });
152
+ // add the renamed file
153
+ arr.push({ sha: file.sha, path: file.filename });
154
+ } else {
155
+ // add the file
156
+ arr.push({ sha: file.sha, path: file.filename });
157
+ }
158
+ return arr;
159
+ }, [] as { sha: string | null; path: string }[]);
160
+
161
+ return treeFiles;
162
+ }
163
+
164
+ export type Diff = {
165
+ path: string;
166
+ newFile: boolean;
167
+ sha: string;
168
+ binary: boolean;
169
+ };
170
+
171
+ let migrationNotified = false;
172
+
173
+ export default class API {
174
+ apiRoot: string;
175
+ token: string;
176
+ branch: string;
177
+ useOpenAuthoring?: boolean;
178
+ repo: string;
179
+ originRepo: string;
180
+ repoOwner: string;
181
+ repoName: string;
182
+ originRepoOwner: string;
183
+ originRepoName: string;
184
+ repoURL: string;
185
+ originRepoURL: string;
186
+ mergeMethod: string;
187
+ initialWorkflowStatus: string;
188
+ cmsLabelPrefix: string;
189
+
190
+ _userPromise?: Promise<GitHubUser>;
191
+ _metadataSemaphore?: Semaphore;
192
+
193
+ commitAuthor?: {};
194
+
195
+ constructor(config: Config) {
196
+ this.apiRoot = config.apiRoot || 'https://api.github.com';
197
+ this.token = config.token || '';
198
+ this.branch = config.branch || 'master';
199
+ this.useOpenAuthoring = config.useOpenAuthoring;
200
+ this.repo = config.repo || '';
201
+ this.originRepo = config.originRepo || this.repo;
202
+ this.repoURL = `/repos/${this.repo}`;
203
+ // when not in 'useOpenAuthoring' mode originRepoURL === repoURL
204
+ this.originRepoURL = `/repos/${this.originRepo}`;
205
+
206
+ const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
207
+ this.repoOwner = repoParts[0];
208
+ this.repoName = repoParts[1];
209
+
210
+ this.originRepoOwner = originRepoParts[0];
211
+ this.originRepoName = originRepoParts[1];
212
+
213
+ this.mergeMethod = config.squashMerges ? 'squash' : 'merge';
214
+ this.cmsLabelPrefix = config.cmsLabelPrefix;
215
+ this.initialWorkflowStatus = config.initialWorkflowStatus;
216
+ }
217
+
218
+ static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Decap CMS';
219
+
220
+ user(): Promise<{ name: string; login: string }> {
221
+ if (!this._userPromise) {
222
+ this._userPromise = this.getUser();
223
+ }
224
+ return this._userPromise;
225
+ }
226
+
227
+ getUser() {
228
+ return this.request('/user') as Promise<GitHubUser>;
229
+ }
230
+
231
+ async hasWriteAccess() {
232
+ try {
233
+ const result: Octokit.ReposGetResponse = await this.request(this.repoURL);
234
+ // update config repoOwner to avoid case sensitivity issues with GitHub
235
+ this.repoOwner = result.owner.login;
236
+ return result.permissions.push;
237
+ } catch (error) {
238
+ console.error('Problem fetching repo data from GitHub');
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ reset() {
244
+ // no op
245
+ }
246
+
247
+ requestHeaders(headers = {}) {
248
+ const baseHeader: Record<string, string> = {
249
+ 'Content-Type': 'application/json; charset=utf-8',
250
+ ...headers,
251
+ };
252
+
253
+ if (this.token) {
254
+ baseHeader.Authorization = `token ${this.token}`;
255
+ return Promise.resolve(baseHeader);
256
+ }
257
+
258
+ return Promise.resolve(baseHeader);
259
+ }
260
+
261
+ parseJsonResponse(response: Response) {
262
+ return response.json().then(json => {
263
+ if (!response.ok) {
264
+ return Promise.reject(json);
265
+ }
266
+
267
+ return json;
268
+ });
269
+ }
270
+
271
+ urlFor(path: string, options: Options) {
272
+ const params = [];
273
+ if (options.params) {
274
+ for (const key in options.params) {
275
+ params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
276
+ }
277
+ }
278
+ if (params.length) {
279
+ path += `?${params.join('&')}`;
280
+ }
281
+ return this.apiRoot + path;
282
+ }
283
+
284
+ parseResponse(response: Response) {
285
+ const contentType = response.headers.get('Content-Type');
286
+ if (contentType && contentType.match(/json/)) {
287
+ return this.parseJsonResponse(response);
288
+ }
289
+ const textPromise = response.text().then(text => {
290
+ if (!response.ok) {
291
+ return Promise.reject(text);
292
+ }
293
+ return text;
294
+ });
295
+ return textPromise;
296
+ }
297
+
298
+ handleRequestError(error: FetchError, responseStatus: number) {
299
+ throw new APIError(error.message, responseStatus, API_NAME);
300
+ }
301
+
302
+ buildRequest(req: ApiRequest) {
303
+ return req;
304
+ }
305
+
306
+ async request(
307
+ path: string,
308
+ options: Options = {},
309
+ parser = (response: Response) => this.parseResponse(response),
310
+ ) {
311
+ options = { cache: 'no-cache', ...options };
312
+ const headers = await this.requestHeaders(options.headers || {});
313
+ const url = this.urlFor(path, options);
314
+ let responseStatus = 500;
315
+
316
+ try {
317
+ const req = unsentRequest.fromFetchArguments(url, {
318
+ ...options,
319
+ headers,
320
+ }) as unknown as ApiRequest;
321
+ const response = await requestWithBackoff(this, req);
322
+ responseStatus = response.status;
323
+ const parsedResponse = await parser(response);
324
+ return parsedResponse;
325
+ } catch (error) {
326
+ return this.handleRequestError(error, responseStatus);
327
+ }
328
+ }
329
+
330
+ nextUrlProcessor() {
331
+ return (url: string) => url;
332
+ }
333
+
334
+ async requestAllPages<T>(url: string, options: Options = {}) {
335
+ options = { cache: 'no-cache', ...options };
336
+ const headers = await this.requestHeaders(options.headers || {});
337
+ const processedURL = this.urlFor(url, options);
338
+ const allResponses = await getAllResponses(
339
+ processedURL,
340
+ { ...options, headers },
341
+ 'next',
342
+ this.nextUrlProcessor(),
343
+ );
344
+ const pages: T[][] = await Promise.all(
345
+ allResponses.map((res: Response) => this.parseResponse(res)),
346
+ );
347
+ return ([] as T[]).concat(...pages);
348
+ }
349
+
350
+ generateContentKey(collectionName: string, slug: string) {
351
+ const contentKey = generateContentKey(collectionName, slug);
352
+ if (!this.useOpenAuthoring) {
353
+ return contentKey;
354
+ }
355
+
356
+ return `${this.repo}/${contentKey}`;
357
+ }
358
+
359
+ parseContentKey(contentKey: string) {
360
+ if (!this.useOpenAuthoring) {
361
+ return parseContentKey(contentKey);
362
+ }
363
+
364
+ return parseContentKey(contentKey.slice(this.repo.length + 1));
365
+ }
366
+
367
+ checkMetadataRef() {
368
+ return this.request(`${this.repoURL}/git/refs/meta/_decap_cms`)
369
+ .then(response => response.object)
370
+ .catch(() => {
371
+ // Meta ref doesn't exist
372
+ const readme = {
373
+ raw: '# Decap CMS\n\nThis tree is used by the Decap CMS to store metadata information for specific files and branches.',
374
+ };
375
+
376
+ return this.uploadBlob(readme)
377
+ .then(item =>
378
+ this.request(`${this.repoURL}/git/trees`, {
379
+ method: 'POST',
380
+ body: JSON.stringify({
381
+ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }],
382
+ }),
383
+ }),
384
+ )
385
+ .then(tree => this.commit('First Commit', tree))
386
+ .then(response => this.createRef('meta', '_decap_cms', response.sha))
387
+ .then(response => response.object);
388
+ });
389
+ }
390
+
391
+ async storeMetadata(key: string, data: Metadata) {
392
+ // semaphore ensures metadata updates are always ordered, even if
393
+ // calls to storeMetadata are not. concurrent metadata updates
394
+ // will result in the metadata branch being unable to update.
395
+ if (!this._metadataSemaphore) {
396
+ this._metadataSemaphore = semaphore(1);
397
+ }
398
+ return new Promise<void>((resolve, reject) =>
399
+ this._metadataSemaphore?.take(async () => {
400
+ try {
401
+ const branchData = await this.checkMetadataRef();
402
+ const file = { path: `${key}.json`, raw: JSON.stringify(data) };
403
+
404
+ await this.uploadBlob(file);
405
+ const changeTree = await this.updateTree(branchData.sha, [file as TreeFile]);
406
+ const { sha } = await this.commit(`Updating “${key}” metadata`, changeTree);
407
+ await this.patchRef('meta', '_decap_cms', sha);
408
+ await localForage.setItem(`gh.meta.${key}`, {
409
+ expires: Date.now() + 300000, // In 5 minutes
410
+ data,
411
+ });
412
+ this._metadataSemaphore?.leave();
413
+ resolve();
414
+ } catch (err) {
415
+ reject(err);
416
+ }
417
+ }),
418
+ );
419
+ }
420
+
421
+ deleteMetadata(key: string) {
422
+ if (!this._metadataSemaphore) {
423
+ this._metadataSemaphore = semaphore(1);
424
+ }
425
+ return new Promise<void>(resolve =>
426
+ this._metadataSemaphore?.take(async () => {
427
+ try {
428
+ const branchData = await this.checkMetadataRef();
429
+ const file = { path: `${key}.json`, sha: null };
430
+
431
+ const changeTree = await this.updateTree(branchData.sha, [file]);
432
+ const { sha } = await this.commit(`Deleting “${key}” metadata`, changeTree);
433
+ await this.patchRef('meta', '_decap_cms', sha);
434
+ this._metadataSemaphore?.leave();
435
+ resolve();
436
+ } catch (err) {
437
+ this._metadataSemaphore?.leave();
438
+ resolve();
439
+ }
440
+ }),
441
+ );
442
+ }
443
+
444
+ async retrieveMetadataOld(key: string): Promise<Metadata> {
445
+ console.log(
446
+ '%c Checking for MetaData files',
447
+ 'line-height: 30px;text-align: center;font-weight: bold',
448
+ );
449
+
450
+ const metadataRequestOptions = {
451
+ params: { ref: 'refs/meta/_decap_cms' },
452
+ headers: { Accept: 'application/vnd.github.v3.raw' },
453
+ };
454
+
455
+ function errorHandler(err: Error) {
456
+ if (err.message === 'Not Found') {
457
+ console.log(
458
+ '%c %s does not have metadata',
459
+ 'line-height: 30px;text-align: center;font-weight: bold',
460
+ key,
461
+ );
462
+ }
463
+ throw err;
464
+ }
465
+
466
+ if (!this.useOpenAuthoring) {
467
+ const result = await this.request(
468
+ `${this.repoURL}/contents/${key}.json`,
469
+ metadataRequestOptions,
470
+ )
471
+ .then((response: string) => JSON.parse(response))
472
+ .catch(errorHandler);
473
+
474
+ return result;
475
+ }
476
+
477
+ const [user, repo] = key.split('/');
478
+ const result = this.request(
479
+ `/repos/${user}/${repo}/contents/${key}.json`,
480
+ metadataRequestOptions,
481
+ )
482
+ .then((response: string) => JSON.parse(response))
483
+ .catch(errorHandler);
484
+ return result;
485
+ }
486
+
487
+ async getPullRequests(
488
+ head: string | undefined,
489
+ state: PullRequestState,
490
+ predicate: (pr: GitHubPull) => boolean,
491
+ ) {
492
+ const pullRequests: Octokit.PullsListResponse = await this.requestAllPages(
493
+ `${this.originRepoURL}/pulls`,
494
+ {
495
+ params: {
496
+ ...(head ? { head: await this.getHeadReference(head) } : {}),
497
+ base: this.branch,
498
+ state,
499
+ per_page: 100,
500
+ },
501
+ },
502
+ );
503
+
504
+ return pullRequests.filter(
505
+ pr => pr.head.ref.startsWith(`${CMS_BRANCH_PREFIX}/`) && predicate(pr),
506
+ );
507
+ }
508
+
509
+ async getOpenAuthoringPullRequest(branch: string, pullRequests: GitHubPull[]) {
510
+ // we can't use labels when using open authoring
511
+ // since the contributor doesn't have access to set labels
512
+ // a branch without a pr (or a closed pr) means a 'draft' entry
513
+ // a branch with an opened pr means a 'pending_review' entry
514
+ const data = await this.getBranch(branch).catch(() => {
515
+ throw new EditorialWorkflowError('content is not under editorial workflow', true);
516
+ });
517
+ // since we get all (open and closed) pull requests by branch name, make sure to filter by head sha
518
+ const pullRequest = pullRequests.filter(pr => pr.head.sha === data.commit.sha)[0];
519
+ // if no pull request is found for the branch we return a mocked one
520
+ if (!pullRequest) {
521
+ try {
522
+ return {
523
+ head: { sha: data.commit.sha },
524
+ number: MOCK_PULL_REQUEST,
525
+ labels: [{ name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }],
526
+ state: PullRequestState.Open,
527
+ } as GitHubPull;
528
+ } catch (e) {
529
+ throw new EditorialWorkflowError('content is not under editorial workflow', true);
530
+ }
531
+ } else {
532
+ pullRequest.labels = pullRequest.labels.filter(l => !isCMSLabel(l.name, this.cmsLabelPrefix));
533
+ const cmsLabel =
534
+ pullRequest.state === PullRequestState.Closed
535
+ ? { name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }
536
+ : { name: statusToLabel('pending_review', this.cmsLabelPrefix) };
537
+
538
+ pullRequest.labels.push(cmsLabel as Octokit.PullsGetResponseLabelsItem);
539
+ return pullRequest;
540
+ }
541
+ }
542
+
543
+ async getBranchPullRequest(branch: string) {
544
+ if (this.useOpenAuthoring) {
545
+ const pullRequests = await this.getPullRequests(branch, PullRequestState.All, () => true);
546
+ return this.getOpenAuthoringPullRequest(branch, pullRequests);
547
+ } else {
548
+ const pullRequests = await this.getPullRequests(branch, PullRequestState.Open, pr =>
549
+ withCmsLabel(pr, this.cmsLabelPrefix),
550
+ );
551
+ if (pullRequests.length <= 0) {
552
+ throw new EditorialWorkflowError('content is not under editorial workflow', true);
553
+ }
554
+ return pullRequests[0];
555
+ }
556
+ }
557
+
558
+ async getPullRequestCommits(number: number) {
559
+ if (number === MOCK_PULL_REQUEST) {
560
+ return [];
561
+ }
562
+ try {
563
+ const commits: Octokit.PullsListCommitsResponseItem[] = await this.request(
564
+ `${this.originRepoURL}/pulls/${number}/commits`,
565
+ );
566
+ return commits;
567
+ } catch (e) {
568
+ console.log(e);
569
+ return [];
570
+ }
571
+ }
572
+
573
+ async getPullRequestAuthor(pullRequest: Octokit.PullsListResponseItem) {
574
+ if (!pullRequest.user?.login) {
575
+ return;
576
+ }
577
+
578
+ try {
579
+ const user: GitHubUser = await this.request(`/users/${pullRequest.user.login}`);
580
+ return user.name || user.login;
581
+ } catch {
582
+ return;
583
+ }
584
+ }
585
+
586
+ async retrieveUnpublishedEntryData(contentKey: string) {
587
+ const { collection, slug } = this.parseContentKey(contentKey);
588
+ const branch = branchFromContentKey(contentKey);
589
+ const pullRequest = await this.getBranchPullRequest(branch);
590
+ const [{ files }, pullRequestAuthor] = await Promise.all([
591
+ this.getDifferences(this.branch, pullRequest.head.sha),
592
+ this.getPullRequestAuthor(pullRequest),
593
+ ]);
594
+ const diffs = await Promise.all(files.map(file => this.diffFromFile(file)));
595
+ const label = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix)) as {
596
+ name: string;
597
+ };
598
+ const status = labelToStatus(label.name, this.cmsLabelPrefix);
599
+ const updatedAt = pullRequest.updated_at;
600
+ return {
601
+ collection,
602
+ slug,
603
+ status,
604
+ diffs: diffs.map(d => ({ path: d.path, newFile: d.newFile, id: d.sha })),
605
+ updatedAt,
606
+ pullRequestAuthor,
607
+ };
608
+ }
609
+
610
+ async readFile(
611
+ path: string,
612
+ sha?: string | null,
613
+ {
614
+ branch = this.branch,
615
+ repoURL = this.repoURL,
616
+ parseText = true,
617
+ }: {
618
+ branch?: string;
619
+ repoURL?: string;
620
+ parseText?: boolean;
621
+ } = {},
622
+ ) {
623
+ if (!sha) {
624
+ sha = await this.getFileSha(path, { repoURL, branch });
625
+ }
626
+ const content = await this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
627
+ return content;
628
+ }
629
+
630
+ async readFileMetadata(path: string, sha: string | null | undefined) {
631
+ const fetchFileMetadata = async () => {
632
+ try {
633
+ const result: Octokit.ReposListCommitsResponse = await this.request(
634
+ `${this.originRepoURL}/commits`,
635
+ {
636
+ params: { path, sha: this.branch },
637
+ },
638
+ );
639
+ const { commit } = result[0];
640
+ return {
641
+ author: commit.author.name || commit.author.email,
642
+ updatedOn: commit.author.date,
643
+ };
644
+ } catch (e) {
645
+ return { author: '', updatedOn: '' };
646
+ }
647
+ };
648
+ const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
649
+ return fileMetadata;
650
+ }
651
+
652
+ async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
653
+ const result: Octokit.GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, {
654
+ cache: 'force-cache',
655
+ });
656
+
657
+ if (parseText) {
658
+ // treat content as a utf-8 string
659
+ const content = Base64.decode(result.content);
660
+ return content;
661
+ } else {
662
+ // treat content as binary and convert to blob
663
+ const content = Base64.atob(result.content);
664
+ const byteArray = new Uint8Array(content.length);
665
+ for (let i = 0; i < content.length; i++) {
666
+ byteArray[i] = content.charCodeAt(i);
667
+ }
668
+ const blob = new Blob([byteArray]);
669
+ return blob;
670
+ }
671
+ }
672
+
673
+ async listFiles(
674
+ path: string,
675
+ { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
676
+ ): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
677
+ const folder = trim(path, '/');
678
+ try {
679
+ const result: Octokit.GitGetTreeResponse = await this.request(
680
+ `${repoURL}/git/trees/${branch}:${folder}`,
681
+ {
682
+ // GitHub API supports recursive=1 for getting the entire recursive tree
683
+ // or omitting it to get the non-recursive tree
684
+ params: depth > 1 ? { recursive: 1 } : {},
685
+ },
686
+ );
687
+ return (
688
+ result.tree
689
+ // filter only files and up to the required depth
690
+ .filter(file => file.type === 'blob' && file.path.split('/').length <= depth)
691
+ .map(file => ({
692
+ type: file.type,
693
+ id: file.sha,
694
+ name: basename(file.path),
695
+ path: `${folder}/${file.path}`,
696
+ size: file.size!,
697
+ }))
698
+ );
699
+ } catch (err) {
700
+ if (err && err.status === 404) {
701
+ console.log('This 404 was expected and handled appropriately.');
702
+ return [];
703
+ } else {
704
+ throw err;
705
+ }
706
+ }
707
+ }
708
+
709
+ filterOpenAuthoringBranches = async (branch: string) => {
710
+ try {
711
+ const pullRequest = await this.getBranchPullRequest(branch);
712
+ const { state: currentState, merged_at: mergedAt } = pullRequest;
713
+ if (
714
+ pullRequest.number !== MOCK_PULL_REQUEST &&
715
+ currentState === PullRequestState.Closed &&
716
+ mergedAt
717
+ ) {
718
+ // pr was merged, delete branch
719
+ await this.deleteBranch(branch);
720
+ return { branch, filter: false };
721
+ } else {
722
+ return { branch, filter: true };
723
+ }
724
+ } catch (e) {
725
+ return { branch, filter: false };
726
+ }
727
+ };
728
+
729
+ async migrateToVersion1(pullRequest: GitHubPull, metadata: Metadata) {
730
+ // hard code key/branch generation logic to ignore future changes
731
+ const oldContentKey = pullRequest.head.ref.slice(`cms/`.length);
732
+ const newContentKey = `${metadata.collection}/${oldContentKey}`;
733
+ const newBranchName = `cms/${newContentKey}`;
734
+
735
+ // retrieve or create new branch and pull request in new format
736
+ const branch = await this.getBranch(newBranchName).catch(() => undefined);
737
+ if (!branch) {
738
+ await this.createBranch(newBranchName, pullRequest.head.sha as string);
739
+ }
740
+
741
+ const pr =
742
+ (await this.getPullRequests(newBranchName, PullRequestState.All, () => true))[0] ||
743
+ (await this.createPR(pullRequest.title, newBranchName));
744
+
745
+ // store new metadata
746
+ const newMetadata = {
747
+ ...metadata,
748
+ pr: {
749
+ number: pr.number,
750
+ head: pr.head.sha,
751
+ },
752
+ branch: newBranchName,
753
+ version: '1',
754
+ };
755
+ await this.storeMetadata(newContentKey, newMetadata);
756
+
757
+ // remove old data
758
+ await this.closePR(pullRequest.number);
759
+ await this.deleteBranch(pullRequest.head.ref);
760
+ await this.deleteMetadata(oldContentKey);
761
+
762
+ return { metadata: newMetadata, pullRequest: pr };
763
+ }
764
+
765
+ async migrateToPullRequestLabels(pullRequest: GitHubPull, metadata: Metadata) {
766
+ await this.setPullRequestStatus(pullRequest, metadata.status);
767
+
768
+ const contentKey = pullRequest.head.ref.slice(`cms/`.length);
769
+ await this.deleteMetadata(contentKey);
770
+ }
771
+
772
+ async migratePullRequest(pullRequest: GitHubPull, countMessage: string) {
773
+ const { number } = pullRequest;
774
+ console.log(`Migrating Pull Request '${number}' (${countMessage})`);
775
+ const contentKey = contentKeyFromBranch(pullRequest.head.ref);
776
+ let metadata = await this.retrieveMetadataOld(contentKey).catch(() => undefined);
777
+
778
+ if (!metadata) {
779
+ console.log(`Skipped migrating Pull Request '${number}' (${countMessage})`);
780
+ return;
781
+ }
782
+
783
+ let newNumber = number;
784
+ if (!metadata.version) {
785
+ console.log(`Migrating Pull Request '${number}' to version 1`);
786
+ // migrate branch from cms/slug to cms/collection/slug
787
+ try {
788
+ ({ metadata, pullRequest } = await this.migrateToVersion1(pullRequest, metadata));
789
+ } catch (e) {
790
+ console.log(`Failed to migrate Pull Request '${number}' to version 1. See error below.`);
791
+ console.error(e);
792
+ return;
793
+ }
794
+ newNumber = pullRequest.number;
795
+ console.log(
796
+ `Done migrating Pull Request '${number}' to version 1. New pull request '${newNumber}' created.`,
797
+ );
798
+ }
799
+
800
+ if (metadata.version === '1') {
801
+ console.log(`Migrating Pull Request '${newNumber}' to labels`);
802
+ // migrate branch from using orphan ref to store metadata to pull requests label
803
+ await this.migrateToPullRequestLabels(pullRequest, metadata);
804
+ console.log(`Done migrating Pull Request '${newNumber}' to labels`);
805
+ }
806
+
807
+ console.log(
808
+ `Done migrating Pull Request '${
809
+ number === newNumber ? newNumber : `${number} => ${newNumber}`
810
+ }'`,
811
+ );
812
+ }
813
+
814
+ async getOpenAuthoringBranches() {
815
+ const cmsBranches = await this.requestAllPages<Octokit.GitListMatchingRefsResponseItem>(
816
+ `${this.repoURL}/git/refs/heads/cms/${this.repo}`,
817
+ ).catch(() => [] as Octokit.GitListMatchingRefsResponseItem[]);
818
+ return cmsBranches;
819
+ }
820
+
821
+ async listUnpublishedBranches() {
822
+ console.log(
823
+ '%c Checking for Unpublished entries',
824
+ 'line-height: 30px;text-align: center;font-weight: bold',
825
+ );
826
+
827
+ let branches: string[];
828
+ if (this.useOpenAuthoring) {
829
+ // open authoring branches can exist without a pr
830
+ const cmsBranches: Octokit.GitListMatchingRefsResponse =
831
+ await this.getOpenAuthoringBranches();
832
+ branches = cmsBranches.map(b => b.ref.slice('refs/heads/'.length));
833
+ // filter irrelevant branches
834
+ const branchesWithFilter = await Promise.all(
835
+ branches.map(b => this.filterOpenAuthoringBranches(b)),
836
+ );
837
+ branches = branchesWithFilter.filter(b => b.filter).map(b => b.branch);
838
+ } else {
839
+ // backwards compatibility code, get relevant pull requests and migrate them
840
+ const pullRequests = await this.getPullRequests(
841
+ undefined,
842
+ PullRequestState.Open,
843
+ pr => !pr.head.repo.fork && withoutCmsLabel(pr, this.cmsLabelPrefix),
844
+ );
845
+ let prCount = 0;
846
+ for (const pr of pullRequests) {
847
+ if (!migrationNotified) {
848
+ migrationNotified = true;
849
+ alert(oneLine`
850
+ Decap CMS is adding labels to ${pullRequests.length} of your Editorial Workflow
851
+ entries. The "Workflow" tab will be unavailable during this migration. You may use other
852
+ areas of the CMS during this time. Note that closing the CMS will pause the migration.
853
+ `);
854
+ }
855
+ prCount = prCount + 1;
856
+ await this.migratePullRequest(pr, `${prCount} of ${pullRequests.length}`);
857
+ }
858
+ const cmsPullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr =>
859
+ withCmsLabel(pr, this.cmsLabelPrefix),
860
+ );
861
+ branches = cmsPullRequests.map(pr => pr.head.ref);
862
+ }
863
+
864
+ return branches;
865
+ }
866
+
867
+ /**
868
+ * Retrieve statuses for a given SHA. Unrelated to the editorial workflow
869
+ * concept of entry "status". Useful for things like deploy preview links.
870
+ */
871
+ async getStatuses(collectionName: string, slug: string) {
872
+ const contentKey = this.generateContentKey(collectionName, slug);
873
+ const branch = branchFromContentKey(contentKey);
874
+ const pullRequest = await this.getBranchPullRequest(branch);
875
+ const sha = pullRequest.head.sha;
876
+ const resp: { statuses: GitHubCommitStatus[] } = await this.request(
877
+ `${this.originRepoURL}/commits/${sha}/status`,
878
+ );
879
+ return resp.statuses.map(s => ({
880
+ context: s.context,
881
+ target_url: s.target_url,
882
+ state:
883
+ s.state === GitHubCommitStatusState.Success ? PreviewState.Success : PreviewState.Other,
884
+ }));
885
+ }
886
+
887
+ async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
888
+ const files = mediaFiles.concat(dataFiles);
889
+ const uploadPromises = files.map(file => this.uploadBlob(file));
890
+ await Promise.all(uploadPromises);
891
+
892
+ if (!options.useWorkflow) {
893
+ return this.getDefaultBranch()
894
+ .then(branchData =>
895
+ this.updateTree(branchData.commit.sha, files as { sha: string; path: string }[]),
896
+ )
897
+ .then(changeTree => this.commit(options.commitMessage, changeTree))
898
+ .then(response => this.patchBranch(this.branch, response.sha));
899
+ } else {
900
+ const mediaFilesList = (mediaFiles as { sha: string; path: string }[]).map(
901
+ ({ sha, path }) => ({
902
+ path: trimStart(path, '/'),
903
+ sha,
904
+ }),
905
+ );
906
+ const slug = dataFiles[0].slug;
907
+ return this.editorialWorkflowGit(files as TreeFile[], slug, mediaFilesList, options);
908
+ }
909
+ }
910
+
911
+ async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
912
+ /**
913
+ * We need to request the tree first to get the SHA. We use extended SHA-1
914
+ * syntax (<rev>:<path>) to get a blob from a tree without having to recurse
915
+ * through the tree.
916
+ */
917
+
918
+ const pathArray = path.split('/');
919
+ const filename = last(pathArray);
920
+ const directory = initial(pathArray).join('/');
921
+ const fileDataPath = encodeURIComponent(directory);
922
+ const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
923
+
924
+ const result: Octokit.GitGetTreeResponse = await this.request(fileDataURL);
925
+ const file = result.tree.find(file => file.path === filename);
926
+ if (file) {
927
+ return file.sha;
928
+ } else {
929
+ throw new APIError('Not Found', 404, API_NAME);
930
+ }
931
+ }
932
+
933
+ async deleteFiles(paths: string[], message: string) {
934
+ if (this.useOpenAuthoring) {
935
+ return Promise.reject('Cannot delete published entries as an Open Authoring user!');
936
+ }
937
+
938
+ const branchData = await this.getDefaultBranch();
939
+ const files = paths.map(path => ({ path, sha: null }));
940
+ const changeTree = await this.updateTree(branchData.commit.sha, files);
941
+ const commit = await this.commit(message, changeTree);
942
+ await this.patchBranch(this.branch, commit.sha);
943
+ }
944
+
945
+ async createBranchAndPullRequest(branchName: string, sha: string, commitMessage: string) {
946
+ await this.createBranch(branchName, sha);
947
+ return this.createPR(commitMessage, branchName);
948
+ }
949
+
950
+ async updatePullRequestLabels(number: number, labels: string[]) {
951
+ await this.request(`${this.repoURL}/issues/${number}/labels`, {
952
+ method: 'PUT',
953
+ body: JSON.stringify({ labels }),
954
+ });
955
+ }
956
+
957
+ // async since it is overridden in a child class
958
+ async diffFromFile(diff: Octokit.ReposCompareCommitsResponseFilesItem): Promise<Diff> {
959
+ return {
960
+ path: diff.filename,
961
+ newFile: diff.status === 'added',
962
+ sha: diff.sha,
963
+ // media files diffs don't have a patch attribute, except svg files
964
+ // renamed files don't have a patch attribute too
965
+ binary: (diff.status !== 'renamed' && !diff.patch) || diff.filename.endsWith('.svg'),
966
+ };
967
+ }
968
+
969
+ async editorialWorkflowGit(
970
+ files: TreeFile[],
971
+ slug: string,
972
+ mediaFilesList: MediaFile[],
973
+ options: PersistOptions,
974
+ ) {
975
+ const contentKey = this.generateContentKey(options.collectionName as string, slug);
976
+ const branch = branchFromContentKey(contentKey);
977
+ const unpublished = options.unpublished || false;
978
+ if (!unpublished) {
979
+ const branchData = await this.getDefaultBranch();
980
+ const changeTree = await this.updateTree(branchData.commit.sha, files);
981
+ const commitResponse = await this.commit(options.commitMessage, changeTree);
982
+
983
+ if (this.useOpenAuthoring) {
984
+ await this.createBranch(branch, commitResponse.sha);
985
+ } else {
986
+ const pr = await this.createBranchAndPullRequest(
987
+ branch,
988
+ commitResponse.sha,
989
+ options.commitMessage,
990
+ );
991
+ await this.setPullRequestStatus(pr, options.status || this.initialWorkflowStatus);
992
+ }
993
+ } else {
994
+ // Entry is already on editorial review workflow - commit to existing branch
995
+ const { files: diffFiles } = await this.getDifferences(
996
+ this.branch,
997
+ await this.getHeadReference(branch),
998
+ );
999
+
1000
+ const diffs = await Promise.all(diffFiles.map(file => this.diffFromFile(file)));
1001
+ // mark media files to remove
1002
+ const mediaFilesToRemove: { path: string; sha: string | null }[] = [];
1003
+ for (const diff of diffs.filter(d => d.binary)) {
1004
+ if (!mediaFilesList.some(file => file.path === diff.path)) {
1005
+ mediaFilesToRemove.push({ path: diff.path, sha: null });
1006
+ }
1007
+ }
1008
+
1009
+ // rebase the branch before applying new changes
1010
+ const rebasedHead = await this.rebaseBranch(branch);
1011
+ const treeFiles = mediaFilesToRemove.concat(files);
1012
+ const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
1013
+ const commit = await this.commit(options.commitMessage, changeTree);
1014
+
1015
+ return this.patchBranch(branch, commit.sha, { force: true });
1016
+ }
1017
+ }
1018
+
1019
+ async getDifferences(from: string, to: string) {
1020
+ // retry this as sometimes GitHub returns an initial 404 on cross repo compare
1021
+ const attempts = this.useOpenAuthoring ? 10 : 1;
1022
+ for (let i = 1; i <= attempts; i++) {
1023
+ try {
1024
+ const result: Octokit.ReposCompareCommitsResponse = await this.request(
1025
+ `${this.originRepoURL}/compare/${from}...${to}`,
1026
+ );
1027
+ return result;
1028
+ } catch (e) {
1029
+ if (i === attempts) {
1030
+ console.warn(`Reached maximum number of attempts '${attempts}' for getDifferences`);
1031
+ throw e;
1032
+ }
1033
+ await new Promise(resolve => setTimeout(resolve, i * 500));
1034
+ }
1035
+ }
1036
+ throw new APIError('Not Found', 404, API_NAME);
1037
+ }
1038
+
1039
+ async rebaseSingleCommit(baseCommit: GitHubCompareCommit, commit: GitHubCompareCommit) {
1040
+ // first get the diff between the commits
1041
+ const result = await this.getDifferences(commit.parents[0].sha, commit.sha);
1042
+ const files = getTreeFiles(result.files as GitHubCompareFiles);
1043
+
1044
+ // only update the tree if changes were detected
1045
+ if (files.length > 0) {
1046
+ // create a tree with baseCommit as the base with the diff applied
1047
+ const tree = await this.updateTree(baseCommit.sha, files);
1048
+ const { message, author, committer } = commit.commit;
1049
+
1050
+ // create a new commit from the updated tree
1051
+ const newCommit = await this.createCommit(
1052
+ message,
1053
+ tree.sha,
1054
+ [baseCommit.sha],
1055
+ author,
1056
+ committer,
1057
+ );
1058
+ return newCommit as unknown as GitHubCompareCommit;
1059
+ } else {
1060
+ return commit;
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Rebase an array of commits one-by-one, starting from a given base SHA
1066
+ */
1067
+ async rebaseCommits(baseCommit: GitHubCompareCommit, commits: GitHubCompareCommits) {
1068
+ /**
1069
+ * If the parent of the first commit already matches the target base,
1070
+ * return commits as is.
1071
+ */
1072
+ if (commits.length === 0 || commits[0].parents[0].sha === baseCommit.sha) {
1073
+ const head = last(commits) as GitHubCompareCommit;
1074
+ return head;
1075
+ } else {
1076
+ /**
1077
+ * Re-create each commit over the new base, applying each to the previous,
1078
+ * changing only the parent SHA and tree for each, but retaining all other
1079
+ * info, such as the author/committer data.
1080
+ */
1081
+ const newHeadPromise = commits.reduce((lastCommitPromise, commit) => {
1082
+ return lastCommitPromise.then(newParent => {
1083
+ const parent = newParent;
1084
+ const commitToRebase = commit;
1085
+ return this.rebaseSingleCommit(parent, commitToRebase);
1086
+ });
1087
+ }, Promise.resolve(baseCommit));
1088
+ return newHeadPromise;
1089
+ }
1090
+ }
1091
+
1092
+ async rebaseBranch(branch: string) {
1093
+ try {
1094
+ // Get the diff between the default branch the published branch
1095
+ const { base_commit: baseCommit, commits } = await this.getDifferences(
1096
+ this.branch,
1097
+ await this.getHeadReference(branch),
1098
+ );
1099
+ // Rebase the branch based on the diff
1100
+ const rebasedHead = await this.rebaseCommits(baseCommit, commits);
1101
+ return rebasedHead;
1102
+ } catch (error) {
1103
+ console.error(error);
1104
+ throw error;
1105
+ }
1106
+ }
1107
+
1108
+ async setPullRequestStatus(pullRequest: GitHubPull, newStatus: string) {
1109
+ const labels = [
1110
+ ...pullRequest.labels
1111
+ .filter(label => !isCMSLabel(label.name, this.cmsLabelPrefix))
1112
+ .map(l => l.name),
1113
+ statusToLabel(newStatus, this.cmsLabelPrefix),
1114
+ ];
1115
+ await this.updatePullRequestLabels(pullRequest.number, labels);
1116
+ }
1117
+
1118
+ async updateUnpublishedEntryStatus(collectionName: string, slug: string, newStatus: string) {
1119
+ const contentKey = this.generateContentKey(collectionName, slug);
1120
+ const branch = branchFromContentKey(contentKey);
1121
+ const pullRequest = await this.getBranchPullRequest(branch);
1122
+
1123
+ if (!this.useOpenAuthoring) {
1124
+ await this.setPullRequestStatus(pullRequest, newStatus);
1125
+ } else {
1126
+ if (status === 'pending_publish') {
1127
+ throw new Error('Open Authoring entries may not be set to the status "pending_publish".');
1128
+ }
1129
+
1130
+ if (pullRequest.number !== MOCK_PULL_REQUEST) {
1131
+ const { state } = pullRequest;
1132
+ if (state === PullRequestState.Open && newStatus === 'draft') {
1133
+ await this.closePR(pullRequest.number);
1134
+ }
1135
+ if (state === PullRequestState.Closed && newStatus === 'pending_review') {
1136
+ await this.openPR(pullRequest.number);
1137
+ }
1138
+ } else if (newStatus === 'pending_review') {
1139
+ const branch = branchFromContentKey(contentKey);
1140
+ // get the first commit message as the pr title
1141
+ const diff = await this.getDifferences(this.branch, await this.getHeadReference(branch));
1142
+ const title = diff.commits[0]?.commit?.message || API.DEFAULT_COMMIT_MESSAGE;
1143
+ await this.createPR(title, branch);
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ async deleteUnpublishedEntry(collectionName: string, slug: string) {
1149
+ const contentKey = this.generateContentKey(collectionName, slug);
1150
+ const branch = branchFromContentKey(contentKey);
1151
+
1152
+ const pullRequest = await this.getBranchPullRequest(branch);
1153
+ if (pullRequest.number !== MOCK_PULL_REQUEST) {
1154
+ await this.closePR(pullRequest.number);
1155
+ }
1156
+ await this.deleteBranch(branch);
1157
+ }
1158
+
1159
+ async publishUnpublishedEntry(collectionName: string, slug: string) {
1160
+ const contentKey = this.generateContentKey(collectionName, slug);
1161
+ const branch = branchFromContentKey(contentKey);
1162
+
1163
+ const pullRequest = await this.getBranchPullRequest(branch);
1164
+ await this.mergePR(pullRequest);
1165
+ await this.deleteBranch(branch);
1166
+ }
1167
+
1168
+ async createRef(type: string, name: string, sha: string) {
1169
+ const result: Octokit.GitCreateRefResponse = await this.request(`${this.repoURL}/git/refs`, {
1170
+ method: 'POST',
1171
+ body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
1172
+ });
1173
+ return result;
1174
+ }
1175
+
1176
+ async patchRef(type: string, name: string, sha: string, opts: { force?: boolean } = {}) {
1177
+ const force = opts.force || false;
1178
+ const result: Octokit.GitUpdateRefResponse = await this.request(
1179
+ `${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`,
1180
+ {
1181
+ method: 'PATCH',
1182
+ body: JSON.stringify({ sha, force }),
1183
+ },
1184
+ );
1185
+ return result;
1186
+ }
1187
+
1188
+ deleteRef(type: string, name: string) {
1189
+ return this.request(`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, {
1190
+ method: 'DELETE',
1191
+ });
1192
+ }
1193
+
1194
+ async getBranch(branch: string) {
1195
+ const result: Octokit.ReposGetBranchResponse = await this.request(
1196
+ `${this.repoURL}/branches/${encodeURIComponent(branch)}`,
1197
+ );
1198
+ return result;
1199
+ }
1200
+
1201
+ async getDefaultBranch() {
1202
+ const result: Octokit.ReposGetBranchResponse = await this.request(
1203
+ `${this.originRepoURL}/branches/${encodeURIComponent(this.branch)}`,
1204
+ );
1205
+ return result;
1206
+ }
1207
+
1208
+ async backupBranch(branchName: string) {
1209
+ try {
1210
+ const existingBranch = await this.getBranch(branchName);
1211
+ await this.createBranch(
1212
+ existingBranch.name.replace(
1213
+ new RegExp(`${CMS_BRANCH_PREFIX}/`),
1214
+ `${CMS_BRANCH_PREFIX}_${Date.now()}/`,
1215
+ ),
1216
+ existingBranch.commit.sha,
1217
+ );
1218
+ } catch (e) {
1219
+ console.warn(e);
1220
+ }
1221
+ }
1222
+
1223
+ async createBranch(branchName: string, sha: string) {
1224
+ try {
1225
+ const result = await this.createRef('heads', branchName, sha);
1226
+ return result;
1227
+ } catch (e) {
1228
+ const message = String(e.message || '');
1229
+ if (message === 'Reference update failed') {
1230
+ await throwOnConflictingBranches(branchName, name => this.getBranch(name), API_NAME);
1231
+ } else if (
1232
+ message === 'Reference already exists' &&
1233
+ branchName.startsWith(`${CMS_BRANCH_PREFIX}/`)
1234
+ ) {
1235
+ try {
1236
+ // this can happen if the branch wasn't deleted when the PR was merged
1237
+ // we backup the existing branch just in case and patch it with the new sha
1238
+ await this.backupBranch(branchName);
1239
+ const result = await this.patchBranch(branchName, sha, { force: true });
1240
+ return result;
1241
+ } catch (e) {
1242
+ console.log(e);
1243
+ }
1244
+ }
1245
+ throw e;
1246
+ }
1247
+ }
1248
+
1249
+ assertCmsBranch(branchName: string) {
1250
+ return branchName.startsWith(`${CMS_BRANCH_PREFIX}/`);
1251
+ }
1252
+
1253
+ patchBranch(branchName: string, sha: string, opts: { force?: boolean } = {}) {
1254
+ const force = opts.force || false;
1255
+ if (force && !this.assertCmsBranch(branchName)) {
1256
+ throw Error(`Only CMS branches can be force updated, cannot force update ${branchName}`);
1257
+ }
1258
+ return this.patchRef('heads', branchName, sha, { force });
1259
+ }
1260
+
1261
+ deleteBranch(branchName: string) {
1262
+ return this.deleteRef('heads', branchName).catch((err: Error) => {
1263
+ // If the branch doesn't exist, then it has already been deleted -
1264
+ // deletion should be idempotent, so we can consider this a
1265
+ // success.
1266
+ if (err.message === 'Reference does not exist') {
1267
+ return Promise.resolve();
1268
+ }
1269
+ console.error(err);
1270
+ return Promise.reject(err);
1271
+ });
1272
+ }
1273
+
1274
+ async getHeadReference(head: string) {
1275
+ return `${this.repoOwner}:${head}`;
1276
+ }
1277
+
1278
+ async createPR(title: string, head: string) {
1279
+ const result: Octokit.PullsCreateResponse = await this.request(`${this.originRepoURL}/pulls`, {
1280
+ method: 'POST',
1281
+ body: JSON.stringify({
1282
+ title,
1283
+ body: DEFAULT_PR_BODY,
1284
+ head: await this.getHeadReference(head),
1285
+ base: this.branch,
1286
+ }),
1287
+ });
1288
+
1289
+ return result;
1290
+ }
1291
+
1292
+ async openPR(number: number) {
1293
+ console.log('%c Re-opening PR', 'line-height: 30px;text-align: center;font-weight: bold');
1294
+ const result: Octokit.PullsUpdateBranchResponse = await this.request(
1295
+ `${this.originRepoURL}/pulls/${number}`,
1296
+ {
1297
+ method: 'PATCH',
1298
+ body: JSON.stringify({
1299
+ state: PullRequestState.Open,
1300
+ }),
1301
+ },
1302
+ );
1303
+ return result;
1304
+ }
1305
+
1306
+ async closePR(number: number) {
1307
+ console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold');
1308
+ const result: Octokit.PullsUpdateBranchResponse = await this.request(
1309
+ `${this.originRepoURL}/pulls/${number}`,
1310
+ {
1311
+ method: 'PATCH',
1312
+ body: JSON.stringify({
1313
+ state: PullRequestState.Closed,
1314
+ }),
1315
+ },
1316
+ );
1317
+ return result;
1318
+ }
1319
+
1320
+ async mergePR(pullrequest: GitHubPull) {
1321
+ console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold');
1322
+ try {
1323
+ const result: Octokit.PullsMergeResponse = await this.request(
1324
+ `${this.originRepoURL}/pulls/${pullrequest.number}/merge`,
1325
+ {
1326
+ method: 'PUT',
1327
+ body: JSON.stringify({
1328
+ commit_message: MERGE_COMMIT_MESSAGE,
1329
+ sha: pullrequest.head.sha,
1330
+ merge_method: this.mergeMethod,
1331
+ }),
1332
+ },
1333
+ );
1334
+ return result;
1335
+ } catch (error) {
1336
+ if (error instanceof APIError && error.status === 405) {
1337
+ return this.forceMergePR(pullrequest);
1338
+ } else {
1339
+ throw error;
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ async forceMergePR(pullRequest: GitHubPull) {
1345
+ const result = await this.getDifferences(pullRequest.base.sha, pullRequest.head.sha);
1346
+ const files = getTreeFiles(result.files as GitHubCompareFiles);
1347
+
1348
+ let commitMessage = 'Automatically generated. Merged on Decap CMS\n\nForce merge of:';
1349
+ files.forEach(file => {
1350
+ commitMessage += `\n* "${file.path}"`;
1351
+ });
1352
+ console.log(
1353
+ '%c Automatic merge not possible - Forcing merge.',
1354
+ 'line-height: 30px;text-align: center;font-weight: bold',
1355
+ );
1356
+ return this.getDefaultBranch()
1357
+ .then(branchData => this.updateTree(branchData.commit.sha, files))
1358
+ .then(changeTree => this.commit(commitMessage, changeTree))
1359
+ .then(response => this.patchBranch(this.branch, response.sha));
1360
+ }
1361
+
1362
+ toBase64(str: string) {
1363
+ return Promise.resolve(Base64.encode(str));
1364
+ }
1365
+
1366
+ async uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
1367
+ const contentBase64 = await result(
1368
+ item,
1369
+ 'toBase64',
1370
+ partial(this.toBase64, item.raw as string),
1371
+ );
1372
+ const response = await this.request(`${this.repoURL}/git/blobs`, {
1373
+ method: 'POST',
1374
+ body: JSON.stringify({
1375
+ content: contentBase64,
1376
+ encoding: 'base64',
1377
+ }),
1378
+ });
1379
+ item.sha = response.sha;
1380
+ return item;
1381
+ }
1382
+
1383
+ async updateTree(
1384
+ baseSha: string,
1385
+ files: { path: string; sha: string | null; newPath?: string }[],
1386
+ branch = this.branch,
1387
+ ) {
1388
+ const toMove: { from: string; to: string; sha: string }[] = [];
1389
+ const tree = files.reduce((acc, file) => {
1390
+ const entry = {
1391
+ path: trimStart(file.path, '/'),
1392
+ mode: '100644',
1393
+ type: 'blob',
1394
+ sha: file.sha,
1395
+ } as TreeEntry;
1396
+
1397
+ if (file.newPath) {
1398
+ toMove.push({ from: file.path, to: file.newPath, sha: file.sha as string });
1399
+ } else {
1400
+ acc.push(entry);
1401
+ }
1402
+
1403
+ return acc;
1404
+ }, [] as TreeEntry[]);
1405
+
1406
+ for (const { from, to, sha } of toMove) {
1407
+ const sourceDir = dirname(from);
1408
+ const destDir = dirname(to);
1409
+ const files = await this.listFiles(sourceDir, { branch, depth: 100 });
1410
+ for (const file of files) {
1411
+ // delete current path
1412
+ tree.push({
1413
+ path: file.path,
1414
+ mode: '100644',
1415
+ type: 'blob',
1416
+ sha: null,
1417
+ });
1418
+ // create in new path
1419
+ tree.push({
1420
+ path: file.path.replace(sourceDir, destDir),
1421
+ mode: '100644',
1422
+ type: 'blob',
1423
+ sha: file.path === from ? sha : file.id,
1424
+ });
1425
+ }
1426
+ }
1427
+
1428
+ const newTree = await this.createTree(baseSha, tree);
1429
+ return { ...newTree, parentSha: baseSha };
1430
+ }
1431
+
1432
+ async createTree(baseSha: string, tree: TreeEntry[]) {
1433
+ const result: Octokit.GitCreateTreeResponse = await this.request(`${this.repoURL}/git/trees`, {
1434
+ method: 'POST',
1435
+ body: JSON.stringify({ base_tree: baseSha, tree }),
1436
+ });
1437
+ return result;
1438
+ }
1439
+
1440
+ commit(message: string, changeTree: { parentSha?: string; sha: string }) {
1441
+ const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
1442
+ return this.createCommit(message, changeTree.sha, parents);
1443
+ }
1444
+
1445
+ async createCommit(
1446
+ message: string,
1447
+ treeSha: string,
1448
+ parents: string[],
1449
+ author?: GitHubAuthor,
1450
+ committer?: GitHubCommitter,
1451
+ ) {
1452
+ const result: Octokit.GitCreateCommitResponse = await this.request(
1453
+ `${this.repoURL}/git/commits`,
1454
+ {
1455
+ method: 'POST',
1456
+ body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
1457
+ },
1458
+ );
1459
+ return result;
1460
+ }
1461
+
1462
+ async getUnpublishedEntrySha(collection: string, slug: string) {
1463
+ const contentKey = this.generateContentKey(collection, slug);
1464
+ const branch = branchFromContentKey(contentKey);
1465
+ const pullRequest = await this.getBranchPullRequest(branch);
1466
+ return pullRequest.head.sha;
1467
+ }
1468
+ }