Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 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
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
142 changes: 142 additions & 0 deletions packages/decap-cms-backend-github/src/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ import type {
PersistOptions,
FetchError,
ApiRequest,
Note,
} from 'decap-cms-lib-util';
import type { Semaphore } from 'semaphore';
import type { Octokit } from '@octokit/rest';
import type { GitHubIssueComment } from './types/githubPullRequests';

type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
type GitCreateTreeParamsTree = Octokit.GitCreateTreeParamsTree;
Expand Down Expand Up @@ -1474,4 +1476,144 @@ export default class API {
const pullRequest = await this.getBranchPullRequest(branch);
return pullRequest.head.sha;
}

/**
* Constants for note formatting to aid with PR comment to note conversion
*/
private static readonly NOTE_STATUS_RESOLVED = 'RESOLVED';
private static readonly NOTE_STATUS_OPEN = 'OPEN';
// 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)
private static readonly NOTE_REGEX =
/^<!-- DecapCMS Note - Status: (RESOLVED|OPEN) -->([\s\S]+)$/;

private async getPRComments(prNumber: string | number): Promise<GitHubIssueComment[]> {
try {
const response: GitHubIssueComment[] = await this.request(
`${this.originRepoURL}/issues/${prNumber}/comments`,
);
return Array.isArray(response) ? response : [];
} catch (error) {
console.error('Failed to get PR comments:', error);
return [];
}
}

/**
* Format a note for PR comment display
*/
private formatNoteForPR(note: Note): string {
const status = note.resolved ? API.NOTE_STATUS_RESOLVED : API.NOTE_STATUS_OPEN;

return `<!-- DecapCMS Note - Status: ${status} -->
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The template literal has trailing spaces after the HTML comment. This could cause inconsistent formatting. Consider removing trailing whitespace or using a more structured approach.

Suggested change
return `<!-- DecapCMS Note - Status: ${status} -->
return `<!-- DecapCMS Note - Status: ${status} -->

Copilot uses AI. Check for mistakes.

${note.content}`;
}

/**
* Parse a GitHub comment into a Note object
*/
private parseCommentToNote(comment: GitHubIssueComment): Note {
if (!comment || !comment.body || !comment.user) {
throw new Error('Invalid comment structure');
}

const structuredMatch = comment.body.match(API.NOTE_REGEX);

const content = structuredMatch ? structuredMatch[2].trim() : comment.body;
const resolved = structuredMatch ? structuredMatch[1] === API.NOTE_STATUS_RESOLVED : false;

if (!content.trim()) {
throw new Error('Empty note content');
}

return {
id: comment.id.toString(),
author: comment.user.login,
avatarUrl: comment.user.avatar_url,
timestamp: comment.created_at,
content,
resolved,
entrySlug: '',
};
}

async getNotesFromPR(prNumber: string | number): Promise<Note[]> {
const comments = await this.getPRComments(prNumber);
return comments.map(comment => this.parseCommentToNote(comment));
}

async createPRComment(prNumber: string | number, note: Note): Promise<string> {
try {
const response: GitHubIssueComment = await this.request(
`${this.originRepoURL}/issues/${prNumber}/comments`,
{
method: 'POST',
body: JSON.stringify({
body: this.formatNoteForPR(note),
}),
},
);

return response.id.toString();
} catch (error) {
console.error('Failed to create PR comment:', error);
throw new APIError('Failed to create note', error.status || 500, API_NAME);
}
}

async updatePRComment(commentId: string | number, note: Note): Promise<void> {
try {
await this.request(`${this.originRepoURL}/issues/comments/${commentId}`, {
method: 'PATCH',
body: JSON.stringify({
body: this.formatNoteForPR(note),
}),
});
} catch (error) {
console.error('Failed to update PR comment:', error);
throw new APIError('Failed to update note', error.status || 500, API_NAME);
}
}

async deletePRComment(commentId: string | number): Promise<void> {
try {
await this.request(`${this.originRepoURL}/issues/comments/${commentId}`, {
method: 'DELETE',
});
} catch (error) {
console.error('Failed to delete PR comment:', error);
throw new APIError('Failed to delete note', error.status || 500, API_NAME);
}
}

/**
* Get PR metadata from branch name
*/
async getPRMetadataFromBranch(branchName: string): Promise<{
id: string;
url: string;
author: string;
createdAt: string;
} | null> {
try {
const response: GitHubPull[] = await this.request(`${this.originRepoURL}/pulls`, {
params: {
head: await this.getHeadReference(branchName),
state: 'open',
},
});

const pr = response[0];
if (!pr) return null;

return {
id: pr.number.toString(),
url: pr.html_url,
author: pr.user?.login || 'unknown',
createdAt: pr.created_at,
};
} catch (error) {
console.error('Failed to get PR metadata:', error);
return null;
}
}
}
124 changes: 124 additions & 0 deletions packages/decap-cms-backend-github/src/implementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
ImplementationFile,
UnpublishedEntryMediaFile,
Entry,
Note,
} from 'decap-cms-lib-util';
import type { Semaphore } from 'semaphore';

Expand Down Expand Up @@ -716,4 +717,127 @@ export default class GitHub implements Implementation {
'Failed to acquire publish entry lock',
);
}

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

/**
* Helper method to get PR info for an entry
*/
private async getPRInfo(collection: string, slug: string) {
const contentKey = this.api!.generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.api!.getBranchPullRequest(branch);

return {
branch,
pullRequest,
hasPR: pullRequest.number !== -1,
};
}

async getNotes(collection: string, slug: string): Promise<Note[]> {
if (!this.options.useWorkflow) {
return [];
}
try {
const { pullRequest, hasPR } = await this.getPRInfo(collection, slug);

if (!hasPR) {
return [];
}

const notes = await this.api!.getNotesFromPR(pullRequest.number);
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> {
if (!this.options.useWorkflow) {
throw new Error('Notes are only available when workflow is enabled.');
}

const { pullRequest, hasPR } = await this.getPRInfo(collection, slug);

if (!hasPR) {
throw new Error('Cannot add notes to draft entries. Please submit for review first.');
}

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(),
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The noteData.timestamp is being used as a fallback, but the Note interface shows timestamp as a required field. Either the interface should mark it as optional or this fallback should be removed to ensure consistency.

Copilot uses AI. Check for mistakes.

resolved: noteData.resolved || false,
};

const commentId = await this.api!.createPRComment(pullRequest.number, note);
return { ...note, id: commentId };
}

async updateNote(
collection: string,
slug: string,
noteId: string,
updates: Partial<Note>,
): Promise<Note> {
if (!this.options.useWorkflow) {
throw new Error('Notes are only available when workflow is enabled.');
}

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!.updatePRComment(noteId, updatedNote);
return updatedNote;
}

async deleteNote(collection: string, slug: string, noteId: string): Promise<void> {
if (!this.options.useWorkflow) {
throw new Error('Notes are only available when workflow is enabled.');
}

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!.deletePRComment(noteId);
}

async toggleNoteResolution(collection: string, slug: string, noteId: string): Promise<Note> {
if (!this.options.useWorkflow) {
throw new Error('Notes are only available when workflow is enabled.');
}

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,
});
}
}
18 changes: 18 additions & 0 deletions packages/decap-cms-backend-github/src/types/githubPullRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
type GitHubIssueComment = {
id: number;
node_id: string;
url: string;
html_url: string;
body: string;
user: {
login: string;
id: number;
avatar_url: string;
html_url: string;
};
created_at: string;
updated_at: string;
author_association: string;
};

export type { GitHubIssueComment };
Loading
Loading