decap-cms-backend-github 3.6.0 → 3.8.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 CHANGED
@@ -7,6 +7,7 @@ import result from 'lodash/result';
7
7
  import trimStart from 'lodash/trimStart';
8
8
  import trim from 'lodash/trim';
9
9
  import { oneLine } from 'common-tags';
10
+ import { dirname } from 'path';
10
11
  import {
11
12
  getAllResponses,
12
13
  APIError,
@@ -29,7 +30,6 @@ import {
29
30
  unsentRequest,
30
31
  throwOnConflictingBranches,
31
32
  } from 'decap-cms-lib-util';
32
- import { dirname } from 'path';
33
33
 
34
34
  import type {
35
35
  AssetProxy,
@@ -37,6 +37,9 @@ import type {
37
37
  PersistOptions,
38
38
  FetchError,
39
39
  ApiRequest,
40
+ Note,
41
+ IssueState,
42
+ CommentData,
40
43
  } from 'decap-cms-lib-util';
41
44
  import type { Semaphore } from 'semaphore';
42
45
  import type { Endpoints } from '@octokit/types';
@@ -65,6 +68,8 @@ type GitHubLabel = Omit<
65
68
 
66
69
  export const API_NAME = 'GitHub';
67
70
 
71
+ const { fetchWithTimeout: fetch } = unsentRequest;
72
+
68
73
  export const MOCK_PULL_REQUEST = -1;
69
74
 
70
75
  export interface Config {
@@ -89,6 +94,26 @@ interface TreeFile {
89
94
  raw?: string;
90
95
  }
91
96
 
97
+ interface GitHubIssue {
98
+ id: number;
99
+ number: number;
100
+ title: string;
101
+ body: string;
102
+ state: 'open' | 'closed';
103
+ comments: number;
104
+ html_url: string;
105
+ created_at: string;
106
+ updated_at: string;
107
+ user: {
108
+ login: string;
109
+ avatar_url: string;
110
+ } | null;
111
+ labels: Array<{
112
+ name: string;
113
+ color: string;
114
+ }>;
115
+ }
116
+
92
117
  interface TreeFileForUpdate {
93
118
  sha: string | null;
94
119
  path: string;
@@ -1011,9 +1036,15 @@ export default class API {
1011
1036
  const contentKey = this.generateContentKey(options.collectionName as string, slug);
1012
1037
  const branch = branchFromContentKey(contentKey);
1013
1038
  const unpublished = options.unpublished || false;
1039
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
1014
1040
  if (!unpublished) {
1015
1041
  const branchData = await this.getDefaultBranch();
1016
- const changeTree = await this.updateTree(branchData.commit.sha, files);
1042
+ const changeTree = await this.updateTree(
1043
+ branchData.commit.sha,
1044
+ files,
1045
+ this.branch,
1046
+ hasSubfolders,
1047
+ );
1017
1048
  const commitResponse = await this.commit(options.commitMessage, changeTree);
1018
1049
 
1019
1050
  if (this.useOpenAuthoring) {
@@ -1048,7 +1079,7 @@ export default class API {
1048
1079
  // rebase the branch before applying new changes
1049
1080
  const rebasedHead = await this.rebaseBranch(branch);
1050
1081
  const treeFiles = mediaFilesToRemove.concat(files);
1051
- const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
1082
+ const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch, hasSubfolders);
1052
1083
  const commit = await this.commit(options.commitMessage, changeTree);
1053
1084
 
1054
1085
  return this.patchBranch(branch, commit.sha, { force: true });
@@ -1205,6 +1236,7 @@ export default class API {
1205
1236
  const pullRequest = await this.getBranchPullRequest(branch);
1206
1237
  await this.mergePR(pullRequest);
1207
1238
  await this.deleteBranch(branch);
1239
+ await this.closeIssueOnPublish(collectionName, slug);
1208
1240
  }
1209
1241
 
1210
1242
  async createRef(type: string, name: string, sha: string) {
@@ -1418,6 +1450,7 @@ export default class API {
1418
1450
  baseSha: string,
1419
1451
  files: { path: string; sha: string | null; newPath?: string }[],
1420
1452
  branch = this.branch,
1453
+ hasSubfolders = true,
1421
1454
  ) {
1422
1455
  const toMove: { from: string; to: string; sha: string }[] = [];
1423
1456
  const tree = files.reduce((acc, file) => {
@@ -1438,24 +1471,44 @@ export default class API {
1438
1471
  }, [] as TreeEntry[]);
1439
1472
 
1440
1473
  for (const { from, to, sha } of toMove) {
1441
- const sourceDir = dirname(from);
1442
- const destDir = dirname(to);
1443
- const files = await this.listFiles(sourceDir, { branch, depth: 100 });
1444
- for (const file of files) {
1445
- // delete current path
1474
+ if (!hasSubfolders) {
1475
+ // New behavior (subfolders: false): Only move the specific file
1476
+ // Delete the file at the old path
1446
1477
  tree.push({
1447
- path: file.path,
1478
+ path: trimStart(from, '/'),
1448
1479
  mode: '100644',
1449
1480
  type: 'blob',
1450
1481
  sha: null,
1451
1482
  });
1452
- // create in new path
1483
+ // Create the file at the new path
1453
1484
  tree.push({
1454
- path: file.path.replace(sourceDir, destDir),
1485
+ path: trimStart(to, '/'),
1455
1486
  mode: '100644',
1456
1487
  type: 'blob',
1457
- sha: file.path === from ? sha : file.id,
1488
+ sha,
1458
1489
  });
1490
+ } else {
1491
+ // Legacy behavior (subfolders: true, default): Move all files in the directory
1492
+ // This is for collections where all files in a folder represent a single entry
1493
+ const sourceDir = dirname(from);
1494
+ const destDir = dirname(to);
1495
+ const files = await this.listFiles(sourceDir, { branch, depth: 100 });
1496
+ for (const file of files) {
1497
+ // delete current path
1498
+ tree.push({
1499
+ path: file.path,
1500
+ mode: '100644',
1501
+ type: 'blob',
1502
+ sha: null,
1503
+ });
1504
+ // create in new path
1505
+ tree.push({
1506
+ path: file.path.replace(sourceDir, destDir),
1507
+ mode: '100644',
1508
+ type: 'blob',
1509
+ sha: file.path === from ? sha : file.id,
1510
+ });
1511
+ }
1459
1512
  }
1460
1513
  }
1461
1514
 
@@ -1498,4 +1551,463 @@ export default class API {
1498
1551
  const pullRequest = await this.getBranchPullRequest(branch);
1499
1552
  return pullRequest.head.sha;
1500
1553
  }
1554
+
1555
+ /**
1556
+ * Constants for note formatting to aid with PR comment to note conversion
1557
+ */
1558
+ private static readonly NOTE_STATUS_RESOLVED = 'RESOLVED';
1559
+ private static readonly NOTE_STATUS_OPEN = 'OPEN';
1560
+ private static readonly NOTES_LABEL = 'decap-cms-notes';
1561
+ private static readonly NOTE_ISSUE_PREFIX = 'Notes: ';
1562
+ // In Github we hide Decap Notes metadata in a HTML comment, that way we can track status of whether or not a note has been resolved (similar to GDocs)
1563
+ private static readonly NOTE_REGEX =
1564
+ /^<!-- DecapCMS Note - Status: (RESOLVED|OPEN) -->([\s\S]+)$/;
1565
+
1566
+ /**
1567
+ * Format a note for PR comment display
1568
+ */
1569
+ private formatNoteForGithub(note: Note): string {
1570
+ const status = note.resolved ? API.NOTE_STATUS_RESOLVED : API.NOTE_STATUS_OPEN;
1571
+
1572
+ return `<!-- DecapCMS Note - Status: ${status} -->
1573
+ ${note.content}`;
1574
+ }
1575
+
1576
+ /**
1577
+ * Parse a GitHub comment into a Note object
1578
+ */
1579
+ parseCommentToNote(comment: GitHubIssue): Note {
1580
+ if (!comment || !comment.body || !comment.user) {
1581
+ throw new Error('Invalid comment structure');
1582
+ }
1583
+
1584
+ const structuredMatch = comment.body.match(API.NOTE_REGEX);
1585
+
1586
+ const content = structuredMatch ? structuredMatch[2].trim() : comment.body;
1587
+ const resolved = structuredMatch ? structuredMatch[1] === API.NOTE_STATUS_RESOLVED : false;
1588
+
1589
+ if (!content.trim()) {
1590
+ throw new Error('Empty note content');
1591
+ }
1592
+
1593
+ return {
1594
+ id: comment.id.toString(),
1595
+ author: comment.user.login,
1596
+ avatarUrl: comment.user.avatar_url,
1597
+ timestamp: comment.created_at,
1598
+ content,
1599
+ resolved,
1600
+ entrySlug: '',
1601
+ };
1602
+ }
1603
+
1604
+ /**
1605
+ * Create a GitHub issue for storing notes for a specific entry
1606
+ */
1607
+ async createEntryIssue(
1608
+ collectionName: string,
1609
+ slug: string,
1610
+ entryTitle?: string,
1611
+ ): Promise<GitHubIssue> {
1612
+ const title = `${API.NOTE_ISSUE_PREFIX}${entryTitle || `${collectionName}/${slug}`}`;
1613
+ const body = `This issue tracks notes for entry: \`${collectionName}/${slug}\`\n\n---\n*This issue was created automatically by Decap CMS for note management.*`;
1614
+
1615
+ const response: GitHubIssue = await this.request(`${this.repoURL}/issues`, {
1616
+ method: 'POST',
1617
+ body: JSON.stringify({
1618
+ title,
1619
+ body,
1620
+ labels: [API.NOTES_LABEL, `collection:${collectionName}`],
1621
+ }),
1622
+ });
1623
+
1624
+ return response;
1625
+ }
1626
+
1627
+ /**
1628
+ * Find existing issue for an entry (returns null if not found)
1629
+ */
1630
+ async findEntryIssue(collectionName: string, slug: string): Promise<GitHubIssue | null> {
1631
+ // Search for existing issue
1632
+ const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body`;
1633
+
1634
+ try {
1635
+ const searchResponse = await this.request('/search/issues', {
1636
+ params: { q: searchQuery },
1637
+ });
1638
+
1639
+ if (searchResponse.items && searchResponse.items.length > 0) {
1640
+ return searchResponse.items[0];
1641
+ }
1642
+ return null;
1643
+ } catch (error) {
1644
+ console.warn('Failed to search for existing notes issue:', error);
1645
+ return null;
1646
+ }
1647
+ }
1648
+ /**
1649
+ * Get issue with ETag support for conditional requests
1650
+ * Returns { status: 304 } if not modified, or { status: 200, data, etag } if modified
1651
+ */
1652
+ async getIssueWithETag(
1653
+ issueNumber: number,
1654
+ etag: string | null,
1655
+ ): Promise<
1656
+ | { status: 304; data?: never; etag?: never }
1657
+ | { status: 200; data: IssueState; etag: string | null }
1658
+ > {
1659
+ try {
1660
+ const headers: Record<string, string> = {
1661
+ Authorization: `${this.tokenKeyword} ${this.token}`,
1662
+ };
1663
+
1664
+ if (etag) {
1665
+ headers['If-None-Match'] = etag;
1666
+ }
1667
+
1668
+ const response = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}`, {
1669
+ headers,
1670
+ });
1671
+
1672
+ if (response.status === 304) {
1673
+ return { status: 304 };
1674
+ }
1675
+
1676
+ if (response.status === 200) {
1677
+ const issue = await response.json();
1678
+ const newETag = response.headers.get('ETag');
1679
+
1680
+ const commentsResponse = await fetch(
1681
+ `${this.apiRoot}${this.repoURL}/issues/${issueNumber}/comments`,
1682
+ { headers },
1683
+ );
1684
+ const commentsRaw: GitHubIssue[] = await commentsResponse.json();
1685
+
1686
+ const comments: CommentData[] = commentsRaw.map(comment => ({
1687
+ id: comment.id,
1688
+ body: comment.body,
1689
+ user: comment.user,
1690
+ created_at: comment.created_at,
1691
+ updated_at: comment.updated_at,
1692
+ }));
1693
+
1694
+ const issueState: IssueState = {
1695
+ number: issue.number,
1696
+ title: issue.title,
1697
+ body: issue.body,
1698
+ state: issue.state,
1699
+ updated_at: issue.updated_at,
1700
+ comments,
1701
+ labels: issue.labels,
1702
+ html_url: issue.html_url,
1703
+ };
1704
+
1705
+ return {
1706
+ status: 200,
1707
+ data: issueState,
1708
+ etag: newETag,
1709
+ };
1710
+ }
1711
+
1712
+ throw new Error(`Unexpected status: ${response.status}`);
1713
+ } catch (error) {
1714
+ if (error.status === 304) {
1715
+ return { status: 304 };
1716
+ }
1717
+ throw error;
1718
+ }
1719
+ }
1720
+
1721
+ /**
1722
+ * Get the current state of an issue (without ETag)
1723
+ */
1724
+ async getIssueState(issueNumber: number): Promise<IssueState> {
1725
+ const response = await this.getIssueWithETag(issueNumber, null);
1726
+ if (response.status === 200 && response.data) {
1727
+ return response.data;
1728
+ }
1729
+ throw new Error('Failed to get issue state');
1730
+ }
1731
+ /**
1732
+ * Get comments from a GitHub issue
1733
+ */
1734
+ private async getIssueComments(issueNumber: number): Promise<GitHubIssue[]> {
1735
+ try {
1736
+ const response: GitHubIssue[] = await this.request(
1737
+ `${this.repoURL}/issues/${issueNumber}/comments`,
1738
+ );
1739
+ return Array.isArray(response) ? response : [];
1740
+ } catch (error) {
1741
+ console.error('Failed to get issue comments:', error);
1742
+ return [];
1743
+ }
1744
+ }
1745
+
1746
+ /**
1747
+ * Create a comment on a GitHub issue
1748
+ */
1749
+ async createIssueComment(issueNumber: number, note: Note): Promise<string> {
1750
+ try {
1751
+ const response: GitHubIssue = await this.request(
1752
+ `${this.repoURL}/issues/${issueNumber}/comments`,
1753
+ {
1754
+ method: 'POST',
1755
+ body: JSON.stringify({
1756
+ body: this.formatNoteForGithub(note),
1757
+ }),
1758
+ },
1759
+ );
1760
+
1761
+ return response.id.toString();
1762
+ } catch (error) {
1763
+ console.error('Failed to create issue comment:', error);
1764
+ throw new APIError('Failed to create note', error.status || 500, API_NAME);
1765
+ }
1766
+ }
1767
+
1768
+ /**
1769
+ * Update a GitHub issue comment
1770
+ */
1771
+ async updateIssueComment(commentId: string | number, note: Note): Promise<void> {
1772
+ try {
1773
+ await this.request(`${this.repoURL}/issues/comments/${commentId}`, {
1774
+ method: 'PATCH',
1775
+ body: JSON.stringify({
1776
+ body: this.formatNoteForGithub(note),
1777
+ }),
1778
+ });
1779
+ } catch (error) {
1780
+ console.error('Failed to update issue comment:', error);
1781
+ throw new APIError('Failed to update note', error.status || 500, API_NAME);
1782
+ }
1783
+ }
1784
+
1785
+ /**
1786
+ * Delete a GitHub issue comment
1787
+ */
1788
+ async deleteIssueComment(commentId: string | number): Promise<void> {
1789
+ try {
1790
+ await this.request(`${this.repoURL}/issues/comments/${commentId}`, {
1791
+ method: 'DELETE',
1792
+ });
1793
+ } catch (error) {
1794
+ console.error('Failed to delete issue comment:', error);
1795
+ throw new APIError('Failed to delete note', error.status || 500, API_NAME);
1796
+ }
1797
+ }
1798
+
1799
+ /**
1800
+ * Close the notes issue when an entry is published
1801
+ */
1802
+ async closeIssueOnPublish(collectionName: string, slug: string): Promise<void> {
1803
+ try {
1804
+ const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body state:open`;
1805
+ const searchResponse = await this.request('/search/issues', {
1806
+ params: { q: searchQuery },
1807
+ });
1808
+
1809
+ if (searchResponse.items && searchResponse.items.length > 0) {
1810
+ const issue = searchResponse.items[0];
1811
+ await this.request(`${this.repoURL}/issues/${issue.number}`, {
1812
+ method: 'PATCH',
1813
+ body: JSON.stringify({
1814
+ state: 'closed',
1815
+ labels: [
1816
+ ...(issue.labels || []).map((l: { name: string }) => l.name),
1817
+ 'entry-published',
1818
+ ],
1819
+ }),
1820
+ });
1821
+ }
1822
+ } catch (error) {
1823
+ console.warn('Failed to close notes issue on publish:', error);
1824
+ }
1825
+ }
1826
+
1827
+ /**
1828
+ * Reopen the notes issue when an entry is unpublished
1829
+ */
1830
+ async reopenIssueOnUnpublish(collectionName: string, slug: string): Promise<void> {
1831
+ try {
1832
+ const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body`;
1833
+ const searchResponse = await this.request('/search/issues', {
1834
+ params: { q: searchQuery },
1835
+ });
1836
+
1837
+ if (searchResponse.items && searchResponse.items.length > 0) {
1838
+ const issue = searchResponse.items[0];
1839
+ // Remove 'entry-published' or 'entry-deleted' labels and reopen
1840
+ const updatedLabels = (issue.labels || [])
1841
+ .map((l: { name: string }) => l.name)
1842
+ .filter((name: string) => name !== 'entry-published' && name !== 'entry-deleted');
1843
+
1844
+ await this.request(`${this.repoURL}/issues/${issue.number}`, {
1845
+ method: 'PATCH',
1846
+ body: JSON.stringify({
1847
+ state: 'open',
1848
+ labels: updatedLabels,
1849
+ }),
1850
+ });
1851
+ }
1852
+ } catch (error) {
1853
+ console.warn('Failed to reopen notes issue on unpublish:', error);
1854
+ }
1855
+ }
1856
+
1857
+ /**
1858
+ * Get all notes for an entry
1859
+ */
1860
+ async getEntryNotes(collectionName: string, slug: string): Promise<Note[]> {
1861
+ try {
1862
+ const issue = await this.findEntryIssue(collectionName, slug);
1863
+ if (!issue) {
1864
+ return []; // No issue means no notes yet
1865
+ }
1866
+ const comments = await this.getIssueComments(issue.number);
1867
+ const issueUrl = issue.html_url; // Get the issue URL once
1868
+
1869
+ // Add issueUrl to each note
1870
+ return comments.map(comment => ({
1871
+ ...this.parseCommentToNote(comment),
1872
+ issueUrl, // Add the issue URL to each note (this info is picked up by the UI to direct users to the source of the Notes in Github)
1873
+ }));
1874
+ } catch (error) {
1875
+ console.error('Failed to get entry notes:', error);
1876
+ return [];
1877
+ }
1878
+ }
1879
+
1880
+ /**
1881
+ * Add a note to any entry
1882
+ */
1883
+ async addNoteToEntry(
1884
+ collectionName: string,
1885
+ slug: string,
1886
+ note: Note,
1887
+ entryTitle?: string,
1888
+ ): Promise<{ commentId: string; issueUrl: string }> {
1889
+ try {
1890
+ let issue = await this.findEntryIssue(collectionName, slug);
1891
+ if (!issue) {
1892
+ issue = await this.createEntryIssue(collectionName, slug, entryTitle);
1893
+ }
1894
+ const commentId = await this.createIssueComment(issue.number, note);
1895
+ return {
1896
+ commentId,
1897
+ issueUrl: issue.html_url,
1898
+ };
1899
+ } catch (error) {
1900
+ console.error('Failed to add note to entry:', error);
1901
+ throw new APIError('Failed to create note', error.status || 500, API_NAME);
1902
+ }
1903
+ }
1904
+
1905
+ async updateEntryNote(noteId: string, note: Note): Promise<void> {
1906
+ try {
1907
+ await this.updateIssueComment(noteId, note);
1908
+ } catch (error) {
1909
+ console.error('Failed to update entry note:', error);
1910
+ throw new APIError('Failed to update note', error.status || 500, API_NAME);
1911
+ }
1912
+ }
1913
+
1914
+ async deleteEntryNote(noteId: string): Promise<void> {
1915
+ try {
1916
+ await this.deleteIssueComment(noteId);
1917
+ } catch (error) {
1918
+ console.error('Failed to delete entry note:', error);
1919
+ throw new APIError('Failed to delete note', error.status || 500, API_NAME);
1920
+ }
1921
+ }
1922
+
1923
+ /**
1924
+ * Get all entries that have notes (useful for showing notes indicator in UI)
1925
+ */
1926
+ async getEntriesWithNotes(): Promise<
1927
+ Array<{ collection: string; slug: string; noteCount: number }>
1928
+ > {
1929
+ try {
1930
+ const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} state:open`;
1931
+ const searchResponse = await this.request('/search/issues', {
1932
+ params: { q: searchQuery, per_page: 100 },
1933
+ });
1934
+
1935
+ const entriesWithNotes = [];
1936
+
1937
+ for (const issue of searchResponse.items || []) {
1938
+ // Extract collection/slug from issue body
1939
+ const match = issue.body.match(/entry: `(.+)\/(.+)`/);
1940
+ if (match) {
1941
+ const [, collection, slug] = match;
1942
+ entriesWithNotes.push({
1943
+ collection,
1944
+ slug,
1945
+ noteCount: issue.comments,
1946
+ });
1947
+ }
1948
+ }
1949
+
1950
+ return entriesWithNotes;
1951
+ } catch (error) {
1952
+ console.error('Failed to get entries with notes:', error);
1953
+ return [];
1954
+ }
1955
+ }
1956
+
1957
+ /**
1958
+ * Close notes issue when entry is deleted
1959
+ */
1960
+ async closeEntryNotesIssue(collectionName: string, slug: string): Promise<void> {
1961
+ try {
1962
+ const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body state:open`;
1963
+ const searchResponse = await this.request('/search/issues', {
1964
+ params: { q: searchQuery },
1965
+ });
1966
+
1967
+ if (searchResponse.items && searchResponse.items.length > 0) {
1968
+ const issue = searchResponse.items[0];
1969
+ await this.request(`${this.repoURL}/issues/${issue.number}`, {
1970
+ method: 'PATCH',
1971
+ body: JSON.stringify({
1972
+ state: 'closed',
1973
+ labels: [...(issue.labels || []).map((l: { name: string }) => l.name), 'entry-deleted'],
1974
+ }),
1975
+ });
1976
+ }
1977
+ } catch (error) {
1978
+ console.warn('Failed to close notes issue:', error);
1979
+ }
1980
+ }
1981
+
1982
+ /**
1983
+ * Get PR metadata from branch name
1984
+ */
1985
+ async getPRMetadataFromBranch(branchName: string): Promise<{
1986
+ id: string;
1987
+ url: string;
1988
+ author: string;
1989
+ createdAt: string;
1990
+ } | null> {
1991
+ try {
1992
+ const response: GitHubPull[] = await this.request(`${this.originRepoURL}/pulls`, {
1993
+ params: {
1994
+ head: await this.getHeadReference(branchName),
1995
+ state: 'open',
1996
+ },
1997
+ });
1998
+
1999
+ const pr = response[0];
2000
+ if (!pr) return null;
2001
+
2002
+ return {
2003
+ id: pr.number.toString(),
2004
+ url: pr.html_url,
2005
+ author: pr.user?.login || 'unknown',
2006
+ createdAt: pr.created_at,
2007
+ };
2008
+ } catch (error) {
2009
+ console.error('Failed to get PR metadata:', error);
2010
+ return null;
2011
+ }
2012
+ }
1501
2013
  }