diff --git a/modules/git/notes.go b/modules/git/notes.go index ee628c0436..54f4d714e2 100644 --- a/modules/git/notes.go +++ b/modules/git/notes.go @@ -6,6 +6,7 @@ package git import ( "context" "io" + "os" "strings" "code.gitea.io/gitea/modules/log" @@ -97,3 +98,41 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) return nil } + +func SetNote(ctx context.Context, repo *Repository, commitID, notes, doerName, doerEmail string) error { + _, err := repo.GetCommit(commitID) + if err != nil { + return err + } + + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+doerName, + "GIT_AUTHOR_EMAIL="+doerEmail, + "GIT_COMMITTER_NAME="+doerName, + "GIT_COMMITTER_EMAIL="+doerEmail, + ) + + cmd := NewCommand(ctx, "notes", "add", "-f", "-m") + cmd.AddDynamicArguments(notes, commitID) + + _, stderr, err := cmd.RunStdString(&RunOpts{Dir: repo.Path, Env: env}) + if err != nil { + log.Error("Error while running git notes add: %s", stderr) + return err + } + + return nil +} + +func RemoveNote(ctx context.Context, repo *Repository, commitID string) error { + cmd := NewCommand(ctx, "notes", "remove") + cmd.AddDynamicArguments(commitID) + + _, stderr, err := cmd.RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + log.Error("Error while running git notes remove: %s", stderr) + return err + } + + return nil +} diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go index bbb16ccb14..cb9f39b93a 100644 --- a/modules/git/notes_test.go +++ b/modules/git/notes_test.go @@ -1,25 +1,38 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package git_test import ( "context" + "os" "path/filepath" "testing" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const ( + testReposDir = "tests/repos/" +) + +// openRepositoryWithDefaultContext opens the repository at the given path with DefaultContext. +func openRepositoryWithDefaultContext(repoPath string) (*git.Repository, error) { + return git.OpenRepository(git.DefaultContext, repoPath) +} + func TestGetNotes(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) require.NoError(t, err) defer bareRepo1.Close() - note := Note{} - err = GetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", ¬e) + note := git.Note{} + err = git.GetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", ¬e) require.NoError(t, err) assert.Equal(t, []byte("Note contents\n"), note.Message) assert.Equal(t, "Vladimir Panteleev", note.Commit.Author.Name) @@ -31,11 +44,11 @@ func TestGetNestedNotes(t *testing.T) { require.NoError(t, err) defer repo.Close() - note := Note{} - err = GetNote(context.Background(), repo, "3e668dbfac39cbc80a9ff9c61eb565d944453ba4", ¬e) + note := git.Note{} + err = git.GetNote(context.Background(), repo, "3e668dbfac39cbc80a9ff9c61eb565d944453ba4", ¬e) require.NoError(t, err) assert.Equal(t, []byte("Note 2"), note.Message) - err = GetNote(context.Background(), repo, "ba0a96fa63532d6c5087ecef070b0250ed72fa47", ¬e) + err = git.GetNote(context.Background(), repo, "ba0a96fa63532d6c5087ecef070b0250ed72fa47", ¬e) require.NoError(t, err) assert.Equal(t, []byte("Note 1"), note.Message) } @@ -46,8 +59,48 @@ func TestGetNonExistentNotes(t *testing.T) { require.NoError(t, err) defer bareRepo1.Close() - note := Note{} - err = GetNote(context.Background(), bareRepo1, "non_existent_sha", ¬e) + note := git.Note{} + err = git.GetNote(context.Background(), bareRepo1, "non_existent_sha", ¬e) require.Error(t, err) - assert.IsType(t, ErrNotExist{}, err) + assert.IsType(t, git.ErrNotExist{}, err) +} + +func TestSetNote(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + tempDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + require.NoError(t, unittest.CopyDir(bareRepo1Path, filepath.Join(tempDir, "repo1"))) + + bareRepo1, err := openRepositoryWithDefaultContext(filepath.Join(tempDir, "repo1")) + require.NoError(t, err) + defer bareRepo1.Close() + + require.NoError(t, git.SetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", "This is a new note", "Test", "test@test.com")) + + note := git.Note{} + err = git.GetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", ¬e) + require.NoError(t, err) + assert.Equal(t, []byte("This is a new note\n"), note.Message) + assert.Equal(t, "Test", note.Commit.Author.Name) +} + +func TestRemoveNote(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + tempDir := t.TempDir() + + require.NoError(t, unittest.CopyDir(bareRepo1Path, filepath.Join(tempDir, "repo1"))) + + bareRepo1, err := openRepositoryWithDefaultContext(filepath.Join(tempDir, "repo1")) + require.NoError(t, err) + defer bareRepo1.Close() + + require.NoError(t, git.RemoveNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653")) + + note := git.Note{} + err = git.GetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", ¬e) + require.Error(t, err) + assert.IsType(t, git.ErrNotExist{}, err) } diff --git a/modules/structs/repo_note.go b/modules/structs/repo_note.go index 4eaf5a255d..76c6c17898 100644 --- a/modules/structs/repo_note.go +++ b/modules/structs/repo_note.go @@ -8,3 +8,7 @@ type Note struct { Message string `json:"message"` Commit *Commit `json:"commit"` } + +type NoteOptions struct { + Message string `json:"message"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4423832d2a..856a835b32 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2622,6 +2622,9 @@ diff.browse_source = Browse source diff.parent = parent diff.commit = commit diff.git-notes = Notes +diff.git-notes.add = Add Note +diff.git-notes.remove-header = Remove Note +diff.git-notes.remove-body = This will remove this Note diff.data_not_available = Diff content is not available diff.options_button = Diff options diff.show_diff_stats = Show stats diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 718b27aeef..4fe10d8a00 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1316,7 +1316,11 @@ func Routes() *web.Route { m.Get("/trees/{sha}", repo.GetTree) m.Get("/blobs/{sha}", repo.GetBlob) m.Get("/tags/{sha}", repo.GetAnnotatedTag) - m.Get("/notes/{sha}", repo.GetNote) + m.Group("/notes/{sha}", func() { + m.Get("", repo.GetNote) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), bind(api.NoteOptions{}), repo.SetNote) + m.Delete("", reqToken(), reqRepoWriter(unit.TypeCode), repo.RemoveNote) + }) }, context.ReferencesGitRepo(true), reqRepoReader(unit.TypeCode)) m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.ApplyDiffPatch) m.Group("/contents", func() { diff --git a/routers/api/v1/repo/notes.go b/routers/api/v1/repo/notes.go index a4a1d4eab7..9ed78ce80f 100644 --- a/routers/api/v1/repo/notes.go +++ b/routers/api/v1/repo/notes.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -102,3 +103,107 @@ func getNote(ctx *context.APIContext, identifier string) { apiNote := api.Note{Message: string(note.Message), Commit: cmt} ctx.JSON(http.StatusOK, apiNote) } + +// SetNote Sets a note corresponding to a single commit from a repository +func SetNote(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/git/notes/{sha} repository repoSetNote + // --- + // summary: Set a note corresponding to a single commit from a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: sha + // in: path + // description: a git ref or commit sha + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/NoteOptions" + // responses: + // "200": + // "$ref": "#/responses/Note" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + sha := ctx.Params(":sha") + if !git.IsValidRefPattern(sha) { + ctx.Error(http.StatusUnprocessableEntity, "no valid ref or sha", fmt.Sprintf("no valid ref or sha: %s", sha)) + return + } + + form := web.GetForm(ctx).(*api.NoteOptions) + + err := git.SetNote(ctx, ctx.Repo.GitRepo, sha, form.Message, ctx.Doer.Name, ctx.Doer.GetEmail()) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(sha) + } else { + ctx.Error(http.StatusInternalServerError, "SetNote", err) + } + return + } + + getNote(ctx, sha) +} + +// RemoveNote Removes a note corresponding to a single commit from a repository +func RemoveNote(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/git/notes/{sha} repository repoRemoveNote + // --- + // summary: Removes a note corresponding to a single commit from a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: sha + // in: path + // description: a git ref or commit sha + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + sha := ctx.Params(":sha") + if !git.IsValidRefPattern(sha) { + ctx.Error(http.StatusUnprocessableEntity, "no valid ref or sha", fmt.Sprintf("no valid ref or sha: %s", sha)) + return + } + + err := git.RemoveNote(ctx, ctx.Repo.GitRepo, sha) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(sha) + } else { + ctx.Error(http.StatusInternalServerError, "RemoveNote", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 3034b09ce3..1dccf92d82 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -231,4 +231,7 @@ type swaggerParameterBodies struct { // in:body SetUserQuotaGroupsOptions api.SetUserQuotaGroupsOptions + + // in:body + NoteOptions api.NoteOptions } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 1428238074..a06da71429 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -27,7 +27,9 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/gitdiff" git_service "code.gitea.io/gitea/services/repository" ) @@ -467,3 +469,29 @@ func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) []*git_mo } return commits } + +func SetCommitNotes(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CommitNotesForm) + + commitID := ctx.Params(":sha") + + err := git.SetNote(ctx, ctx.Repo.GitRepo, commitID, form.Notes, ctx.Doer.Name, ctx.Doer.GetEmail()) + if err != nil { + ctx.ServerError("SetNote", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/commit/%s", ctx.Repo.Repository.HTMLURL(), commitID)) +} + +func RemoveCommitNotes(ctx *context.Context) { + commitID := ctx.Params(":sha") + + err := git.RemoveNote(ctx, ctx.Repo.GitRepo, commitID) + if err != nil { + ctx.ServerError("RemoveNotes", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/commit/%s", ctx.Repo.Repository.HTMLURL(), commitID)) +} diff --git a/routers/web/web.go b/routers/web/web.go index ecdd5d8d92..d3b50b873c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1559,6 +1559,10 @@ func registerRoutes(m *web.Route) { m.Get("/graph", repo.Graph) m.Get("/commit/{sha:([a-f0-9]{4,64})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) m.Get("/commit/{sha:([a-f0-9]{4,64})$}/load-branches-and-tags", repo.LoadBranchesAndTags) + m.Group("/commit/{sha:([a-f0-9]{4,64})$}/notes", func() { + m.Post("", web.Bind(forms.CommitNotesForm{}), repo.SetCommitNotes) + m.Post("/remove", repo.RemoveCommitNotes) + }, reqSignIn, reqRepoCodeWriter) m.Get("/cherry-pick/{sha:([a-f0-9]{4,64})$}", repo.SetEditorconfigIfExists, repo.CherryPick) }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 58c34473aa..f6e184fcb6 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -749,3 +749,7 @@ func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding. ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +type CommitNotesForm struct { + Notes string +} diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index a25b450dbe..aaec11385d 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -275,10 +275,61 @@ {{.NoteCommit.Author.Name}} {{end}} {{DateUtils.TimeSince .NoteCommit.Author.When}} + {{if or ($.Permission.CanWrite $.UnitTypeCode) (not $.Repository.IsArchived) (not .IsDeleted)}} +
{{ctx.Locale.Tr "repo.diff.git-notes.remove-body"}}
+{{.NoteRendered | SanitizeHTML}}
This is a test note\n") + assert.Contains(t, resp.Body.String(), "commit-notes-display-area") + + t.Run("Set", func(t *testing.T) { + req = NewRequestWithValues(t, "POST", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/notes", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1"), + "notes": "This is a new note", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d") + resp = MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "
This is a new note\n") + assert.Contains(t, resp.Body.String(), "commit-notes-display-area") + }) + + t.Run("Delete", func(t *testing.T) { + req = NewRequestWithValues(t, "POST", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/notes/remove", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1"), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d") + resp = MakeRequest(t, req, http.StatusOK) + assert.NotContains(t, resp.Body.String(), "commit-notes-display-area") + }) + }) +} diff --git a/web_src/js/features/repo-commit.js b/web_src/js/features/repo-commit.js index f61ea08a42..88887d1110 100644 --- a/web_src/js/features/repo-commit.js +++ b/web_src/js/features/repo-commit.js @@ -25,3 +25,21 @@ export function initCommitStatuses() { }); } } + +export function initCommitNotes() { + const notesEditButton = document.getElementById('commit-notes-edit-button'); + if (notesEditButton !== null) { + notesEditButton.addEventListener('click', () => { + document.getElementById('commit-notes-display-area').classList.add('tw-hidden'); + document.getElementById('commit-notes-edit-area').classList.remove('tw-hidden'); + }); + } + + const notesAddButton = document.getElementById('commit-notes-add-button'); + if (notesAddButton !== null) { + notesAddButton.addEventListener('click', () => { + notesAddButton.classList.add('tw-hidden'); + document.getElementById('commit-notes-add-area').classList.remove('tw-hidden'); + }); + } +} diff --git a/web_src/js/index.js b/web_src/js/index.js index 80aff9e59e..bab1abfa36 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -33,7 +33,7 @@ import { initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler, } from './features/repo-issue.js'; -import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.js'; +import {initRepoEllipsisButton, initCommitStatuses, initCommitNotes} from './features/repo-commit.js'; import { initFootLanguageMenu, initGlobalButtonClickOnEnter, @@ -179,6 +179,7 @@ onDomReady(() => { initRepoMilestoneEditor(); initCommitStatuses(); + initCommitNotes(); initCaptcha(); initUserAuthOauth2();