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.
@@ -1,4 +1,3 @@
1
- import * as React from 'react';
2
1
  import semaphore from 'semaphore';
3
2
  import trimStart from 'lodash/trimStart';
4
3
  import { stripIndent } from 'common-tags';
@@ -24,6 +23,7 @@ import {
24
23
 
25
24
  import AuthenticationPage from './AuthenticationPage';
26
25
  import API, { API_NAME } from './API';
26
+ import { ETagPollingManager } from './polling';
27
27
  import GraphQLAPI from './GraphQLAPI';
28
28
 
29
29
  import type { Endpoints } from '@octokit/types';
@@ -39,6 +39,8 @@ import type {
39
39
  ImplementationFile,
40
40
  UnpublishedEntryMediaFile,
41
41
  Entry,
42
+ Note,
43
+ IssueChange,
42
44
  } from 'decap-cms-lib-util';
43
45
  import type { Semaphore } from 'semaphore';
44
46
 
@@ -90,6 +92,8 @@ export default class GitHub implements Implementation {
90
92
  [key: string]: Promise<boolean>;
91
93
  };
92
94
  _mediaDisplayURLSem?: Semaphore;
95
+ pollingManager?: ETagPollingManager;
96
+ unwatchFunctions: Map<string, () => void> = new Map();
93
97
 
94
98
  constructor(config: Config, options = {}) {
95
99
  this.options = {
@@ -381,12 +385,22 @@ export default class GitHub implements Implementation {
381
385
  // }
382
386
  // }
383
387
 
388
+ if (this.api && !this.pollingManager) {
389
+ this.pollingManager = new ETagPollingManager(this.api, 15000);
390
+ }
384
391
  // Authorized user
385
392
  return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring };
386
393
  }
387
394
 
388
395
  logout() {
389
396
  this.token = null;
397
+ // Clean up polling
398
+ if (this.pollingManager) {
399
+ this.pollingManager.destroy();
400
+ this.pollingManager = undefined;
401
+ }
402
+ this.unwatchFunctions.clear();
403
+
390
404
  if (this.api && this.api.reset && typeof this.api.reset === 'function') {
391
405
  return this.api.reset();
392
406
  }
@@ -710,7 +724,15 @@ export default class GitHub implements Implementation {
710
724
  // deleteUnpublishedEntry is a transactional operation
711
725
  return runWithLock(
712
726
  this.lock,
713
- () => this.api!.deleteUnpublishedEntry(collection, slug),
727
+ async () => {
728
+ await this.api!.deleteUnpublishedEntry(collection, slug);
729
+ // Clean up associated notes issue
730
+ try {
731
+ await this.api!.closeEntryNotesIssue(collection, slug);
732
+ } catch (error) {
733
+ console.warn('Failed to close notes issue during entry deletion:', error);
734
+ }
735
+ },
714
736
  'Failed to acquire delete entry lock',
715
737
  );
716
738
  }
@@ -719,8 +741,186 @@ export default class GitHub implements Implementation {
719
741
  // publishUnpublishedEntry is a transactional operation
720
742
  return runWithLock(
721
743
  this.lock,
722
- () => this.api!.publishUnpublishedEntry(collection, slug),
744
+ async () => {
745
+ await this.api!.publishUnpublishedEntry(collection, slug);
746
+ await this.api!.closeIssueOnPublish(collection, slug);
747
+ },
723
748
  'Failed to acquire publish entry lock',
724
749
  );
725
750
  }
751
+
752
+ // Notes implementation, which is an abstraction to Github's PR issue comments.
753
+
754
+ // Notes implementation using GitHub Issues
755
+ async getNotes(collection: string, slug: string): Promise<Note[]> {
756
+ try {
757
+ const notes = await this.api!.getEntryNotes(collection, slug);
758
+ return notes.map(note => ({ ...note, entrySlug: slug }));
759
+ } catch (error) {
760
+ console.error('Failed to get notes:', error);
761
+ return [];
762
+ }
763
+ }
764
+
765
+ async addNote(collection: string, slug: string, noteData: Omit<Note, 'id'>): Promise<Note> {
766
+ const currentUser = await this.currentUser({ token: this.token! });
767
+
768
+ const note: Note = {
769
+ ...noteData,
770
+ id: 'temp-' + Date.now(),
771
+ author: currentUser.login || currentUser.name || '',
772
+ avatarUrl: currentUser.avatar_url,
773
+ entrySlug: slug,
774
+ timestamp: noteData.timestamp || new Date().toISOString(),
775
+ resolved: noteData.resolved || false,
776
+ issueUrl: undefined,
777
+ };
778
+
779
+ // Get entry title for better issue naming
780
+ let entryTitle: string | undefined;
781
+ try {
782
+ const entryData = await this.getEntry(`${collection}/${slug}.md`);
783
+ const titleMatch = entryData.data.match(/^title:\s*["']?([^"'\n]+)["']?/m);
784
+ entryTitle = titleMatch ? titleMatch[1] : undefined;
785
+ } catch (error) {
786
+ // Entry not found or error reading, use undefined title
787
+ }
788
+
789
+ const { commentId, issueUrl } = await this.api!.addNoteToEntry(
790
+ collection,
791
+ slug,
792
+ note,
793
+ entryTitle,
794
+ );
795
+
796
+ return {
797
+ ...note,
798
+ id: commentId,
799
+ issueUrl,
800
+ };
801
+ }
802
+
803
+ async updateNote(
804
+ collection: string,
805
+ slug: string,
806
+ noteId: string,
807
+ updates: Partial<Note>,
808
+ ): Promise<Note> {
809
+ const currentNotes = await this.getNotes(collection, slug);
810
+ const existingNote = currentNotes.find(note => note.id === noteId);
811
+ if (!existingNote) {
812
+ throw new Error(`Note with ID ${noteId} not found`);
813
+ }
814
+
815
+ const updatedNote: Note = {
816
+ ...existingNote,
817
+ ...updates,
818
+ id: noteId,
819
+ entrySlug: slug,
820
+ };
821
+
822
+ await this.api!.updateEntryNote(noteId, updatedNote);
823
+ return updatedNote;
824
+ }
825
+
826
+ async deleteNote(collection: string, slug: string, noteId: string): Promise<void> {
827
+ const currentNotes = await this.getNotes(collection, slug);
828
+ const noteExists = currentNotes.some(note => note.id === noteId);
829
+ if (!noteExists) {
830
+ throw new Error(`Note with ID ${noteId} not found`);
831
+ }
832
+
833
+ await this.api!.deleteEntryNote(noteId);
834
+ }
835
+
836
+ async toggleNoteResolution(collection: string, slug: string, noteId: string): Promise<Note> {
837
+ const currentNotes = await this.getNotes(collection, slug);
838
+ const note = currentNotes.find(n => n.id === noteId);
839
+ if (!note) {
840
+ throw new Error(`Note with ID ${noteId} not found`);
841
+ }
842
+
843
+ return this.updateNote(collection, slug, noteId, {
844
+ resolved: !note.resolved,
845
+ });
846
+ }
847
+
848
+ async reopenIssueForUnpublishedEntry(collection: string, slug: string) {
849
+ await this.api!.reopenIssueOnUnpublish(collection, slug);
850
+ }
851
+ /**
852
+ * Start watching notes for changes
853
+ * Called from Redux action
854
+ */
855
+ async startNotesPolling(
856
+ collection: string,
857
+ slug: string,
858
+ callbacks: {
859
+ onUpdate: (notes: Note[], changes: IssueChange[]) => void;
860
+ onChange?: (change: IssueChange) => void;
861
+ },
862
+ ): Promise<void> {
863
+ if (!this.pollingManager) {
864
+ console.warn('[DecapNotes Polling] Polling manager not initialized');
865
+ return;
866
+ }
867
+
868
+ const issueKey = `${collection}/${slug}`;
869
+
870
+ // Check if already watching this exact entry - if so, skip
871
+ if (this.pollingManager.getStatus().currentWatch === issueKey) {
872
+ return;
873
+ }
874
+
875
+ // First, ensure any previous polling for this entry is completely stopped
876
+ const existingUnwatch = this.unwatchFunctions.get(issueKey);
877
+ if (existingUnwatch) {
878
+ existingUnwatch();
879
+ this.unwatchFunctions.delete(issueKey);
880
+ }
881
+
882
+ try {
883
+ const unwatchFn = await this.pollingManager.watchIssueWithRetry(
884
+ collection,
885
+ slug,
886
+ callbacks,
887
+ 5, // maxRetries - will try up to 5 times
888
+ 2000, // retryDelay - 2 seconds between attempts
889
+ );
890
+
891
+ // Store the new unwatch function
892
+ this.unwatchFunctions.set(issueKey, unwatchFn);
893
+ } catch (error) {
894
+ console.error('[DecapNotes Polling] Failed to start polling after retries:', error);
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Stop watching notes for changes
900
+ * Called from Redux action: dispatch(stopNotesPolling(collection, slug))
901
+ *
902
+ * Ensures complete cleanup of polling for this entry
903
+ */
904
+ async stopNotesPolling(collection: string, slug: string): Promise<void> {
905
+ const issueKey = `${collection}/${slug}`;
906
+ const unwatchFn = this.unwatchFunctions.get(issueKey);
907
+
908
+ if (unwatchFn) {
909
+ unwatchFn();
910
+ this.unwatchFunctions.delete(issueKey);
911
+ } else {
912
+ console.log(`[DecapNotes Polling] No active polling found for ${issueKey}`);
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Manually refresh notes (force check now)
918
+ * Called from Redux action: dispatch(refreshNotesNow(collection, slug))
919
+ */
920
+ async refreshNotesNow(collection: string, slug: string): Promise<void> {
921
+ if (!this.pollingManager) {
922
+ throw new Error('Polling manager not initialized');
923
+ }
924
+ await this.pollingManager.checkIssueNow(collection, slug);
925
+ }
726
926
  }