Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
67187bb
feat: added interface for EditorNotesPane
EstoesMoises Jul 26, 2025
1aeff96
feat: crud with Redux complete for EditorNotesPane
EstoesMoises Jul 26, 2025
6c9e475
feat: notes functionality connected to Test Backend
EstoesMoises Jul 26, 2025
cdc52aa
feat: notespane now immediately saves the note and it works with prox…
EstoesMoises Jul 27, 2025
e04f2f0
feat: notespane now integrated with Github backend
EstoesMoises Jul 28, 2025
51abb33
feat: adding notes to test backend
EstoesMoises Jul 28, 2025
be1a1d6
chore: adding english locale to Note toasts
EstoesMoises Jul 28, 2025
8ea8cb2
chore: linting + notes will only work on EditorialWorkflow
EstoesMoises Jul 28, 2025
c97975d
feat: notes can be unresolved
EstoesMoises Jul 28, 2025
eb9e5c6
fix: removing char limit on notes
EstoesMoises Jul 28, 2025
039e84d
feat: avatar now visible in notes
EstoesMoises Jul 28, 2025
7117c47
fix: now getting author and avatar immediately on note creation + not…
EstoesMoises Jul 28, 2025
e2f38db
feat: you can only edit or delete your own notes
EstoesMoises Jul 28, 2025
d07a7bf
chore: adding error handling for API calls and fixing colours in Note…
EstoesMoises Jul 28, 2025
9048235
fix: passing tests
EstoesMoises Jul 28, 2025
5a4c192
fix: automated test account for Notes
EstoesMoises Jul 28, 2025
7eb8d07
Merge branch 'main' into 52-notes-pane
martinjagodic Jul 31, 2025
0636dfb
fix: inverted logic for initializing notesVisible
EstoesMoises Sep 22, 2025
706ec59
fix: more robust handleBlur function for notes
EstoesMoises Sep 22, 2025
240091b
feat: notes pane feature is now opt-in depending on config
EstoesMoises Sep 22, 2025
4edb039
chore: changed icon for notesPane to quote
EstoesMoises Sep 22, 2025
f3a14ef
feat: notes now use Github issues making notes usable for both publi…
EstoesMoises Sep 23, 2025
9265a4f
fix: removed condition that would loadNotes() twice
EstoesMoises Sep 25, 2025
722cbd5
feat: lifecycle for github issues when publishing/unpublishing entries
EstoesMoises Sep 25, 2025
fc8c647
chore: format
EstoesMoises Sep 25, 2025
4b444db
chore: added tests for notes on editorialWorkflow specs
EstoesMoises Sep 25, 2025
a143fde
chore: added tests for Notes Github implementation
EstoesMoises Sep 25, 2025
28ed3d2
chore: updated config specification test to account for the editor.no…
EstoesMoises Sep 25, 2025
4ae697f
Merge branch 'main' into 52-notes-pane
EstoesMoises Sep 25, 2025
b6d5fc5
fix: github issues are only created after the first note is added
EstoesMoises Oct 23, 2025
ef8772c
fix: notes now show dates in absolute time
EstoesMoises Oct 23, 2025
35eaa4f
style: increased padding of NoteList, NoteItem and AddNoteForm
EstoesMoises Oct 23, 2025
f925c43
feat: github notes now have a convenient link to the source (github i…
EstoesMoises Oct 23, 2025
d3bc540
feat: decapNotes now have a polling system to retrieve new notes auto…
EstoesMoises Oct 28, 2025
26c6e76
fix: notes polling system now works well with first-ever Note
EstoesMoises Oct 30, 2025
ed70037
chore: adding more context to console.log of decapNotes functionality…
EstoesMoises Oct 30, 2025
d9b4276
chore: formatting
EstoesMoises Oct 30, 2025
dfda2fa
chore: fixed with correct data structure in DecapNotes test
EstoesMoises Nov 1, 2025
6813ad6
chore: added tests for decapNotes polling system
EstoesMoises Nov 1, 2025
083f398
Merge branch 'main' into 52-notes-pane
EstoesMoises Nov 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
485 changes: 485 additions & 0 deletions packages/decap-cms-backend-github/src/API.ts

Large diffs are not rendered by default.

Large diffs are not rendered by default.

205 changes: 203 additions & 2 deletions packages/decap-cms-backend-github/src/implementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {

import AuthenticationPage from './AuthenticationPage';
import API, { API_NAME } from './API';
import { ETagPollingManager } from './polling';
import GraphQLAPI from './GraphQLAPI';

import type { Octokit } from '@octokit/rest';
Expand All @@ -39,6 +40,8 @@ import type {
ImplementationFile,
UnpublishedEntryMediaFile,
Entry,
Note,
IssueChange,
} from 'decap-cms-lib-util';
import type { Semaphore } from 'semaphore';

Expand Down Expand Up @@ -90,6 +93,8 @@ export default class GitHub implements Implementation {
[key: string]: Promise<boolean>;
};
_mediaDisplayURLSem?: Semaphore;
pollingManager?: ETagPollingManager;
unwatchFunctions: Map<string, () => void> = new Map();

constructor(config: Config, options = {}) {
this.options = {
Expand Down Expand Up @@ -374,12 +379,22 @@ export default class GitHub implements Implementation {
// }
// }

if (this.api && !this.pollingManager) {
this.pollingManager = new ETagPollingManager(this.api, 15000);
}
// Authorized user
return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring };
}

logout() {
this.token = null;
// Clean up polling
if (this.pollingManager) {
this.pollingManager.destroy();
this.pollingManager = undefined;
}
this.unwatchFunctions.clear();

if (this.api && this.api.reset && typeof this.api.reset === 'function') {
return this.api.reset();
}
Expand Down Expand Up @@ -703,7 +718,15 @@ export default class GitHub implements Implementation {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
async () => {
await this.api!.deleteUnpublishedEntry(collection, slug);
// Clean up associated notes issue
try {
await this.api!.closeEntryNotesIssue(collection, slug);
} catch (error) {
console.warn('Failed to close notes issue during entry deletion:', error);
}
},
'Failed to acquire delete entry lock',
);
}
Expand All @@ -712,8 +735,186 @@ export default class GitHub implements Implementation {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
async () => {
this.api!.publishUnpublishedEntry(collection, slug),
await this.api!.closeIssueOnPublish(collection, slug);
},
'Failed to acquire publish entry lock',
);
}

// Notes implementation, which is an abstraction to Github's PR issue comments.

// Notes implementation using GitHub Issues
async getNotes(collection: string, slug: string): Promise<Note[]> {
try {
const notes = await this.api!.getEntryNotes(collection, slug);
return notes.map(note => ({ ...note, entrySlug: slug }));
} catch (error) {
console.error('Failed to get notes:', error);
return [];
}
}

async addNote(collection: string, slug: string, noteData: Omit<Note, 'id'>): Promise<Note> {
const currentUser = await this.currentUser({ token: this.token! });

const note: Note = {
...noteData,
id: 'temp-' + Date.now(),
author: currentUser.login || currentUser.name,
avatarUrl: currentUser.avatar_url,
entrySlug: slug,
timestamp: noteData.timestamp || new Date().toISOString(),
resolved: noteData.resolved || false,
issueUrl: undefined,
};

// Get entry title for better issue naming
let entryTitle: string | undefined;
try {
const entryData = await this.getEntry(`${collection}/${slug}.md`);
const titleMatch = entryData.data.match(/^title:\s*["']?([^"'\n]+)["']?/m);
entryTitle = titleMatch ? titleMatch[1] : undefined;
} catch (error) {
// Entry not found or error reading, use undefined title
}

const { commentId, issueUrl } = await this.api!.addNoteToEntry(
collection,
slug,
note,
entryTitle,
);

return {
...note,
id: commentId,
issueUrl,
};
}

async updateNote(
collection: string,
slug: string,
noteId: string,
updates: Partial<Note>,
): Promise<Note> {
const currentNotes = await this.getNotes(collection, slug);
const existingNote = currentNotes.find(note => note.id === noteId);
if (!existingNote) {
throw new Error(`Note with ID ${noteId} not found`);
}

const updatedNote: Note = {
...existingNote,
...updates,
id: noteId,
entrySlug: slug,
};

await this.api!.updateEntryNote(noteId, updatedNote);
return updatedNote;
}

async deleteNote(collection: string, slug: string, noteId: string): Promise<void> {
const currentNotes = await this.getNotes(collection, slug);
const noteExists = currentNotes.some(note => note.id === noteId);
if (!noteExists) {
throw new Error(`Note with ID ${noteId} not found`);
}

await this.api!.deleteEntryNote(noteId);
}

async toggleNoteResolution(collection: string, slug: string, noteId: string): Promise<Note> {
const currentNotes = await this.getNotes(collection, slug);
const note = currentNotes.find(n => n.id === noteId);
if (!note) {
throw new Error(`Note with ID ${noteId} not found`);
}

return this.updateNote(collection, slug, noteId, {
resolved: !note.resolved,
});
}

async reopenIssueForUnpublishedEntry(collection: string, slug: string) {
await this.api!.reopenIssueOnUnpublish(collection, slug);
}
/**
* Start watching notes for changes
* Called from Redux action
*/
async startNotesPolling(
collection: string,
slug: string,
callbacks: {
onUpdate: (notes: Note[], changes: IssueChange[]) => void;
onChange?: (change: IssueChange) => void;
},
): Promise<void> {
if (!this.pollingManager) {
console.warn('[DecapNotes Polling] Polling manager not initialized');
return;
}

const issueKey = `${collection}/${slug}`;

// Check if already watching this exact entry - if so, skip
if (this.pollingManager.getStatus().currentWatch === issueKey) {
return;
}

// First, ensure any previous polling for this entry is completely stopped
const existingUnwatch = this.unwatchFunctions.get(issueKey);
if (existingUnwatch) {
existingUnwatch();
this.unwatchFunctions.delete(issueKey);
}

try {
const unwatchFn = await this.pollingManager.watchIssueWithRetry(
collection,
slug,
callbacks,
5, // maxRetries - will try up to 5 times
2000, // retryDelay - 2 seconds between attempts
);

// Store the new unwatch function
this.unwatchFunctions.set(issueKey, unwatchFn);
} catch (error) {
console.error('[DecapNotes Polling] Failed to start polling after retries:', error);
}
}

/**
* Stop watching notes for changes
* Called from Redux action: dispatch(stopNotesPolling(collection, slug))
*
* Ensures complete cleanup of polling for this entry
*/
async stopNotesPolling(collection: string, slug: string): Promise<void> {
const issueKey = `${collection}/${slug}`;
const unwatchFn = this.unwatchFunctions.get(issueKey);

if (unwatchFn) {
unwatchFn();
this.unwatchFunctions.delete(issueKey);
} else {
console.log(`[DecapNotes Polling] No active polling found for ${issueKey}`);
}
}

/**
* Manually refresh notes (force check now)
* Called from Redux action: dispatch(refreshNotesNow(collection, slug))
*/
async refreshNotesNow(collection: string, slug: string): Promise<void> {
if (!this.pollingManager) {
throw new Error('Polling manager not initialized');
}
await this.pollingManager.checkIssueNow(collection, slug);
}
}
Loading