diff --git a/.forgejo/workflows/backport.yml b/.forgejo/workflows/backport.yml deleted file mode 100644 index 614a2099af..0000000000 --- a/.forgejo/workflows/backport.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2024 The Forgejo Authors -# SPDX-License-Identifier: MIT -# -name: issue-labels - -on: - pull_request_target: - types: - - closed - - labeled - -jobs: - backporting: - if: > - vars.ROLE == 'forgejo-coding' && - - secrets.BACKPORT_TOKEN != '' && - - github.event_name == 'pull_request_target' && - ( - github.event.pull_request.merged && - contains(toJSON(github.event.pull_request.labels), 'backport/v') - ) - runs-on: docker - container: - image: 'code.forgejo.org/oci/node:20-bookworm' - steps: - - name: Debug info - run: | - cat <<'EOF' - ${{ toJSON(github) }} - EOF - - uses: https://code.forgejo.org/actions/git-backporting@v4.8.4 - with: - target-branch-pattern: "^backport/(?(v.*))$" - strategy: ort - strategy-option: find-renames - cherry-pick-options: -x - auth: ${{ secrets.BACKPORT_TOKEN }} - pull-request: ${{ github.event.pull_request.url }} - auto-no-squash: true - enable-err-notification: true - git-user: forgejo-backport-action - git-email: forgejo-backport-action@noreply.codeberg.org diff --git a/.forgejo/workflows/cascade-setup-end-to-end.yml b/.forgejo/workflows/cascade-setup-end-to-end.yml deleted file mode 100644 index 1c12031b56..0000000000 --- a/.forgejo/workflows/cascade-setup-end-to-end.yml +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2024 The Forgejo Authors -# SPDX-License-Identifier: MIT -# -name: issue-labels - -on: - pull_request_target: - types: - - labeled - -jobs: - end-to-end: - if: > - vars.ROLE == 'forgejo-coding' && - - secrets.END_TO_END_CASCADING_PR_DESTINATION != '' && - secrets.END_TO_END_CASCADING_PR_ORIGIN != '' && - - ( - github.event_name == 'push' || - ( - github.event_name == 'pull_request_target' && - github.event.action == 'label_updated' && - github.event.label.name == 'run-end-to-end-tests' - ) - ) - runs-on: docker - container: - image: code.forgejo.org/oci/node:20-bookworm - steps: - - name: Debug info - run: | - cat <<'EOF' - ${{ toJSON(github) }} - EOF - - uses: actions/checkout@v4 - with: - fetch-depth: '0' - show-progress: 'false' - - uses: actions/cascading-pr@v2 - with: - origin-url: ${{ env.GITHUB_SERVER_URL }} - origin-repo: ${{ github.repository }} - origin-token: ${{ secrets.END_TO_END_CASCADING_PR_ORIGIN }} - origin-pr: ${{ github.event.pull_request.number }} - origin-ref: ${{ github.event_name == 'push' && github.event.ref || '' }} - destination-url: https://code.forgejo.org - destination-fork-repo: cascading-pr/end-to-end - destination-repo: forgejo/end-to-end - destination-branch: main - destination-token: ${{ secrets.END_TO_END_CASCADING_PR_DESTINATION }} - close-merge: true - update: .forgejo/cascading-pr-end-to-end diff --git a/.forgejo/workflows/issue-labels.yml b/.forgejo/workflows/issue-labels.yml new file mode 100644 index 0000000000..886d0640a0 --- /dev/null +++ b/.forgejo/workflows/issue-labels.yml @@ -0,0 +1,205 @@ +# Copyright 2024 The Forgejo Authors +# SPDX-License-Identifier: MIT +# +# To modify the pull_request_target jobs: +# +# - push it to the wip-ci-issue-labels branch on the forgejo repository +# otherwise it will not have access to the required secrets. +# +# - once it works, open a pull request for the sake of keeping track +# of the change even if the PR won't run it because it will use +# whatever is in the default branch instead +# +# - after it is merged, double check it works by changing the labels +# to trigger the job. +# +name: issue-labels + +on: + push: + branches: + - 'wip-ci-issue-labels' + + pull_request_target: + types: + - closed + - edited + - labeled + - synchronize + + pull_request: + types: + - edited + - labeled + - opened + - synchronize + +jobs: + info: + if: vars.ROLE == 'forgejo-coding' + runs-on: docker + container: + image: code.forgejo.org/oci/node:20-bookworm + steps: + - name: Debug info + run: | + cat <<'EOF' + ${{ toJSON(github) }} + EOF + + end-to-end: + if: > + vars.ROLE == 'forgejo-coding' && + + secrets.END_TO_END_CASCADING_PR_DESTINATION != '' && + secrets.END_TO_END_CASCADING_PR_ORIGIN != '' && + + ( + github.event_name == 'push' || + ( + github.event_name == 'pull_request_target' && + github.event.action == 'label_updated' && + github.event.label.name == 'run-end-to-end-tests' + ) + ) + runs-on: docker + container: + image: code.forgejo.org/oci/node:20-bookworm + steps: + - name: Debug info + run: | + cat <<'EOF' + ${{ toJSON(github) }} + EOF + - uses: actions/checkout@v4 + with: + fetch-depth: '0' + show-progress: 'false' + - uses: actions/cascading-pr@v2 + with: + origin-url: ${{ env.GITHUB_SERVER_URL }} + origin-repo: ${{ github.repository }} + origin-token: ${{ secrets.END_TO_END_CASCADING_PR_ORIGIN }} + origin-pr: ${{ github.event.pull_request.number }} + origin-ref: ${{ github.event_name == 'push' && github.event.ref || '' }} + destination-url: https://code.forgejo.org + destination-fork-repo: cascading-pr/end-to-end + destination-repo: forgejo/end-to-end + destination-branch: main + destination-token: ${{ secrets.END_TO_END_CASCADING_PR_DESTINATION }} + close-merge: true + update: .forgejo/cascading-pr-end-to-end + + backporting: + if: > + vars.ROLE == 'forgejo-coding' && + + secrets.BACKPORT_TOKEN != '' && + + github.event_name == 'pull_request_target' && + ( + github.event.pull_request.merged && + contains(toJSON(github.event.pull_request.labels), 'backport/v') + ) + runs-on: docker + container: + image: 'code.forgejo.org/oci/node:20-bookworm' + steps: + - name: Debug info + run: | + cat <<'EOF' + ${{ toJSON(github) }} + EOF + - uses: https://code.forgejo.org/actions/git-backporting@v4.8.4 + with: + target-branch-pattern: "^backport/(?(v.*))$" + strategy: ort + strategy-option: find-renames + cherry-pick-options: -x + auth: ${{ secrets.BACKPORT_TOKEN }} + pull-request: ${{ github.event.pull_request.url }} + auto-no-squash: true + enable-err-notification: true + git-user: forgejo-backport-action + git-email: forgejo-backport-action@noreply.codeberg.org + + merge-conditions: + if: > + vars.ROLE == 'forgejo-coding' && + + github.event_name == 'pull_request' && + ( + github.event.action == 'label_updated' || + github.event.action == 'edited' || + github.event.action == 'opened' + ) + runs-on: docker + container: + image: 'code.forgejo.org/oci/node:20-bookworm' + steps: + - name: Debug info + run: | + cat <<'EOF' + ${{ toJSON(github) }} + EOF + - name: Missing test label + if: > + !( + contains(toJSON(github.event.pull_request.labels), 'test/present') + || contains(toJSON(github.event.pull_request.labels), 'test/not-needed') + || contains(toJSON(github.event.pull_request.labels), 'test/manual') + ) + run: | + echo "Test label must be set to either 'present', 'not-needed' or 'manual'." + exit 1 + - name: Missing manual test instructions + if: > + ( + contains(toJSON(github.event.pull_request.labels), 'test/manual') + && !contains(toJSON(github.event.pull_request.body), '# Test') + ) + run: | + echo "Manual test label is set. The PR description needs to contain test steps introduced by a heading like:" + echo "# Testing" + exit 1 + + release-notes: + if: > + vars.ROLE == 'forgejo-coding' && + + secrets.RELEASE_NOTES_ASSISTANT_TOKEN != '' && + + github.event_name == 'pull_request_target' && + contains(github.event.pull_request.labels.*.name, 'worth a release-note') && + ( + github.event.action == 'label_updated' || + github.event.action == 'edited' || + github.event.action == 'synchronized' + ) + + runs-on: docker + container: + image: 'code.forgejo.org/oci/node:20-bookworm' + steps: + - name: Debug info + run: | + cat <<'EOF' + ${{ toJSON(github) }} + EOF + + - uses: https://code.forgejo.org/actions/checkout@v4 + + - uses: https://code.forgejo.org/actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: false + + - name: apt install jq + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get -q install -y -qq jq + + - name: release-notes-assistant preview + run: | + go run code.forgejo.org/forgejo/release-notes-assistant@v1.1.1 --config .release-notes-assistant.yaml --storage pr --storage-location ${{ github.event.pull_request.number }} --forgejo-url $GITHUB_SERVER_URL --repository $GITHUB_REPOSITORY --token ${{ secrets.RELEASE_NOTES_ASSISTANT_TOKEN }} preview ${{ github.event.pull_request.number }} diff --git a/.forgejo/workflows/merge-requirements.yml b/.forgejo/workflows/merge-requirements.yml deleted file mode 100644 index fe18aa1139..0000000000 --- a/.forgejo/workflows/merge-requirements.yml +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright 2024 The Forgejo Authors -# SPDX-License-Identifier: MIT - -name: issue-labels - -on: - pull_request: - types: - - labeled - - edited - - opened - -jobs: - merge-conditions: - if: > - vars.ROLE == 'forgejo-coding' && - - github.event_name == 'pull_request' && - ( - github.event.action == 'label_updated' || - github.event.action == 'edited' || - github.event.action == 'opened' - ) - runs-on: docker - container: - image: 'code.forgejo.org/oci/node:20-bookworm' - steps: - - name: Debug info - run: | - cat <<'EOF' - ${{ toJSON(github) }} - EOF - - name: Missing test label - if: > - !( - contains(toJSON(github.event.pull_request.labels), 'test/present') - || contains(toJSON(github.event.pull_request.labels), 'test/not-needed') - || contains(toJSON(github.event.pull_request.labels), 'test/manual') - ) - run: | - echo "Test label must be set to either 'present', 'not-needed' or 'manual'." - exit 1 - - name: Missing manual test instructions - if: > - ( - contains(toJSON(github.event.pull_request.labels), 'test/manual') - && !contains(toJSON(github.event.pull_request.body), '# Test') - ) - run: | - echo "Manual test label is set. The PR description needs to contain test steps introduced by a heading like:" - echo "# Testing" - exit 1 diff --git a/.forgejo/workflows/release-notes-assistant.yml b/.forgejo/workflows/release-notes-assistant.yml deleted file mode 100644 index 71c8d5f8da..0000000000 --- a/.forgejo/workflows/release-notes-assistant.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: issue-labels - -on: - pull_request_target: - types: - - edited - - synchronize - - labeled - -jobs: - release-notes: - if: > - vars.ROLE == 'forgejo-coding' && - - secrets.RELEASE_NOTES_ASSISTANT_TOKEN != '' && - - github.event_name == 'pull_request_target' && - contains(github.event.pull_request.labels.*.name, 'worth a release-note') && - ( - github.event.action == 'label_updated' || - github.event.action == 'edited' || - github.event.action == 'synchronized' - ) - - runs-on: docker - container: - image: 'code.forgejo.org/oci/node:20-bookworm' - steps: - - name: Debug info - run: | - cat <<'EOF' - ${{ toJSON(github) }} - EOF - - - uses: https://code.forgejo.org/actions/checkout@v4 - - - uses: https://code.forgejo.org/actions/setup-go@v5 - with: - go-version-file: "go.mod" - cache: false - - - name: apt install jq - run: | - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get -q install -y -qq jq - - - name: release-notes-assistant preview - run: | - go run code.forgejo.org/forgejo/release-notes-assistant@v1.1.1 --config .release-notes-assistant.yaml --storage pr --storage-location ${{ github.event.pull_request.number }} --forgejo-url $GITHUB_SERVER_URL --repository $GITHUB_REPOSITORY --token ${{ secrets.RELEASE_NOTES_ASSISTANT_TOKEN }} preview ${{ github.event.pull_request.number }} diff --git a/.forgejo/workflows/testing.yml b/.forgejo/workflows/testing.yml index 2f17603f78..1a304b4835 100644 --- a/.forgejo/workflows/testing.yml +++ b/.forgejo/workflows/testing.yml @@ -216,11 +216,13 @@ jobs: ldap: image: code.forgejo.org/oci/test-openldap:latest pgsql: - image: 'code.forgejo.org/oci/postgres:15' + image: code.forgejo.org/oci/bitnami/postgresql:15 env: - POSTGRES_DB: test - POSTGRES_PASSWORD: postgres - options: --tmpfs /var/lib/postgresql/data + POSTGRESQL_DATABASE: test + POSTGRESQL_PASSWORD: postgres + POSTGRESQL_FSYNC: off + POSTGRESQL_EXTRA_FLAGS: -c full_page_writes=off + options: --tmpfs /bitnami/postgresql steps: - uses: https://code.forgejo.org/actions/checkout@v4 - uses: ./.forgejo/workflows-composite/setup-env 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 17c1750661..4e1bd28774 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 note will be removed. diff.data_not_available = Diff content is not available diff.options_button = Diff options diff.show_diff_stats = Show stats @@ -3837,8 +3840,8 @@ runs.actors_no_select = All actors runs.status_no_select = All status runs.no_results = No results matched. runs.no_workflows = There are no workflows yet. -runs.no_workflows.quick_start = Don't know how to start with Forgejo Actions? See the quick start guide. -runs.no_workflows.documentation = For more information on Forgejo Actions, see the documentation. +runs.no_workflows.help_write_access = Don't know how to start with Forgejo Actions? Check out the quick start in the user documentation to write your first workflow, then set up a Forgejo runner to execute your jobs. +runs.no_workflows.help_no_write_access = To learn about Forgejo Actions, see the documentation. runs.no_runs = The workflow has no runs yet. runs.empty_commit_message = (empty commit message) runs.expire_log_message = Logs have been purged because they were too old. diff --git a/package-lock.json b/package-lock.json index 9eae33a5f5..6ae55f895d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "asciinema-player": "3.8.0", "chart.js": "4.4.5", "chartjs-adapter-dayjs-4": "1.0.4", - "chartjs-plugin-zoom": "2.0.1", + "chartjs-plugin-zoom": "2.1.0", "clippie": "4.1.1", "css-loader": "7.0.0", "dayjs": "1.11.12", @@ -4807,6 +4807,12 @@ "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", "license": "MIT" }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6479,11 +6485,12 @@ } }, "node_modules/chartjs-plugin-zoom": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.0.1.tgz", - "integrity": "sha512-ogOmLu6e+Q7E1XWOCOz9YwybMslz9qNfGV2a+qjfmqJYpsw5ZMoRHZBUyW+NGhkpQ5PwwPA/+rikHpBZb7PZuA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.1.0.tgz", + "integrity": "sha512-7lMimfQCUaIJLhPJaWSAA4gw+1m8lyR3Wn+M3MxjHbM/XxRUnOxN7cM5RR9jUmxmyW0h7L2hZ8KhvUsqrFxy/Q==", "license": "MIT", "dependencies": { + "@types/hammerjs": "^2.0.45", "hammerjs": "^2.0.8" }, "peerDependencies": { diff --git a/package.json b/package.json index 068459be14..41f1a9a246 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "asciinema-player": "3.8.0", "chart.js": "4.4.5", "chartjs-adapter-dayjs-4": "1.0.4", - "chartjs-plugin-zoom": "2.0.1", + "chartjs-plugin-zoom": "2.1.0", "clippie": "4.1.1", "css-loader": "7.0.0", "dayjs": "1.11.12", diff --git a/release-notes-assistant.sh b/release-notes-assistant.sh index afb8037c24..89fd0833f5 100755 --- a/release-notes-assistant.sh +++ b/release-notes-assistant.sh @@ -7,6 +7,7 @@ label_bug=bug label_feature=feature label_ui=forgejo/ui label_breaking=breaking +label_security=security label_localization=forgejo/i18n payload=$(mktemp) @@ -17,50 +18,71 @@ function test_main() { set -ex PS4='${BASH_SOURCE[0]}:$LINENO: ${FUNCNAME[0]}: ' + test_payload_labels $label_worth $label_breaking $label_security $label_bug + test "$(categorize)" = 'AA Breaking security bug fixes' + + test_payload_labels $label_worth $label_security $label_bug + test "$(categorize)" = 'AB Security bug fixes' + + test_payload_labels $label_worth $label_breaking $label_security $label_feature + test "$(categorize)" = 'AC Breaking security features' + + test_payload_labels $label_worth $label_security $label_feature + test "$(categorize)" = 'AD Security features' + + test_payload_labels $label_worth $label_security + test "$(categorize)" = 'ZA Security changes without a feature or bug label' + test_payload_labels $label_worth $label_breaking $label_feature - test "$(categorize)" = 'AA Breaking features' + test "$(categorize)" = 'BA Breaking features' test_payload_labels $label_worth $label_breaking $label_bug - test "$(categorize)" = 'AB Breaking bug fixes' + test "$(categorize)" = 'BB Breaking bug fixes' test_payload_labels $label_worth $label_breaking - test "$(categorize)" = 'ZC Breaking changes without a feature or bug label' + test "$(categorize)" = 'ZB Breaking changes without a feature or bug label' test_payload_labels $label_worth $label_ui $label_feature - test "$(categorize)" = 'BA User Interface features' + test "$(categorize)" = 'CA User Interface features' test_payload_labels $label_worth $label_ui $label_bug - test "$(categorize)" = 'BB User Interface bug fixes' + test "$(categorize)" = 'CB User Interface bug fixes' test_payload_labels $label_worth $label_ui - test "$(categorize)" = 'ZD User Interface changes without a feature or bug label' - - test_payload_labels $label_worth $label_feature - test "$(categorize)" = 'CA Features' - - test_payload_labels $label_worth $label_bug - test "$(categorize)" = 'CB Bug fixes' + test "$(categorize)" = 'ZC User Interface changes without a feature or bug label' test_payload_labels $label_worth $label_localization test "$(categorize)" = 'DA Localization' + test_payload_labels $label_worth $label_feature + test "$(categorize)" = 'EA Features' + + test_payload_labels $label_worth $label_bug + test "$(categorize)" = 'EB Bug fixes' + test_payload_labels $label_worth test "$(categorize)" = 'ZE Other changes without a feature or bug label' test_payload_labels test "$(categorize)" = 'ZF Included for completeness but not worth a release note' + test_payload_draft "fix(security)!: breaking security bug fix" + test "$(categorize)" = 'AA Breaking security bug fixes' + + test_payload_draft "fix(security): security bug fix" + test "$(categorize)" = 'AB Security bug fixes' + test_payload_draft "feat!: breaking feature" - test "$(categorize)" = 'AA Breaking features' + test "$(categorize)" = 'BA Breaking features' test_payload_draft "fix!: breaking bug fix" - test "$(categorize)" = 'AB Breaking bug fixes' + test "$(categorize)" = 'BB Breaking bug fixes' test_payload_draft "feat: feature" - test "$(categorize)" = 'CA Features' + test "$(categorize)" = 'EA Features' test_payload_draft "fix: bug fix" - test "$(categorize)" = 'CB Bug fixes' + test "$(categorize)" = 'EB Bug fixes' test_payload_draft "something with no prefix" test "$(categorize)" = 'ZE Other changes without a feature or bug label' @@ -109,6 +131,7 @@ function categorize() { is_feature=false is_localization=false is_breaking=false + is_security=false # # first try to figure out the category from the labels @@ -125,6 +148,12 @@ function categorize() { ;; esac + case "$labels" in + *$label_security*) + is_security=true + ;; + esac + case "$labels" in *$label_breaking*) is_breaking=true @@ -143,6 +172,15 @@ function categorize() { if ! $is_bug && ! $is_feature; then draft="$(jq --raw-output .Draft <$payload)" case "$draft" in + fix\(security\)!:*) + is_bug=true + is_breaking=true + is_security=true + ;; + fix\(security\):*) + is_bug=true + is_security=true + ;; fix!:*) is_bug=true is_breaking=true @@ -171,29 +209,45 @@ function categorize() { fi fi - if $is_breaking; then - if $is_feature; then - echo -n AA Breaking features - elif $is_bug; then - echo -n AB Breaking bug fixes + if $is_security; then + if $is_bug; then + if $is_breaking; then + echo -n AA Breaking security bug fixes + else + echo -n AB Security bug fixes + fi + elif $is_feature; then + if $is_breaking; then + echo -n AC Breaking security features + else + echo -n AD Security features + fi else - echo -n ZC Breaking changes without a feature or bug label + echo -n ZA Security changes without a feature or bug label + fi + elif $is_breaking; then + if $is_feature; then + echo -n BA Breaking features + elif $is_bug; then + echo -n BB Breaking bug fixes + else + echo -n ZB Breaking changes without a feature or bug label fi elif $is_ui; then if $is_feature; then - echo -n BA User Interface features + echo -n CA User Interface features elif $is_bug; then - echo -n BB User Interface bug fixes + echo -n CB User Interface bug fixes else - echo -n ZD User Interface changes without a feature or bug label + echo -n ZC User Interface changes without a feature or bug label fi elif $is_localization; then echo -n DA Localization else if $is_feature; then - echo -n CA Features + echo -n EA Features elif $is_bug; then - echo -n CB Bug fixes + echo -n EB Bug fixes else echo -n ZE Other changes without a feature or bug label fi 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 9880186ad2..5cd4b7c961 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1560,6 +1560,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/services/webhook/discord.go b/services/webhook/discord.go index 7741ceb10d..cd25175ea1 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -10,6 +10,7 @@ import ( "html/template" "net/http" "net/url" + "regexp" "strconv" "strings" "unicode/utf8" @@ -202,6 +203,9 @@ func (d discordConvertor) Push(p *api.PushPayload) (DiscordPayload, error) { // limit the commit message display to just the summary, otherwise it would be hard to read message := strings.TrimRight(strings.SplitN(commit.Message, "\n", 1)[0], "\r") + // Escaping markdown character + message = escapeMarkdown(message) + // a limit of 50 is set because GitHub does the same if utf8.RuneCountInString(message) > 50 { message = fmt.Sprintf("%.47s...", message) @@ -365,3 +369,40 @@ func (d discordConvertor) createPayload(s *api.User, title, text, url string, co }, } } + +var orderedListPattern = regexp.MustCompile(`(\d+)\.`) + +var markdownPatterns = map[string]*regexp.Regexp{ + "~": regexp.MustCompile(`\~(.*?)\~`), + "*": regexp.MustCompile(`\*(.*?)\*`), + "_": regexp.MustCompile(`\_(.*?)\_`), +} + +var markdownToEscape = strings.NewReplacer( + "* ", "\\* ", + "`", "\\`", + "[", "\\[", + "]", "\\]", + "(", "\\(", + ")", "\\)", + "#", "\\#", + "+ ", "\\+ ", + "- ", "\\- ", + "---", "\\---", + "!", "\\!", + "|", "\\|", + "<", "\\<", + ">", "\\>", +) + +// Escape Markdown characters +func escapeMarkdown(input string) string { + // Escaping ordered list + output := orderedListPattern.ReplaceAllString(input, "$1\\.") + + for char, pattern := range markdownPatterns { + output = pattern.ReplaceAllString(output, fmt.Sprintf(`\%s$1\%s`, char, char)) + } + + return markdownToEscape.Replace(output) +} diff --git a/services/webhook/discord_test.go b/services/webhook/discord_test.go index 680f7806a9..4edd06bd76 100644 --- a/services/webhook/discord_test.go +++ b/services/webhook/discord_test.go @@ -94,6 +94,20 @@ func TestDiscordPayload(t *testing.T) { assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) + t.Run("PushWithMarkdownCharactersInCommitMessage", func(t *testing.T) { + p := pushTestEscapeCommitMessagePayload() + + pl, err := dc.Push(p) + require.NoError(t, err) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo:test] 2 new commits", pl.Embeds[0].Title) + assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) \\# conflicts\n\\# \\- some/conflicting/file.txt - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) \\# conflicts\n\\# \\- some/conflicting/file.txt - user1", pl.Embeds[0].Description) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) + }) + t.Run("Issue", func(t *testing.T) { p := issueTestPayload() @@ -346,3 +360,89 @@ func TestDiscordJSONPayload(t *testing.T) { require.NoError(t, err) assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Embeds[0].Description) } + +var escapedMarkdownTests = map[string]struct { + input string + expected string +}{ + "Escape heading level 1": { + input: "# Heading level 1", + expected: "\\# Heading level 1", + }, + "Escape heading level 2": { + input: "## Heading level 2", + expected: "\\#\\# Heading level 2", + }, + "Escape heading level 3": { + input: "### Heading level 3", + expected: "\\#\\#\\# Heading level 3", + }, + "Escape bold text": { + input: "**bold text**", + expected: "\\*\\*bold text\\*\\*", + }, + "Escape italic text": { + input: "*italic text*", + expected: "\\*italic text\\*", + }, + "Escape italic text underline": { + input: "_italic text_", + expected: "\\_italic text\\_", + }, + "Escape strikethrough": { + input: "~~strikethrough~~", + expected: "\\~\\~strikethrough\\~\\~", + }, + "Escape Ordered list item": { + input: "1. Ordered list item\n2. Second ordered list item\n999999999999. 999999999999 ordered list item", + expected: "1\\. Ordered list item\n2\\. Second ordered list item\n999999999999\\. 999999999999 ordered list item", + }, + "Escape Unordered list item": { + input: "- Unordered list\n + using plus", + expected: "\\- Unordered list\n \\+ using plus", + }, + "Escape bullet list item": { + input: "* Bullet list item", + expected: "\\* Bullet list item", + }, + "Escape table": { + input: "| Table | Example |\n|-|-|\n| Lorem | Ipsum |", + expected: "\\| Table \\| Example \\|\n\\|-\\|-\\|\n\\| Lorem \\| Ipsum \\|", + }, + "Escape link": { + input: "[Link to Forgejo](https://forgejo.org/)", + expected: "\\[Link to Forgejo\\]\\(https://forgejo.org/\\)", + }, + "Escape Alt text for an image": { + input: "![Alt text for an image](https://forgejo.org/_astro/mascot-dark.1omhhgvT_Zm0N2n.webp)", + expected: "\\!\\[Alt text for an image\\]\\(https://forgejo.org/\\_astro/mascot-dark.1omhhgvT\\_Zm0N2n.webp\\)", + }, + "Escape URL if it has markdown character": { + input: "https://forgejo.org/_astro/mascot-dark.1omhhgvT_Zm0N2n.webp", + expected: "https://forgejo.org/\\_astro/mascot-dark.1omhhgvT\\_Zm0N2n.webp", + }, + "Escape blockquote text": { + input: "> Blockquote text.", + expected: "\\> Blockquote text.", + }, + "Escape inline code": { + input: "`Inline code`", + expected: "\\`Inline code\\`", + }, + "Escape multiple code": { + input: "```\nCode block\nwith multiple lines\n```\n", + expected: "\\`\\`\\`\nCode block\nwith multiple lines\n\\`\\`\\`\n", + }, + "Escape horizontal rule": { + input: "---", + expected: "\\---", + }, +} + +func TestEscapeMarkdownChar(t *testing.T) { + for name, test := range escapedMarkdownTests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.expected, escapeMarkdown(test.input)) + }) + } +} diff --git a/services/webhook/general_test.go b/services/webhook/general_test.go index 6dcd787fab..8412293708 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -72,6 +72,10 @@ func pushTestMultilineCommitMessagePayload() *api.PushPayload { return pushTestPayloadWithCommitMessage("This is a commit summary ⚠️⚠️⚠️⚠️ containing 你好 ⚠️⚠️️\n\nThis is the message body.") } +func pushTestEscapeCommitMessagePayload() *api.PushPayload { + return pushTestPayloadWithCommitMessage("# conflicts\n# - some/conflicting/file.txt") +} + func pushTestPayloadWithCommitMessage(message string) *api.PushPayload { commit := &api.PayloadCommit{ ID: "2020558fe2e34debb818a514715839cabd25e778", diff --git a/templates/repo/actions/no_workflows.tmpl b/templates/repo/actions/no_workflows.tmpl index 88d6e513ef..fb3a77fb9a 100644 --- a/templates/repo/actions/no_workflows.tmpl +++ b/templates/repo/actions/no_workflows.tmpl @@ -2,7 +2,8 @@ {{svg "octicon-no-entry" 48}}

{{ctx.Locale.Tr "actions.runs.no_workflows"}}

{{if and .CanWriteCode .CanWriteActions}} -

{{ctx.Locale.Tr "actions.runs.no_workflows.quick_start" "https://forgejo.org/docs/latest/admin/actions/"}}

+

{{ctx.Locale.Tr "actions.runs.no_workflows.help_write_access" "https://forgejo.org/docs/latest/user/actions/#quick-start" "https://forgejo.org/docs/latest/admin/runner-installation/"}}

+ {{else}} +

{{ctx.Locale.Tr "actions.runs.no_workflows.help_no_write_access" "https://forgejo.org/docs/latest/user/actions/"}}

{{end}} -

{{ctx.Locale.Tr "actions.runs.no_workflows.documentation" "https://forgejo.org/docs/latest/admin/actions/"}}

diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index a25b450dbe..c7361663b2 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -128,6 +128,9 @@ +
+ {{ctx.Locale.Tr "repo.diff.git-notes.add"}} +
{{end}} @@ -275,10 +278,60 @@ {{.NoteCommit.Author.Name}} {{end}} {{DateUtils.TimeSince .NoteCommit.Author.When}} + {{if and ($.Permission.CanWrite $.UnitTypeCode) (not $.Repository.IsArchived) (not .IsDeleted)}} +
+ + +
+ + {{end}} -
+
{{.NoteRendered | SanitizeHTML}}
+ {{if and ($.Permission.CanWrite $.UnitTypeCode) (not $.Repository.IsArchived) (not .IsDeleted)}} +
+
+ {{.CsrfTokenHtml}} + +
+ +
+ +
+ +
+
+
+ {{end}} + {{else if and ($.Permission.CanWrite $.UnitTypeCode) (not $.Repository.IsArchived) (not .IsDeleted)}} +
+
+ {{.CsrfTokenHtml}} + +
+ +
+ +
+ +
+
+
{{end}} {{template "repo/diff/box" .}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8e64c68f89..3e3838ccc2 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7375,6 +7375,101 @@ "$ref": "#/responses/validationError" } } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Set a note corresponding to a single commit from a repository", + "operationId": "repoSetNote", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "a git ref or commit sha", + "name": "sha", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/NoteOptions" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Note" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Removes a note corresponding to a single commit from a repository", + "operationId": "repoRemoveNote", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "a git ref or commit sha", + "name": "sha", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } } }, "/repos/{owner}/{repo}/git/refs": { @@ -24601,6 +24696,16 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "NoteOptions": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NotificationCount": { "description": "NotificationCount number of unread notifications", "type": "object", @@ -28350,7 +28455,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/SetUserQuotaGroupsOptions" + "$ref": "#/definitions/NoteOptions" } }, "quotaExceeded": { diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl index 52036d9a38..5120bbde03 100644 --- a/templates/user/settings/profile.tmpl +++ b/templates/user/settings/profile.tmpl @@ -51,10 +51,12 @@ -
- -

{{.SignedUser.Email}}

-
+ {{if not .SignedUser.KeepEmailPrivate}} +
+ +

{{.SignedUser.Email}}

+
+ {{end}}
{{template "shared/combomarkdowneditor" (dict diff --git a/tests/e2e/git-notes.test.e2e.ts b/tests/e2e/git-notes.test.e2e.ts new file mode 100644 index 0000000000..c0dc1618db --- /dev/null +++ b/tests/e2e/git-notes.test.e2e.ts @@ -0,0 +1,30 @@ +// @ts-check +import {test, expect} from '@playwright/test'; +import {login_user, load_logged_in_context} from './utils_e2e.ts'; + +test.beforeAll(async ({browser}, workerInfo) => { + await login_user(browser, workerInfo, 'user2'); +}); + +test('Change git note', async ({browser}, workerInfo) => { + const context = await load_logged_in_context(browser, workerInfo, 'user2'); + const page = await context.newPage(); + let response = await page.goto('/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d'); + expect(response?.status()).toBe(200); + + await page.locator('#commit-notes-edit-button').click(); + + let textarea = page.locator('textarea[name="notes"]'); + await expect(textarea).toBeVisible(); + await textarea.fill('This is a new note'); + + await page.locator('#notes-save-button').click(); + + expect(response?.status()).toBe(200); + + response = await page.goto('/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d'); + expect(response?.status()).toBe(200); + + textarea = page.locator('textarea[name="notes"]'); + await expect(textarea).toHaveText('This is a new note'); +}); diff --git a/tests/integration/api_repo_git_notes_test.go b/tests/integration/api_repo_git_notes_test.go index 9f3e927077..1b5e5d652c 100644 --- a/tests/integration/api_repo_git_notes_test.go +++ b/tests/integration/api_repo_git_notes_test.go @@ -4,11 +4,13 @@ package integration import ( + "fmt" "net/http" "net/url" "testing" auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" @@ -16,7 +18,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAPIReposGitNotes(t *testing.T) { +func TestAPIReposGetGitNotes(t *testing.T) { onGiteaRun(t, func(*testing.T, *url.URL) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // Login as User2. @@ -44,3 +46,53 @@ func TestAPIReposGitNotes(t *testing.T) { assert.NotNil(t, apiData.Commit.RepoCommit.Verification) }) } + +func TestAPIReposSetGitNotes(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", repo.FullName()) + resp := MakeRequest(t, req, http.StatusOK) + var apiData api.Note + DecodeJSON(t, resp, &apiData) + assert.Equal(t, "This is a test note\n", apiData.Message) + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", repo.FullName()), &api.NoteOptions{ + Message: "This is a new note", + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiData) + assert.Equal(t, "This is a new note\n", apiData.Message) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", repo.FullName()) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiData) + assert.Equal(t, "This is a new note\n", apiData.Message) + }) +} + +func TestAPIReposDeleteGitNotes(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", repo.FullName()) + resp := MakeRequest(t, req, http.StatusOK) + var apiData api.Note + DecodeJSON(t, resp, &apiData) + assert.Equal(t, "This is a test note\n", apiData.Message) + + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", repo.FullName()).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", repo.FullName()) + MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/tests/integration/repo_git_note_test.go b/tests/integration/repo_git_note_test.go new file mode 100644 index 0000000000..6ad57c81b0 --- /dev/null +++ b/tests/integration/repo_git_note_test.go @@ -0,0 +1,72 @@ +package integration + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRepoModifyGitNotes(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d") + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "
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") + }) + }) +} + +func TestRepoGitNotesButtonsVisible(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + t.Run("With Permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Body.String(), "id=\"commit-notes-edit-button\"") + assert.Contains(t, resp.Body.String(), "data-modal=\"#delete-note-modal\"") + }) + + t.Run("Without Permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d") + resp := MakeRequest(t, req, http.StatusOK) + + assert.NotContains(t, resp.Body.String(), "id=\"commit-notes-edit-button\"") + assert.NotContains(t, resp.Body.String(), "data-modal=\"#delete-note-modal\"") + }) + }) +} diff --git a/tests/integration/setting_test.go b/tests/integration/setting_test.go index 29615b3ecf..4677770fed 100644 --- a/tests/integration/setting_test.go +++ b/tests/integration/setting_test.go @@ -1,4 +1,5 @@ // Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration @@ -156,3 +157,23 @@ func TestSettingSecurityAuthSource(t *testing.T) { assert.Contains(t, resp.Body.String(), `gitlab-active`) assert.Contains(t, resp.Body.String(), `gitlab-inactive`) } + +func TestSettingShowUserEmailSettings(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // user1: keep_email_private = false, user2: keep_email_private = true + + // user1 can see own visible email + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/user/settings") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.doc.Find("#signed-user-email").Text(), "user1@example.com") + + // user2 cannot see own hidden email + session = loginUser(t, "user2") + req = NewRequest(t, "GET", "/user/settings") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + assert.NotContains(t, htmlDoc.doc.Find("#signed-user-email").Text(), "user2@example.com") +} diff --git a/web_src/js/features/repo-commit.js b/web_src/js/features/repo-commit.js index f61ea08a42..988d57b891 100644 --- a/web_src/js/features/repo-commit.js +++ b/web_src/js/features/repo-commit.js @@ -25,3 +25,20 @@ 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', () => { + 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 61469c07ff..dfd067c469 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(() => { initRepoRecentCommits(); initCommitStatuses(); + initCommitNotes(); initCaptcha(); initUserAuthOauth2();