mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-23 19:47:07 +09:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b140311d54 | ||
|
|
2b0aa6cfd2 | ||
|
|
9d1d5ceddb | ||
|
|
9141a6386f | ||
|
|
a814cf7567 | ||
|
|
2fd12d5f76 | ||
|
|
640b528580 | ||
|
|
3e5256b6ca | ||
|
|
3996c2c520 | ||
|
|
716295015b | ||
|
|
f50a5d0dab | ||
|
|
bc3e228e78 | ||
|
|
193c676946 | ||
|
|
977c9181ad | ||
|
|
197e761471 | ||
|
|
01db50f0bf | ||
|
|
4b743d2e8c | ||
|
|
31653bdb34 | ||
|
|
7365946851 | ||
|
|
254e18107e | ||
|
|
290568a07b | ||
|
|
b758396619 | ||
|
|
9bc4ce88ee | ||
|
|
9bb9a51623 | ||
|
|
5838cc8172 |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
- uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||||
gitea_fork: giteabot/gitea
|
gitea_fork: giteabot/gitea
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ on:
|
|||||||
checks:
|
checks:
|
||||||
description: Comma-separated list of non-backport checks to run
|
description: Comma-separated list of non-backport checks to run
|
||||||
required: false
|
required: false
|
||||||
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
default: backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
statuses: write
|
statuses: write
|
||||||
steps:
|
steps:
|
||||||
- uses: go-gitea/giteabot@8033358e118c0bd97f5e9902dc5d6633eadc6aeb # v1.2.1
|
- uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||||
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
checks: ${{ github.event.inputs.checks || 'backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
||||||
@@ -3083,7 +3083,7 @@ LEVEL = Info
|
|||||||
;LOG_RETENTION_DAYS = 365
|
;LOG_RETENTION_DAYS = 365
|
||||||
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
|
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
|
||||||
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
|
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
|
||||||
;; Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step.
|
;; Workflows can request a shorter retention period, down to 1 day.
|
||||||
;ARTIFACT_RETENTION_DAYS = 90
|
;ARTIFACT_RETENTION_DAYS = 90
|
||||||
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
|
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
|
||||||
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
|
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module gitea.dev
|
|||||||
|
|
||||||
go 1.27
|
go 1.27
|
||||||
|
|
||||||
toolchain go1.27.2
|
toolchain go1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.21.0
|
connectrpc.com/connect v1.21.0
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ const artifactKeepForever timeutil.TimeStamp = 0
|
|||||||
|
|
||||||
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
|
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
|
||||||
if requested.Has() {
|
if requested.Has() {
|
||||||
return max(requested.Value(), artifactKeepForever+1)
|
now := timeutil.TimeStampNow()
|
||||||
|
expiry := max(requested.Value(), now.Add(timeutil.Day)) // v4 clients use the runner's clock
|
||||||
|
if setting.Actions.ArtifactRetentionDays > 0 {
|
||||||
|
expiry = min(expiry, now.Add(timeutil.Day*setting.Actions.ArtifactRetentionDays))
|
||||||
|
}
|
||||||
|
return expiry
|
||||||
}
|
}
|
||||||
if setting.Actions.ArtifactRetentionDays <= 0 {
|
if setting.Actions.ArtifactRetentionDays <= 0 {
|
||||||
return artifactKeepForever
|
return artifactKeepForever
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ package actions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/optional"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -25,3 +31,21 @@ func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.Equal(t, []int64{1, 4, 5}, ids)
|
assert.Equal(t, []int64{1, 4, 5}, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestArtifactExpiry(t *testing.T) {
|
||||||
|
defer timeutil.MockSet(time.Unix(1_000_000_000, 0))()
|
||||||
|
now := timeutil.TimeStampNow()
|
||||||
|
days := func(n int64) timeutil.TimeStamp { return now.Add(n * timeutil.Day) }
|
||||||
|
requested := optional.Some[timeutil.TimeStamp]
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 90)()
|
||||||
|
assert.Equal(t, days(90), artifactExpiry(optional.None[timeutil.TimeStamp]()))
|
||||||
|
assert.Equal(t, days(5), artifactExpiry(requested(days(5))))
|
||||||
|
assert.Equal(t, days(1), artifactExpiry(requested(days(-2))), "runner clock behind must not expire the artifact at once")
|
||||||
|
assert.Equal(t, days(90), artifactExpiry(requested(days(1000))), "runner must not exceed the instance retention")
|
||||||
|
|
||||||
|
setting.Actions.ArtifactRetentionDays = 0
|
||||||
|
assert.Equal(t, artifactKeepForever, artifactExpiry(optional.None[timeutil.TimeStamp]()))
|
||||||
|
assert.Equal(t, days(1000), artifactExpiry(requested(days(1000))))
|
||||||
|
assert.Equal(t, days(1), artifactExpiry(requested(now)))
|
||||||
|
}
|
||||||
@@ -75,11 +75,11 @@ func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
|
|||||||
// Since this sortType is dynamically created, it has to be treated specially.
|
// Since this sortType is dynamically created, it has to be treated specially.
|
||||||
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
||||||
scope := after
|
scope := after
|
||||||
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
|
// a subquery instead of a JOIN keeps one row per issue, "exclusive_order=0" means unset and COALESCE sorts it last on every DB
|
||||||
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
|
sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+
|
||||||
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
|
" INNER JOIN label ON label.id = issue_label.label_id"+
|
||||||
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
|
" WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%").
|
||||||
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
|
Desc("issue.id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func GetTotalUnreferencedBlobSize(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsBlobAccessibleForUser tests if the user has access to the blob
|
// IsBlobAccessibleForUser tests if the user has access to the blob
|
||||||
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User) (bool, error) {
|
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User, publicOnly bool) (bool, error) {
|
||||||
if user.IsAdmin {
|
if user.IsAdmin && !publicOnly {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
|
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
|
||||||
@@ -153,6 +153,9 @@ func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model
|
|||||||
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
|
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
|
||||||
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
|
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
|
||||||
)
|
)
|
||||||
|
if publicOnly {
|
||||||
|
cond = cond.And(builder.Eq{"`user`.visibility": structs.VisibleTypePublic})
|
||||||
|
}
|
||||||
|
|
||||||
return db.GetEngine(ctx).
|
return db.GetEngine(ctx).
|
||||||
Table("package_blob").
|
Table("package_blob").
|
||||||
|
|||||||
@@ -64,11 +64,15 @@ func TestIsBlobAccessibleForRestrictedUser(t *testing.T) {
|
|||||||
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
|
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}))
|
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, accessible)
|
assert.True(t, accessible)
|
||||||
|
|
||||||
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}))
|
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, accessible)
|
||||||
|
|
||||||
|
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, accessible)
|
assert.False(t, accessible)
|
||||||
}
|
}
|
||||||
@@ -112,6 +112,9 @@ func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
||||||
|
if watch.UserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if watch.Mode == mode {
|
if watch.Mode == mode {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestGetWatchers(t *testing.T) {
|
|||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
// One watchers are inactive, thus minus 1
|
// One watcher is inactive, thus minus 1
|
||||||
assert.Len(t, watches, repo.NumWatches-1)
|
assert.Len(t, watches, repo.NumWatches-1)
|
||||||
for _, watch := range watches {
|
for _, watch := range watches {
|
||||||
assert.Equal(t, repo.ID, watch.RepoID)
|
assert.Equal(t, repo.ID, watch.RepoID)
|
||||||
@@ -90,24 +90,30 @@ func TestWatchIfAuto(t *testing.T) {
|
|||||||
|
|
||||||
setting.Service.AutoWatchOnChanges = true
|
setting.Service.AutoWatchOnChanges = true
|
||||||
|
|
||||||
// Must not add watch
|
// Must not add watch (user 8 has "don't watch" set)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount)
|
assert.Len(t, watchers, prevCount)
|
||||||
|
|
||||||
// Should not add watch
|
// Should not add watch (read operation)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount)
|
assert.Len(t, watchers, prevCount)
|
||||||
|
|
||||||
// Should add watch
|
// Should add watch (write operation)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount+1)
|
assert.Len(t, watchers, prevCount+1)
|
||||||
|
|
||||||
|
// Should not add watch (system user should not be able to watch)
|
||||||
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), user_model.ActionsUserID, 1, true))
|
||||||
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, watchers, prevCount+1)
|
||||||
|
|
||||||
// Should remove watch, inhibit from adding auto
|
// Should remove watch, inhibit from adding auto
|
||||||
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
|
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
|
|||||||
+13
-17
@@ -5,6 +5,7 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -200,22 +201,18 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
|
|||||||
}
|
}
|
||||||
if old {
|
if old {
|
||||||
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
||||||
end, _ = strconv.ParseInt(groups["endOld"], 10, 64)
|
end, _ = strconv.ParseInt(cmp.Or(groups["endOld"], "1"), 10, 64)
|
||||||
// init otherLine with begin of opposite side
|
// init otherLine with begin of opposite side
|
||||||
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
||||||
} else {
|
} else {
|
||||||
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
||||||
if groups["endNew"] != "" {
|
end, _ = strconv.ParseInt(cmp.Or(groups["endNew"], "1"), 10, 64)
|
||||||
end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
|
|
||||||
} else {
|
|
||||||
end = 0
|
|
||||||
}
|
|
||||||
// init otherLine with begin of opposite side
|
// init otherLine with begin of opposite side
|
||||||
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
||||||
}
|
}
|
||||||
end += begin // end is for real only the number of lines in hunk
|
end += begin // end is for real only the number of lines in hunk
|
||||||
// lof is between begin and end
|
// lof is between begin and end
|
||||||
if begin <= line && end >= line {
|
if begin <= line && line < end {
|
||||||
hunk = append(hunk, lof)
|
hunk = append(hunk, lof)
|
||||||
currentLine = begin
|
currentLine = begin
|
||||||
continue
|
continue
|
||||||
@@ -265,27 +262,26 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
|
|||||||
oldBegin = otherLine
|
oldBegin = otherLine
|
||||||
newBegin = currentLine
|
newBegin = currentLine
|
||||||
}
|
}
|
||||||
// headers + hunk header
|
|
||||||
newHunk := make([]string, headerLines)
|
|
||||||
// transfer existing headers
|
|
||||||
copy(newHunk, hunk[:headerLines])
|
|
||||||
// transfer last n lines
|
|
||||||
newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
|
|
||||||
// calculate newBegin, ... by counting lines
|
// calculate newBegin, ... by counting lines
|
||||||
for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- {
|
start := len(hunk)
|
||||||
switch hunk[i][0] {
|
for count := 0; count < numbersOfLine && start > headerLines+1; {
|
||||||
|
start--
|
||||||
|
switch hunk[start][0] {
|
||||||
case '+':
|
case '+':
|
||||||
newBegin--
|
newBegin--
|
||||||
newNumOfLines++
|
newNumOfLines++
|
||||||
case '-':
|
case '-':
|
||||||
oldBegin--
|
oldBegin--
|
||||||
oldNumOfLines++
|
oldNumOfLines++
|
||||||
|
case '\\':
|
||||||
|
continue
|
||||||
default:
|
default:
|
||||||
oldBegin--
|
oldBegin--
|
||||||
newBegin--
|
newBegin--
|
||||||
newNumOfLines++
|
newNumOfLines++
|
||||||
oldNumOfLines++
|
oldNumOfLines++
|
||||||
}
|
}
|
||||||
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
|
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
|
||||||
@@ -294,9 +290,9 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
|
|||||||
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
|
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
|
||||||
|
|
||||||
// construct the new hunk header
|
// construct the new hunk header
|
||||||
newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
|
hunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
|
||||||
oldBegin, oldNumOfLines, newBegin, newNumOfLines)
|
oldBegin, oldNumOfLines, newBegin, newNumOfLines)
|
||||||
return strings.Join(newHunk, "\n"), nil
|
return strings.Join(append(hunk[:headerLines+1], hunk[start:]...), "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAffectedFiles returns the affected files between two commits
|
// GetAffectedFiles returns the affected files between two commits
|
||||||
|
|||||||
@@ -120,10 +120,18 @@ func TestCutDiffAroundLine(t *testing.T) {
|
|||||||
assert.Empty(t, emptyResult)
|
assert.Empty(t, emptyResult)
|
||||||
|
|
||||||
// Line is out of scope
|
// Line is out of scope
|
||||||
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0)
|
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 7, false, 3)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Empty(t, emptyResult)
|
assert.Empty(t, emptyResult)
|
||||||
|
|
||||||
|
noNewlineDiff := "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+a"
|
||||||
|
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, false, 2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,1 +1,1 @@\n-a\n\\ No newline at end of file\n+a", result)
|
||||||
|
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, true, 2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a", result)
|
||||||
|
|
||||||
// Handle minus diffs properly
|
// Handle minus diffs properly
|
||||||
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
|
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
+7
-3
@@ -71,12 +71,12 @@ func DefaultFeatures() *Features {
|
|||||||
func loadGitVersionFeatures() (*Features, error) {
|
func loadGitVersionFeatures() (*Features, error) {
|
||||||
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
|
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
return nil, runErr
|
return nil, fmt.Errorf(`failed to run command "git version": %w`, runErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
|
ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(`failed to parse output of command "git version": %w`, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
features := &Features{gitVersion: ver, UsingGogit: isGogit}
|
features := &Features{gitVersion: ver, UsingGogit: isGogit}
|
||||||
@@ -108,7 +108,11 @@ func parseGitVersionLine(s string) (*version.Version, error) {
|
|||||||
if len(versionFields) > 3 {
|
if len(versionFields) > 3 {
|
||||||
versionFields = versionFields[:3]
|
versionFields = versionFields[:3]
|
||||||
}
|
}
|
||||||
return version.NewVersion(strings.Join(versionFields, "."))
|
ver, err := version.NewVersion(strings.Join(versionFields, "."))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid git version: %q: %w", s, err)
|
||||||
|
}
|
||||||
|
return ver, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkGitVersionCompatibility(gitVer *version.Version) error {
|
func checkGitVersionCompatibility(gitVer *version.Version) error {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const (
|
|||||||
StderrPathSpec StderrPrefix = "fatal: pathspec"
|
StderrPathSpec StderrPrefix = "fatal: pathspec"
|
||||||
StderrBadRevision StderrPrefix = "fatal: bad revision"
|
StderrBadRevision StderrPrefix = "fatal: bad revision"
|
||||||
StderrNoSuchPath StderrPrefix = "fatal: no such path"
|
StderrNoSuchPath StderrPrefix = "fatal: no such path"
|
||||||
|
StderrBadObject StderrPrefix = "fatal: bad object "
|
||||||
|
|
||||||
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
|
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
|
||||||
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
|
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
|
|||||||
// Client is used to communicate with a LFS source
|
// Client is used to communicate with a LFS source
|
||||||
type Client interface {
|
type Client interface {
|
||||||
BatchSize() int
|
BatchSize() int
|
||||||
Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error
|
Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error // ref is the full git ref name the objects belong to, may be empty
|
||||||
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
|
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (c *FilesystemClient) objectPath(oid string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download reads the specific LFS object from the target path
|
// Download reads the specific LFS object from the target path
|
||||||
func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
|
func (c *FilesystemClient) Download(ctx context.Context, _ string, objects []Pointer, callback DownloadCallback) error {
|
||||||
for _, object := range objects {
|
for _, object := range objects {
|
||||||
p := Pointer{object.Oid, object.Size}
|
p := Pointer{object.Oid, object.Size}
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -67,18 +67,18 @@ func (c *HTTPClient) transferNames() []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
|
func (c *HTTPClient) batch(ctx context.Context, operation, ref string, objects []Pointer) (*BatchResponse, error) {
|
||||||
log.Trace("BATCH operation with objects: %v", objects)
|
log.Trace("BATCH operation with objects: %v", objects)
|
||||||
|
|
||||||
url := c.endpoint + "/objects/batch"
|
url := c.endpoint + "/objects/batch"
|
||||||
|
|
||||||
// Original: In some lfs server implementations, they require the ref attribute. #32838
|
// `ref` is optional, but some servers like aliyun require it, while others like Hugging Face reject an empty or null one
|
||||||
// `ref` is an "optional object describing the server ref that the objects belong to"
|
|
||||||
// but some (incorrect) lfs servers like aliyun require it, so maybe adding an empty ref here doesn't break the correct ones.
|
|
||||||
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
|
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
|
||||||
//
|
// Related issues: #32838, #33453, #39891
|
||||||
// UPDATE: it can't use "empty ref" here because it breaks others like https://github.com/go-gitea/gitea/issues/33453
|
request := &BatchRequest{Operation: operation, Transfers: c.transferNames(), Objects: objects}
|
||||||
request := &BatchRequest{operation, c.transferNames(), nil, objects}
|
if ref != "" {
|
||||||
|
request.Ref = &Reference{Name: ref}
|
||||||
|
}
|
||||||
|
|
||||||
payload := new(bytes.Buffer)
|
payload := new(bytes.Buffer)
|
||||||
err := json.NewEncoder(payload).Encode(request)
|
err := json.NewEncoder(payload).Encode(request)
|
||||||
@@ -113,17 +113,17 @@ func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Poin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download reads the specific LFS object from the LFS server
|
// Download reads the specific LFS object from the LFS server
|
||||||
func (c *HTTPClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
|
func (c *HTTPClient) Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error {
|
||||||
return c.performOperation(ctx, objects, callback, nil)
|
return c.performOperation(ctx, ref, objects, callback, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload sends the specific LFS object to the LFS server
|
// Upload sends the specific LFS object to the LFS server
|
||||||
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
|
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
|
||||||
return c.performOperation(ctx, objects, nil, callback)
|
return c.performOperation(ctx, "", objects, nil, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
|
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
|
||||||
func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
|
func (c *HTTPClient) performOperation(ctx context.Context, ref string, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
|
||||||
if len(objects) == 0 {
|
if len(objects) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
|
|||||||
operation = "upload"
|
operation = "upload"
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := c.batch(ctx, operation, objects)
|
result, err := c.batch(ctx, operation, ref, objects)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ func TestHTTPClientDownload(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "download", batchRequest.Operation)
|
assert.Equal(t, "download", batchRequest.Operation)
|
||||||
|
assert.Equal(t, &Reference{Name: "refs/heads/main"}, batchRequest.Ref)
|
||||||
assert.Len(t, batchRequest.Objects, 1)
|
assert.Len(t, batchRequest.Objects, 1)
|
||||||
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
||||||
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
||||||
@@ -248,7 +249,7 @@ func TestHTTPClientDownload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := client.Download(t.Context(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
|
err := client.Download(t.Context(), "refs/heads/main", []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
|
||||||
if objectError != nil {
|
if objectError != nil {
|
||||||
return objectError
|
return objectError
|
||||||
}
|
}
|
||||||
@@ -283,6 +284,7 @@ func TestHTTPClientUpload(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "upload", batchRequest.Operation)
|
assert.Equal(t, "upload", batchRequest.Operation)
|
||||||
|
assert.Nil(t, batchRequest.Ref)
|
||||||
assert.Len(t, batchRequest.Objects, 1)
|
assert.Len(t, batchRequest.Objects, 1)
|
||||||
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
||||||
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
||||||
|
|||||||
@@ -4,17 +4,28 @@
|
|||||||
package optional
|
package optional
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (o *Option[T]) assignUnmarshalledValue(v *T) {
|
||||||
|
if reflect.TypeFor[T]().Kind() == reflect.Pointer && v == nil {
|
||||||
|
var zero T
|
||||||
|
*o = Some[T](zero)
|
||||||
|
} else {
|
||||||
|
*o = FromPtr(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Option[T]) UnmarshalJSON(data []byte) error {
|
func (o *Option[T]) UnmarshalJSON(data []byte) error {
|
||||||
var v *T
|
var v *T
|
||||||
if err := json.Unmarshal(data, &v); err != nil {
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
*o = FromPtr(v)
|
o.assignUnmarshalledValue(v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +42,7 @@ func (o *Option[T]) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
if err := value.Decode(&v); err != nil {
|
if err := value.Decode(&v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
*o = FromPtr(v)
|
o.assignUnmarshalledValue(v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package optional_test
|
|||||||
import (
|
import (
|
||||||
std_json "encoding/json" //nolint:depguard // for testing purpose
|
std_json "encoding/json" //nolint:depguard // for testing purpose
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
@@ -28,6 +29,14 @@ type testSerializationStruct struct {
|
|||||||
OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"`
|
OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testSerializationNullValue struct {
|
||||||
|
OptString optional.Option[string] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type testSerializationNullPointer struct {
|
||||||
|
OptNull optional.Option[*time.Time] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func TestOptionalToJson(t *testing.T) {
|
func TestOptionalToJson(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -63,6 +72,32 @@ func TestOptionalToJson(t *testing.T) {
|
|||||||
assert.Equal(t, tc.want, string(b), "std json module returned unexpected")
|
assert.Equal(t, tc.want, string(b), "std json module returned unexpected")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("NullValue", func(t *testing.T) {
|
||||||
|
var v1 testSerializationNullValue
|
||||||
|
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, v1.OptString.Has())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NullPointer", func(t *testing.T) {
|
||||||
|
var v1 testSerializationNullPointer
|
||||||
|
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, v1.OptNull.Has())
|
||||||
|
assert.Nil(t, v1.OptNull.Value())
|
||||||
|
|
||||||
|
var v2 testSerializationNullPointer
|
||||||
|
err = std_json.Unmarshal([]byte(`{}`), &v2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, v2.OptNull.Has())
|
||||||
|
|
||||||
|
var v3 testSerializationNullPointer
|
||||||
|
err = std_json.Unmarshal([]byte(`{"opt_null":"2006-01-02T15:04:05Z"}`), &v3)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, v3.OptNull.Has())
|
||||||
|
assert.NotNil(t, v3.OptNull.Value())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOptionalFromJson(t *testing.T) {
|
func TestOptionalFromJson(t *testing.T) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
@@ -23,6 +25,8 @@ var (
|
|||||||
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
|
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
|
||||||
// ErrInvalidVersion indicates an invalid version in the metadata.gz file
|
// ErrInvalidVersion indicates an invalid version in the metadata.gz file
|
||||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
||||||
|
// ErrInvalidMetadata indicates metadata that would corrupt the generated index
|
||||||
|
ErrInvalidMetadata = util.NewInvalidArgumentErrorf("package metadata is invalid")
|
||||||
)
|
)
|
||||||
|
|
||||||
var globalVars = sync.OnceValue(func() (ret struct {
|
var globalVars = sync.OnceValue(func() (ret struct {
|
||||||
@@ -220,6 +224,19 @@ func parseMetadataFile(r io.Reader) (*Package, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
values := []string{m.Platform}
|
||||||
|
reqs := slices.Concat(m.RequiredRubyVersion, m.RequiredRubygemsVersion)
|
||||||
|
for _, dep := range slices.Concat(m.RuntimeDependencies, m.DevelopmentDependencies) {
|
||||||
|
values = append(values, dep.Name)
|
||||||
|
reqs = append(reqs, dep.Version...)
|
||||||
|
}
|
||||||
|
for _, req := range reqs {
|
||||||
|
values = append(values, req.Restriction, req.Version)
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(values, func(s string) bool { return strings.ContainsAny(s, "\r\n") }) {
|
||||||
|
return nil, ErrInvalidMetadata // the compact index is line-based, a line break would forge entries
|
||||||
|
}
|
||||||
|
|
||||||
return &Package{
|
return &Package{
|
||||||
Name: spec.Name,
|
Name: spec.Name,
|
||||||
Version: spec.Version.Version,
|
Version: spec.Version.Version,
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ version:
|
|||||||
assert.Nil(t, rp)
|
assert.Nil(t, rp)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("LineBreakInIndexedMetadata", func(t *testing.T) {
|
||||||
|
for _, field := range []string{
|
||||||
|
`platform: "ruby\nx"`,
|
||||||
|
`dependencies: [{name: "d\nx"}]`,
|
||||||
|
`dependencies: [{name: d, requirement: {requirements: [[">=", {version: "1\nx"}]]}}]`,
|
||||||
|
`required_ruby_version: {requirements: [[">=", {version: "1\nx"}]]}`,
|
||||||
|
} {
|
||||||
|
rp, err := parseMetadataFile(test.CompressGzip("name: g\nversion:\n version: 1\n" + field + "\n"))
|
||||||
|
assert.ErrorIs(t, err, ErrInvalidMetadata, field)
|
||||||
|
assert.Nil(t, rp)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseMetadataFile(t *testing.T) {
|
func TestParseMetadataFile(t *testing.T) {
|
||||||
|
|||||||
@@ -67,8 +67,13 @@ func StoreMissingLfsObjectsInRepository(ctx context.Context, repo *repo_model.Re
|
|||||||
errChan <- lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan)
|
errChan <- lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// some servers require `ref`, send the default branch for objects from all refs like
|
||||||
|
// `git lfs fetch --all` does on the main branch: it uses current branch as `ref`:
|
||||||
|
// https://github.com/git-lfs/git-lfs/blob/58a763d09d5fe4b7ea85c89ec799d71c5b0dc1da/commands/commands.go#L124-L137
|
||||||
|
// `man git-lfs-fetch` said that "--all" can fetch all objects, so the default branch should work
|
||||||
|
ref := git.RefNameFromBranch(repo.DefaultBranch).String()
|
||||||
downloadObjects := func(pointers []lfs.Pointer) error {
|
downloadObjects := func(pointers []lfs.Pointer) error {
|
||||||
err := lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
|
err := lfsClient.Download(ctx, ref, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
|
||||||
if errors.Is(objectError, lfs.ErrObjectNotExist) {
|
if errors.Is(objectError, lfs.ErrObjectNotExist) {
|
||||||
log.Warn("Ignoring missing upstream LFS object %-v: %v", p, objectError)
|
log.Warn("Ignoring missing upstream LFS object %-v: %v", p, objectError)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ type EditLabelOption struct {
|
|||||||
// IssueLabelsOption a collection of labels
|
// IssueLabelsOption a collection of labels
|
||||||
type IssueLabelsOption struct {
|
type IssueLabelsOption struct {
|
||||||
// Labels can be a list of integers representing label IDs
|
// Labels can be a list of integers representing label IDs
|
||||||
// or a list of strings representing label names
|
// or a list of strings representing label names.
|
||||||
|
// If a repository label and an organization label share a name,
|
||||||
|
// the repository label is used.
|
||||||
Labels []any `json:"labels"`
|
Labels []any `json:"labels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package structs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/optional"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Milestone milestone is a collection of issues on one repository
|
// Milestone milestone is a collection of issues on one repository
|
||||||
@@ -54,6 +56,7 @@ type EditMilestoneOption struct {
|
|||||||
// State indicates the updated state of the milestone
|
// State indicates the updated state of the milestone
|
||||||
// enum: ["open","closed"]
|
// enum: ["open","closed"]
|
||||||
State *string `json:"state"`
|
State *string `json:"state"`
|
||||||
// Deadline is the updated due date for the milestone
|
// Deadline is the due date for the milestone, use null or empty string to remove the deadline
|
||||||
Deadline *time.Time `json:"due_on"`
|
// swagger:strfmt date-time
|
||||||
|
Deadline optional.Option[*string] `json:"due_on,omitzero"`
|
||||||
}
|
}
|
||||||
@@ -3556,7 +3556,7 @@
|
|||||||
"units.error.no_unit_allowed_repo": "You are not allowed to access any section of this repository.",
|
"units.error.no_unit_allowed_repo": "You are not allowed to access any section of this repository.",
|
||||||
"units.error.unit_not_allowed": "You are not allowed to access this repository section.",
|
"units.error.unit_not_allowed": "You are not allowed to access this repository section.",
|
||||||
"packages.title": "Packages",
|
"packages.title": "Packages",
|
||||||
"packages.desc": "Manage repository packages.",
|
"packages.desc": "Access all packages of the organization, regardless of the team's repositories.",
|
||||||
"packages.empty": "There are no packages yet.",
|
"packages.empty": "There are no packages yet.",
|
||||||
"packages.no_metadata": "No metadata.",
|
"packages.no_metadata": "No metadata.",
|
||||||
"packages.empty.documentation": "For more information on the package registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.",
|
"packages.empty.documentation": "For more information on the package registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.",
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export default defineConfig({
|
|||||||
name: 'firefox',
|
name: 'firefox',
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Firefox'],
|
...devices['Desktop Firefox'],
|
||||||
|
launchOptions: {
|
||||||
|
firefoxUserPrefs: {
|
||||||
|
'dom.events.testing.asyncClipboard': true, // disable paste prompt
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
@@ -26,6 +28,10 @@ const (
|
|||||||
tokenHeaderKey = protocol.TokenHeader
|
tokenHeaderKey = protocol.TokenHeader
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type runnerTokenHash struct{ salt, hash string }
|
||||||
|
|
||||||
|
var verifiedRunnerTokens, _ = lru.New[runnerTokenHash, [sha256.Size]byte](10000) // not auth_model.TokenCache, whose small default size thrashes once more runners poll in turn
|
||||||
|
|
||||||
var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc {
|
var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc {
|
||||||
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
|
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
|
||||||
methodName := getMethodName(request)
|
methodName := getMethodName(request)
|
||||||
@@ -42,7 +48,7 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
|
|||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) {
|
if !verifyRunnerToken(runner, token) {
|
||||||
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
|
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +79,18 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
func verifyRunnerToken(runner *actions_model.ActionRunner, token string) bool {
|
||||||
|
key, digest := runnerTokenHash{runner.TokenSalt, runner.TokenHash}, sha256.Sum256([]byte(token))
|
||||||
|
if cached, ok := verifiedRunnerTokens.Get(key); ok && util.CryptoConstTimeEqual(cached[:], digest[:]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
verifiedRunnerTokens.Add(key, digest)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func getMethodName(req connect.AnyRequest) string {
|
func getMethodName(req connect.AnyRequest) string {
|
||||||
splits := strings.Split(req.Spec().Procedure, "/")
|
splits := strings.Split(req.Spec().Procedure, "/")
|
||||||
if len(splits) > 0 {
|
if len(splits) > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "gitea.dev/models/actions"
|
||||||
|
auth_model "gitea.dev/models/auth"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyRunnerToken(t *testing.T) {
|
||||||
|
verifiedRunnerTokens.Purge()
|
||||||
|
runner := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("token", "salt")}
|
||||||
|
assert.True(t, verifyRunnerToken(runner, "token"))
|
||||||
|
assert.True(t, verifyRunnerToken(runner, "token"))
|
||||||
|
assert.False(t, verifyRunnerToken(runner, "wrong"))
|
||||||
|
|
||||||
|
other := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("other", "salt")}
|
||||||
|
assert.False(t, verifyRunnerToken(other, "token"))
|
||||||
|
assert.Equal(t, [][sha256.Size]byte{sha256.Sum256([]byte("token"))}, verifiedRunnerTokens.Values())
|
||||||
|
}
|
||||||
@@ -244,7 +244,7 @@ func PostBlobsUploads(ctx *context.Context) {
|
|||||||
Digest: mount,
|
Digest: mount,
|
||||||
})
|
})
|
||||||
if blob != nil {
|
if blob != nil {
|
||||||
accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer)
|
accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer, context.TokenIsPublicOnly(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apiError(ctx, http.StatusInternalServerError, err)
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -492,6 +492,9 @@ func reqAnyRepoReader() func(ctx *context.APIContext) {
|
|||||||
// reqOrgOwnership user should be an organization owner, or a site admin
|
// reqOrgOwnership user should be an organization owner, or a site admin
|
||||||
func reqOrgOwnership() func(ctx *context.APIContext) {
|
func reqOrgOwnership() func(ctx *context.APIContext) {
|
||||||
return func(ctx *context.APIContext) {
|
return func(ctx *context.APIContext) {
|
||||||
|
if rejectPublicOnly()(ctx); ctx.Written() { // org ownership reaches the org's private repos (hooks, teams, secrets)
|
||||||
|
return
|
||||||
|
}
|
||||||
if ctx.IsUserSiteAdmin() {
|
if ctx.IsUserSiteAdmin() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1283,6 +1286,7 @@ func Routes() *web.Router {
|
|||||||
// FIXME: we need org in context
|
// FIXME: we need org in context
|
||||||
tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository),
|
tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository),
|
||||||
reqToken(),
|
reqToken(),
|
||||||
|
rejectPublicOnly(),
|
||||||
bind(api.CreateRepoOption{}),
|
bind(api.CreateRepoOption{}),
|
||||||
repo.CreateOrgRepoDeprecated)
|
repo.CreateOrgRepoDeprecated)
|
||||||
|
|
||||||
@@ -1303,7 +1307,7 @@ func Routes() *web.Router {
|
|||||||
m.Combo("").Get(reqAnyRepoReader(), repo.Get).
|
m.Combo("").Get(reqAnyRepoReader(), repo.Get).
|
||||||
Delete(reqToken(), reqRepoDangerZone(), repo.Delete).
|
Delete(reqToken(), reqRepoDangerZone(), repo.Delete).
|
||||||
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit)
|
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit)
|
||||||
m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate)
|
m.Post("/generate", reqToken(), rejectPublicOnly(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate)
|
||||||
m.Group("/transfer", func() {
|
m.Group("/transfer", func() {
|
||||||
m.Post("", reqRepoDangerZone(), bind(api.TransferRepoOption{}), repo.Transfer)
|
m.Post("", reqRepoDangerZone(), bind(api.TransferRepoOption{}), repo.Transfer)
|
||||||
m.Post("/accept", repo.AcceptTransfer)
|
m.Post("/accept", repo.AcceptTransfer)
|
||||||
@@ -1777,7 +1781,7 @@ func Routes() *web.Router {
|
|||||||
m.Get("", reqToken(), org.ListUserOrgs)
|
m.Get("", reqToken(), org.ListUserOrgs)
|
||||||
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
|
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker)
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker)
|
||||||
m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create)
|
m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), rejectPublicOnly(), bind(api.CreateOrgOption{}), org.Create)
|
||||||
m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.GetAll)
|
m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.GetAll)
|
||||||
m.Group("/orgs/{org}", func() {
|
m.Group("/orgs/{org}", func() {
|
||||||
m.Combo("").Get(org.Get).
|
m.Combo("").Get(org.Get).
|
||||||
@@ -1785,7 +1789,7 @@ func Routes() *web.Router {
|
|||||||
Delete(reqToken(), reqOrgOwnership(), org.Delete)
|
Delete(reqToken(), reqOrgOwnership(), org.Delete)
|
||||||
m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename)
|
m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename)
|
||||||
m.Combo("/repos").Get(user.ListOrgRepos).
|
m.Combo("/repos").Get(user.ListOrgRepos).
|
||||||
Post(reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), bind(api.CreateRepoOption{}), repo.CreateOrgRepo).
|
Post(reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo).
|
||||||
Delete(reqToken(), reqOrgOwnership(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), org.DeleteOrgRepos)
|
Delete(reqToken(), reqOrgOwnership(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), org.DeleteOrgRepos)
|
||||||
m.Group("/members", func() {
|
m.Group("/members", func() {
|
||||||
m.Get("", reqToken(), org.ListMembers)
|
m.Get("", reqToken(), org.ListMembers)
|
||||||
@@ -1921,7 +1925,7 @@ func Routes() *web.Router {
|
|||||||
m.Get("/runs", admin.ListWorkflowRuns)
|
m.Get("/runs", admin.ListWorkflowRuns)
|
||||||
m.Get("/jobs", admin.ListWorkflowJobs)
|
m.Get("/jobs", admin.ListWorkflowJobs)
|
||||||
})
|
})
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin())
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin(), rejectPublicOnly())
|
||||||
|
|
||||||
m.Group("/topics", func() {
|
m.Group("/topics", func() {
|
||||||
m.Get("/search", repo.TopicSearch)
|
m.Get("/search", repo.TopicSearch)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
access_model "gitea.dev/models/perm/access"
|
access_model "gitea.dev/models/perm/access"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
user_model "gitea.dev/models/user"
|
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
@@ -166,13 +165,7 @@ func CreateFork(ctx *context.APIContext) {
|
|||||||
Description: repo.Description,
|
Description: repo.Description,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) {
|
ctx.APIErrorAuto(err)
|
||||||
ctx.APIError(http.StatusConflict, err.Error())
|
|
||||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
|
||||||
ctx.APIError(http.StatusForbidden, err.Error())
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,8 +337,8 @@ func ListIssues(ctx *context.APIContext) {
|
|||||||
keyword := ctx.FormTrim("q")
|
keyword := ctx.FormTrim("q")
|
||||||
|
|
||||||
var labelIDs []int64
|
var labelIDs []int64
|
||||||
if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 {
|
if labelsParam := ctx.FormTrim("labels"); labelsParam != "" {
|
||||||
labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted)
|
labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, strings.Split(labelsParam, ","))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -513,17 +513,12 @@ func GetIssue(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
|
if err := issue.LoadAttributes(ctx); err != nil {
|
||||||
ctx.APIErrorNotFound()
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue))
|
ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue))
|
||||||
@@ -698,19 +693,13 @@ func EditIssue(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
form := web.GetForm[*api.EditIssueOption](ctx)
|
form := web.GetForm[*api.EditIssueOption](ctx)
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
issue.Repo = ctx.Repo.Repository
|
|
||||||
canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
|
canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
|
||||||
|
|
||||||
err = issue.LoadAttributes(ctx)
|
err := issue.LoadAttributes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -901,13 +890,12 @@ func DeleteIssue(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
// "423":
|
// "423":
|
||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil {
|
if err := issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -953,13 +941,8 @@ func UpdateIssueDeadline(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
form := web.GetForm[*api.EditDeadlineOption](ctx)
|
form := web.GetForm[*api.EditDeadlineOption](ctx)
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,3 +982,17 @@ func closeOrReopenIssue(ctx *context.APIContext, issue *issues_model.Issue, stat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getRepoIssueByIndexForDoer(ctx *context.APIContext, index int64) *issues_model.Issue {
|
||||||
|
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, index)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorAuto(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
issue.Repo = ctx.Repo.Repository
|
||||||
|
return issue
|
||||||
|
}
|
||||||
@@ -146,14 +146,8 @@ func CheckIssueAssignee(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,9 +181,8 @@ func checkAssignableUser(ctx *context.APIContext, assigneeName string, repo *rep
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateIssueAssignees(ctx *context.APIContext, opts api.IssueAssigneesOption, isAdd bool) {
|
func updateIssueAssignees(ctx *context.APIContext, opts api.IssueAssigneesOption, isAdd bool) {
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func GetIssueAttachment(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/error"
|
// "$ref": "#/responses/error"
|
||||||
|
|
||||||
issue := getIssueFromContext(ctx)
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if issue == nil {
|
if issue == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ func ListIssueAttachments(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/error"
|
// "$ref": "#/responses/error"
|
||||||
|
|
||||||
issue := getIssueFromContext(ctx)
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if issue == nil {
|
if issue == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func CreateIssueAttachment(ctx *context.APIContext) {
|
|||||||
// "423":
|
// "423":
|
||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
issue := getIssueFromContext(ctx)
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if issue == nil {
|
if issue == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -333,20 +333,8 @@ func DeleteIssueAttachment(ctx *context.APIContext) {
|
|||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getIssueFromContext(ctx *context.APIContext) *issues_model.Issue {
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
issue.Repo = ctx.Repo.Repository
|
|
||||||
|
|
||||||
return issue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
|
func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
|
||||||
issue := getIssueFromContext(ctx)
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if issue == nil {
|
if issue == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,17 +69,10 @@ func ListIssueComments(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
issue.Repo = ctx.Repo.Repository
|
|
||||||
|
|
||||||
opts := &issues_model.FindCommentsOptions{
|
opts := &issues_model.FindCommentsOptions{
|
||||||
IssueID: issue.ID,
|
IssueID: issue.ID,
|
||||||
@@ -173,12 +166,10 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
issue.Repo = ctx.Repo.Repository
|
|
||||||
|
|
||||||
opts := &issues_model.FindCommentsOptions{
|
opts := &issues_model.FindCommentsOptions{
|
||||||
ListOptions: utils.GetListOptions(ctx),
|
ListOptions: utils.GetListOptions(ctx),
|
||||||
@@ -381,14 +372,8 @@ func CreateIssueComment(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
form := web.GetForm[*api.CreateIssueCommentOption](ctx)
|
form := web.GetForm[*api.CreateIssueCommentOption](ctx)
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
perm_model "gitea.dev/models/perm"
|
||||||
|
access_model "gitea.dev/models/perm/access"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unit"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/services/context"
|
||||||
|
"gitea.dev/services/contexttest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueReadEndpointsRequireUnitPermission(t *testing.T) {
|
||||||
|
unittest.PrepareTestEnv(t)
|
||||||
|
|
||||||
|
for name, handler := range map[string]func(*context.APIContext){
|
||||||
|
"timeline": ListIssueCommentsAndTimeline,
|
||||||
|
"labels": ListIssueLabels,
|
||||||
|
"subscribers": GetIssueSubscribers,
|
||||||
|
"attachments": ListIssueAttachments,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := func(index string) int {
|
||||||
|
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
|
||||||
|
contexttest.LoadRepo(t, ctx, 1)
|
||||||
|
ctx.Repo.Permission = access_model.Permission{}
|
||||||
|
ctx.Repo.Permission.SetUnitsWithDefaultAccessMode([]*repo_model.RepoUnit{{Type: unit.TypeIssues}}, perm_model.AccessModeRead)
|
||||||
|
ctx.SetPathParam("index", index)
|
||||||
|
handler(ctx)
|
||||||
|
return resp.Code
|
||||||
|
}
|
||||||
|
assert.Equal(t, http.StatusOK, request("1"))
|
||||||
|
assert.Equal(t, http.StatusNotFound, request("2"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,15 +61,8 @@ func GetIssueDependencies(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. We must be able to read this issue
|
|
||||||
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +72,7 @@ func GetIssueDependencies(ctx *context.APIContext) {
|
|||||||
|
|
||||||
blockerIssues := make([]*issues_model.Issue, 0, min(listOptions.PageSize, setting.API.MaxResponseItems))
|
blockerIssues := make([]*issues_model.Issue, 0, min(listOptions.PageSize, setting.API.MaxResponseItems))
|
||||||
|
|
||||||
// 2. Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>`
|
// Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>`
|
||||||
blockersInfo, total, err := issue.BlockedByDependencies(ctx, listOptions)
|
blockersInfo, total, err := issue.BlockedByDependencies(ctx, listOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -177,7 +170,7 @@ func CreateIssueDependency(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
// We want to make <:index> depend on <Form>, i.e. <:index> is the target
|
// We want to make <:index> depend on <Form>, i.e. <:index> is the target
|
||||||
target := getParamsIssue(ctx)
|
target := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,7 +231,7 @@ func RemoveIssueDependency(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
// We want to make <:index> depend on <Form>, i.e. <:index> is the target
|
// We want to make <:index> depend on <Form>, i.e. <:index> is the target
|
||||||
target := getParamsIssue(ctx)
|
target := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,16 +296,11 @@ func GetIssueBlocks(ctx *context.APIContext) {
|
|||||||
// We need to list the issues that DEPEND on this issue not the other way round
|
// We need to list the issues that DEPEND on this issue not the other way round
|
||||||
// Therefore whether dependencies are enabled or not in this repository is potentially irrelevant.
|
// Therefore whether dependencies are enabled or not in this repository is potentially irrelevant.
|
||||||
|
|
||||||
issue := getParamsIssue(ctx)
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page := max(ctx.FormInt("page"), 1)
|
page := max(ctx.FormInt("page"), 1)
|
||||||
limit := ctx.FormInt("limit")
|
limit := ctx.FormInt("limit")
|
||||||
if limit <= 1 {
|
if limit <= 1 {
|
||||||
@@ -399,7 +387,7 @@ func CreateIssueBlocking(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// description: the issue does not exist
|
// description: the issue does not exist
|
||||||
|
|
||||||
dependency := getParamsIssue(ctx)
|
dependency := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -456,7 +444,7 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
dependency := getParamsIssue(ctx)
|
dependency := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -480,16 +468,6 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
|
|||||||
ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency))
|
ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getParamsIssue(ctx *context.APIContext) *issues_model.Issue {
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
issue.Repo = ctx.Repo.Repository
|
|
||||||
return issue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue {
|
func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue {
|
||||||
var repo *repo_model.Repository
|
var repo *repo_model.Repository
|
||||||
if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name {
|
if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
|
"gitea.dev/modules/container"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
@@ -47,13 +48,8 @@ func ListIssueLabels(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,13 +163,8 @@ func DeleteIssueLabel(ctx *context.APIContext) {
|
|||||||
// "423":
|
// "423":
|
||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,13 +283,8 @@ func ClearIssueLabels(ctx *context.APIContext) {
|
|||||||
// "423":
|
// "423":
|
||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,14 +302,9 @@ func ClearIssueLabels(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) {
|
func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) {
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
return nil, nil, errors.New("issue not found")
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
||||||
@@ -359,12 +340,23 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption)
|
|||||||
}
|
}
|
||||||
labelIDs = append(labelIDs, repoLabelIDs...)
|
labelIDs = append(labelIDs, repoLabelIDs...)
|
||||||
if ctx.Repo.Owner.IsOrganization() {
|
if ctx.Repo.Owner.IsOrganization() {
|
||||||
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, labelNames)
|
repoLabels, err := issues_model.GetLabelsByIDs(ctx, repoLabelIDs, "name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
labelIDs = append(labelIDs, orgLabelIDs...)
|
orgLabelNames := container.SetOf(labelNames...)
|
||||||
|
for _, label := range repoLabels {
|
||||||
|
orgLabelNames.Remove(label.Name)
|
||||||
|
}
|
||||||
|
if len(orgLabelNames) > 0 {
|
||||||
|
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, orgLabelNames.Values())
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
labelIDs = append(labelIDs, orgLabelIDs...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ func LockIssue(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
reason := web.GetForm[*api.LockIssueOption](ctx).Reason
|
reason := web.GetForm[*api.LockIssueOption](ctx).Reason
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ func LockIssue(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
issue.Repo = ctx.Repo.Repository
|
issue.Repo = ctx.Repo.Repository
|
||||||
err = issues_model.LockIssue(ctx, opt)
|
err := issues_model.LockIssue(ctx, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -114,9 +113,8 @@ func UnlockIssue(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +130,7 @@ func UnlockIssue(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
issue.Repo = ctx.Repo.Repository
|
issue.Repo = ctx.Repo.Repository
|
||||||
err = issues_model.UnlockIssue(ctx, opt)
|
err := issues_model.UnlockIssue(ctx, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -41,20 +41,13 @@ func PinIssue(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/forbidden"
|
// "$ref": "#/responses/forbidden"
|
||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else if issues_model.IsErrIssueMaxPinReached(err) {
|
|
||||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't do this, it will crash when trying to add the pin event to the comment history
|
// If we don't do this, it will crash when trying to add the pin event to the comment history
|
||||||
err = issue.LoadRepo(ctx)
|
err := issue.LoadRepo(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -98,18 +91,13 @@ func UnpinIssue(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/forbidden"
|
// "$ref": "#/responses/forbidden"
|
||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't do this, it will crash when trying to add the unpin event to the comment history
|
// If we don't do this, it will crash when trying to add the unpin event to the comment history
|
||||||
err = issue.LoadRepo(ctx)
|
err := issue.LoadRepo(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -159,17 +147,12 @@ func MoveIssuePin(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/forbidden"
|
// "$ref": "#/responses/forbidden"
|
||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = issues_model.MovePin(ctx, issue, ctx.PathParamInt("position"))
|
err := issues_model.MovePin(ctx, issue, ctx.PathParamInt("position"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -172,13 +172,8 @@ func DeleteIssueStopwatch(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue {
|
func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue {
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,14 +104,8 @@ func DelIssueSubscription(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setIssueSubscription(ctx *context.APIContext, watch bool) {
|
func setIssueSubscription(ctx *context.APIContext, watch bool) {
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,14 +179,8 @@ func CheckIssueSubscription(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,14 +239,8 @@ func GetIssueSubscribers(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,8 @@ func ListTrackedTimes(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorNotFound("timetracker is disabled")
|
ctx.APIErrorNotFound("timetracker is disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +99,7 @@ func ListTrackedTimes(ctx *context.APIContext) {
|
|||||||
opts.UserID = user.ID
|
opts.UserID = user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil {
|
if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil {
|
||||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||||
return
|
return
|
||||||
@@ -200,9 +200,8 @@ func AddTime(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
form := web.GetForm[*api.AddTimeOption](ctx)
|
form := web.GetForm[*api.AddTimeOption](ctx)
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +218,7 @@ func AddTime(ctx *context.APIContext) {
|
|||||||
if form.User != "" {
|
if form.User != "" {
|
||||||
if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin {
|
if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin {
|
||||||
// allow only RepoAdmin, Admin and User to add time
|
// allow only RepoAdmin, Admin and User to add time
|
||||||
|
var err error
|
||||||
user, err = user_model.GetUserByName(ctx, form.User)
|
user, err = user_model.GetUserByName(ctx, form.User)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
@@ -280,9 +280,8 @@ func ResetIssueTime(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +294,7 @@ func ResetIssueTime(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer)
|
err := issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsErrNotExist(err) {
|
if db.IsErrNotExist(err) {
|
||||||
ctx.APIError(http.StatusNotFound, err.Error())
|
ctx.APIError(http.StatusNotFound, err.Error())
|
||||||
@@ -349,9 +348,8 @@ func DeleteTime(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
|
||||||
if err != nil {
|
if issue == nil {
|
||||||
ctx.APIErrorAuto(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ func EditMilestone(ctx *context.APIContext) {
|
|||||||
if form.Description != nil {
|
if form.Description != nil {
|
||||||
milestone.Content = *form.Description
|
milestone.Content = *form.Description
|
||||||
}
|
}
|
||||||
milestone.DeadlineUnix, _ = common.ParseAPIDeadlineToEndOfDay(form.Deadline)
|
if _, err := common.ApplyAPIDeadlineTimestamp(&milestone.DeadlineUnix, form.Deadline, nil); err != nil {
|
||||||
|
ctx.APIErrorAuto(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
oldIsClosed := milestone.IsClosed
|
oldIsClosed := milestone.IsClosed
|
||||||
if form.State != nil {
|
if form.State != nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
activities_model "gitea.dev/models/activities"
|
activities_model "gitea.dev/models/activities"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
@@ -154,7 +155,7 @@ func GetUserHeatmapData(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
heatmap, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
|
heatmap, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, util.Iif(ctx.PublicOnly, nil, ctx.Doer))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ package common
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseDeadlineDateToEndOfDay(date string) (timeutil.TimeStamp, error) {
|
func ParseDeadlineDateToEndOfDay(date string) (timeutil.TimeStamp, error) {
|
||||||
@@ -29,3 +31,34 @@ func ParseAPIDeadlineToEndOfDay(t *time.Time) (timeutil.TimeStamp, error) {
|
|||||||
deadline := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation)
|
deadline := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation)
|
||||||
return timeutil.TimeStamp(deadline.Unix()), nil
|
return timeutil.TimeStamp(deadline.Unix()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ApplyAPIDeadlineTimestamp(field *timeutil.TimeStamp, input optional.Option[*string], remove *bool) (changed bool, err error) {
|
||||||
|
old := *field
|
||||||
|
if remove != nil && *remove || input.Has() && (input.Value() == nil || *input.Value() == "") {
|
||||||
|
*field = 0
|
||||||
|
return old != *field, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !input.Has() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
inputValue := *input.Value()
|
||||||
|
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
time.RFC3339,
|
||||||
|
}
|
||||||
|
for _, format := range formats {
|
||||||
|
t, err := time.ParseInLocation(format, inputValue, setting.DefaultUILocation)
|
||||||
|
if err == nil {
|
||||||
|
ts := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation).Unix()
|
||||||
|
if ts < 86400*2 {
|
||||||
|
ts = 0 // tolerate timezone difference between server and client
|
||||||
|
}
|
||||||
|
*field = timeutil.TimeStamp(ts)
|
||||||
|
return old != *field, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, util.NewInvalidArgumentErrorf("invalid date format: %s", inputValue)
|
||||||
|
}
|
||||||
@@ -121,16 +121,7 @@ func InfoOAuth(ctx *context.Context) {
|
|||||||
Picture: ctx.Doer.AvatarLink(ctx),
|
Picture: ctx.Doer.AvatarLink(ctx),
|
||||||
}
|
}
|
||||||
|
|
||||||
var accessTokenScope auth.AccessTokenScope
|
onlyPublicGroups, _ := tokenScope.PublicOnly()
|
||||||
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
|
||||||
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
|
|
||||||
accessTokenScope, _, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// since version 1.22 does not verify if groups should be public-only,
|
|
||||||
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
|
|
||||||
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
|
|
||||||
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
|
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("Oauth groups for user", err)
|
ctx.ServerError("Oauth groups for user", err)
|
||||||
@@ -141,12 +132,21 @@ func InfoOAuth(ctx *context.Context) {
|
|||||||
ctx.JSON(http.StatusOK, response)
|
ctx.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC 6749 section 2.3.1: client_secret_basic credentials are form-urlencoded
|
||||||
|
func parseClientSecretBasic(authHeader string) (clientID, clientSecret string, ok bool) {
|
||||||
|
parsed, _ := httpauth.ParseAuthorizationHeader(authHeader)
|
||||||
|
if parsed.BasicAuth == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
clientID, errID := url.QueryUnescape(parsed.BasicAuth.Username)
|
||||||
|
clientSecret, errSecret := url.QueryUnescape(parsed.BasicAuth.Password)
|
||||||
|
return clientID, clientSecret, errID == nil && errSecret == nil
|
||||||
|
}
|
||||||
|
|
||||||
// IntrospectOAuth introspects an oauth token
|
// IntrospectOAuth introspects an oauth token
|
||||||
func IntrospectOAuth(ctx *context.Context) {
|
func IntrospectOAuth(ctx *context.Context) {
|
||||||
var introspectingApp *auth.OAuth2Application
|
var introspectingApp *auth.OAuth2Application
|
||||||
authHeader := ctx.Req.Header.Get("Authorization")
|
if clientID, clientSecret, ok := parseClientSecretBasic(ctx.Req.Header.Get("Authorization")); ok {
|
||||||
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
|
|
||||||
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
|
||||||
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
|
||||||
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
|
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
|
||||||
// this is likely a database error; log it and respond without details
|
// this is likely a database error; log it and respond without details
|
||||||
@@ -504,15 +504,14 @@ func AccessTokenOAuth(ctx *context.Context) {
|
|||||||
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
|
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
|
||||||
if form.ClientID == "" || form.ClientSecret == "" {
|
if form.ClientID == "" || form.ClientSecret == "" {
|
||||||
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
|
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
|
||||||
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
|
clientID, clientSecret, ok := parseClientSecretBasic(authHeader)
|
||||||
if !ok || parsed.BasicAuth == nil {
|
if !ok {
|
||||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
||||||
ErrorDescription: "cannot parse basic auth header",
|
ErrorDescription: "cannot parse basic auth header",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
|
||||||
// validate that any fields present in the form match the Basic auth header
|
// validate that any fields present in the form match the Basic auth header
|
||||||
if form.ClientID != "" && form.ClientID != clientID {
|
if form.ClientID != "" && form.ClientID != clientID {
|
||||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/models/auth"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/contexttest"
|
||||||
|
"gitea.dev/services/forms"
|
||||||
|
"gitea.dev/services/oauth2_provider"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOAuth2FormEncodedBasicAuth(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
signingKey, err := oauth2_provider.CreateJWTSigningKey("HS256", make([]byte, 32))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer test.MockVariableValue(&oauth2_provider.DefaultSigningKey, signingKey)()
|
||||||
|
|
||||||
|
app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1})
|
||||||
|
const fixtureClientSecretPlaintext = "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA="
|
||||||
|
escape := strings.NewReplacer("-", "%2D", "=", "%3D").Replace
|
||||||
|
|
||||||
|
t.Run("AccessToken accepts client matching body client_id and rejects unknown code", func(t *testing.T) {
|
||||||
|
ctx, resp := contexttest.MockContext(t, "POST /login/oauth/access_token")
|
||||||
|
ctx.Req.SetBasicAuth(escape(app.ClientID), escape(fixtureClientSecretPlaintext))
|
||||||
|
web.SetForm(ctx, &forms.AccessTokenForm{GrantType: "authorization_code", ClientID: app.ClientID})
|
||||||
|
AccessTokenOAuth(ctx)
|
||||||
|
|
||||||
|
var accessTokenErr oauth2_provider.AccessTokenError
|
||||||
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &accessTokenErr))
|
||||||
|
assert.Equal(t, "client is not authorized", accessTokenErr.ErrorDescription)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Introspect accepts client and reports inactive token", func(t *testing.T) {
|
||||||
|
ctx, resp := contexttest.MockContext(t, "POST /login/oauth/introspect")
|
||||||
|
ctx.Req.SetBasicAuth(escape(app.ClientID), escape(fixtureClientSecretPlaintext))
|
||||||
|
web.SetForm(ctx, &forms.IntrospectTokenForm{Token: "invalid"})
|
||||||
|
IntrospectOAuth(ctx)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseClientSecretBasic(t *testing.T) {
|
||||||
|
basicAuth := func(username, password string) string {
|
||||||
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, clientSecret, ok := parseClientSecretBasic(basicAuth("a+b", "c%2Bd"))
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "a b", clientID)
|
||||||
|
assert.Equal(t, "c+d", clientSecret)
|
||||||
|
|
||||||
|
_, _, ok = parseClientSecretBasic(basicAuth("a%zz", "c"))
|
||||||
|
assert.False(t, ok)
|
||||||
|
_, _, ok = parseClientSecretBasic(basicAuth("a", "c%zz"))
|
||||||
|
assert.False(t, ok)
|
||||||
|
_, _, ok = parseClientSecretBasic("Bearer token")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
+16
-13
@@ -15,6 +15,7 @@ import (
|
|||||||
activities_model "gitea.dev/models/activities"
|
activities_model "gitea.dev/models/activities"
|
||||||
"gitea.dev/models/renderhelper"
|
"gitea.dev/models/renderhelper"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unit"
|
||||||
"gitea.dev/modules/markup"
|
"gitea.dev/modules/markup"
|
||||||
"gitea.dev/modules/markup/markdown"
|
"gitea.dev/modules/markup/markdown"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
@@ -276,27 +277,29 @@ func GetFeedType(name string, req *http.Request) (showFeed bool, feedType string
|
|||||||
|
|
||||||
// feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item
|
// feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item
|
||||||
func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) {
|
func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) {
|
||||||
|
canReadReleases := ctx.Repo.Permission.CanRead(unit.TypeReleases)
|
||||||
for _, rel := range releases {
|
for _, rel := range releases {
|
||||||
err := rel.LoadAttributes(ctx)
|
err := rel.LoadAttributes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var title string
|
|
||||||
var content template.HTML
|
var content template.HTML
|
||||||
|
title := rel.TagName
|
||||||
if rel.IsTag {
|
|
||||||
title = rel.TagName
|
|
||||||
} else {
|
|
||||||
title = rel.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
link := &feeds.Link{Href: rel.HTMLURL()}
|
link := &feeds.Link{Href: rel.HTMLURL()}
|
||||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
|
|
||||||
content, err = markdown.RenderString(rctx,
|
if !canReadReleases {
|
||||||
rel.Note)
|
// the tags feed only requires code access, so list releases as plain tags like the tags page does
|
||||||
if err != nil {
|
link.Href = rel.Repo.HTMLURL() + "/src/tag/" + util.PathEscapeSegments(rel.TagName)
|
||||||
return nil, err
|
} else {
|
||||||
|
if !rel.IsTag {
|
||||||
|
title = rel.Title
|
||||||
|
}
|
||||||
|
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
|
||||||
|
content, err = markdown.RenderString(rctx, rel.Note)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items = append(items, &feeds.Item{
|
items = append(items, &feeds.Item{
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func orgHome(ctx *context.Context, viewRepositories bool) {
|
|||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
OwnerID: org.ID,
|
OwnerID: org.ID,
|
||||||
OrderBy: orderBy,
|
OrderBy: orderBy,
|
||||||
Private: ctx.IsSigned,
|
Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
|
||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
Language: language,
|
Language: language,
|
||||||
IncludeDescription: setting.UI.SearchRepoDescription,
|
IncludeDescription: setting.UI.SearchRepoDescription,
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ func loadHeaderCount(ctx *context.Context) error {
|
|||||||
repoCount, err := repo_model.CountRepository(ctx, repo_model.SearchRepoOptions{
|
repoCount, err := repo_model.CountRepository(ctx, repo_model.SearchRepoOptions{
|
||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
OwnerID: ctx.ContextUser.ID,
|
OwnerID: ctx.ContextUser.ID,
|
||||||
Private: ctx.IsSigned,
|
Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
|
||||||
Collaborate: optional.Some(false),
|
Collaborate: optional.Some(false),
|
||||||
IncludeDescription: setting.UI.SearchRepoDescription,
|
IncludeDescription: setting.UI.SearchRepoDescription,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -714,7 +714,7 @@ func UsernameSubRoute(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check view permissions
|
// check view permissions
|
||||||
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
|
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) || (context.TokenIsPublicOnly(ctx) && !ctx.ContextUser.Visibility.IsPublic()) {
|
||||||
ctx.NotFound(fmt.Errorf("%s", ctx.ContextUser.Name))
|
ctx.NotFound(fmt.Errorf("%s", ctx.ContextUser.Name))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -743,6 +743,9 @@ func UsernameSubRoute(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
context.UserAssignmentWeb()(ctx)
|
context.UserAssignmentWeb()(ctx)
|
||||||
|
if !ctx.Written() && context.TokenIsPublicOnly(ctx) && !ctx.ContextUser.Visibility.IsPublic() {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
}
|
||||||
if !ctx.Written() {
|
if !ctx.Written() {
|
||||||
ctx.Data["EnableFeed"] = setting.Other.EnableFeed
|
ctx.Data["EnableFeed"] = setting.Other.EnableFeed
|
||||||
OwnerProfile(ctx)
|
OwnerProfile(ctx)
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
|||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
OrderBy: orderBy,
|
OrderBy: orderBy,
|
||||||
Private: ctx.IsSigned,
|
Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
|
||||||
StarredByID: ctx.ContextUser.ID,
|
StarredByID: ctx.ContextUser.ID,
|
||||||
Collaborate: optional.Some(false),
|
Collaborate: optional.Some(false),
|
||||||
TopicOnly: topicOnly,
|
TopicOnly: topicOnly,
|
||||||
@@ -230,7 +230,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
|||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
OrderBy: orderBy,
|
OrderBy: orderBy,
|
||||||
Private: ctx.IsSigned,
|
Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
|
||||||
WatchedByID: ctx.ContextUser.ID,
|
WatchedByID: ctx.ContextUser.ID,
|
||||||
Collaborate: optional.Some(false),
|
Collaborate: optional.Some(false),
|
||||||
TopicOnly: topicOnly,
|
TopicOnly: topicOnly,
|
||||||
@@ -290,7 +290,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
|||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
OwnerID: ctx.ContextUser.ID,
|
OwnerID: ctx.ContextUser.ID,
|
||||||
OrderBy: orderBy,
|
OrderBy: orderBy,
|
||||||
Private: ctx.IsSigned,
|
Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
|
||||||
Collaborate: optional.Some(false),
|
Collaborate: optional.Some(false),
|
||||||
TopicOnly: topicOnly,
|
TopicOnly: topicOnly,
|
||||||
Language: language,
|
Language: language,
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func TestCleanupRetentionZeroKeepsForever(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Zero(t, art.ExpiredUnix)
|
assert.Zero(t, art.ExpiredUnix)
|
||||||
|
|
||||||
// a workflow-requested expiry is honored, only the instance default may mean never
|
// a workflow-requested expiry is bounded, only the instance default may mean never
|
||||||
asked, err := actions_model.CreateArtifact(t.Context(), task, "client-asked", "b.txt", optional.Some(timeutil.TimeStampNow()))
|
asked, err := actions_model.CreateArtifact(t.Context(), task, "client-asked", "b.txt", optional.Some(timeutil.TimeStampNow()))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Positive(t, asked.ExpiredUnix)
|
assert.Positive(t, asked.ExpiredUnix)
|
||||||
@@ -141,6 +141,7 @@ func TestCleanupRetentionZeroKeepsForever(t *testing.T) {
|
|||||||
_, err = db.GetEngine(t.Context()).In("id", art.ID, past.ID).Cols("status").
|
_, err = db.GetEngine(t.Context()).In("id", art.ID, past.ID).Cols("status").
|
||||||
Update(&actions_model.ActionArtifact{Status: actions_model.ArtifactStatusUploadConfirmed})
|
Update(&actions_model.ActionArtifact{Status: actions_model.ArtifactStatusUploadConfirmed})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, actions_model.UpdateArtifact(t.Context(), &actions_model.ActionArtifact{ID: past.ID, ExpiredUnix: 1}, "expired_unix"))
|
||||||
expiring, err := actions_model.ListNeedExpiredArtifacts(t.Context())
|
expiring, err := actions_model.ListNeedExpiredArtifacts(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
ids := container.FilterSlice(expiring, func(a *actions_model.ActionArtifact) (int64, bool) { return a.ID, true })
|
ids := container.FilterSlice(expiring, func(a *actions_model.ActionArtifact) (int64, bool) { return a.ID, true })
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
|
|||||||
refProtected = false
|
refProtected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retentionDays := "" // not "0", v3 artifact uploads would read it as a 0-day limit
|
||||||
|
if setting.Actions.ArtifactRetentionDays > 0 {
|
||||||
|
retentionDays = strconv.FormatInt(setting.Actions.ArtifactRetentionDays, 10)
|
||||||
|
}
|
||||||
|
|
||||||
gitContext := GiteaContext{
|
gitContext := GiteaContext{
|
||||||
// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||||
"action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2.
|
"action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2.
|
||||||
@@ -89,7 +94,7 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
|
|||||||
"repository": run.Repo.OwnerName + "/" + run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World.
|
"repository": run.Repo.OwnerName + "/" + run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World.
|
||||||
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
|
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
|
||||||
"repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
|
"repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
|
||||||
"retention_days": "", // string, The number of days that workflow run logs and artifacts are kept.
|
"retention_days": retentionDays, // string, The number of days that artifacts are kept, empty when kept forever.
|
||||||
"run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
|
"run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
|
||||||
"run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
|
"run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
|
||||||
"run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
|
"run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
actions_module "gitea.dev/modules/actions"
|
actions_module "gitea.dev/modules/actions"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
webhook_module "gitea.dev/modules/webhook"
|
webhook_module "gitea.dev/modules/webhook"
|
||||||
@@ -408,6 +409,11 @@ func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
|
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
|
||||||
assert.Equal(t, "main", giteaCtx["ref_name"])
|
assert.Equal(t, "main", giteaCtx["ref_name"])
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 7)()
|
||||||
|
assert.Equal(t, "7", GenerateGiteaContext(t.Context(), run, nil, nil)["retention_days"])
|
||||||
|
setting.Actions.ArtifactRetentionDays = 0
|
||||||
|
assert.Empty(t, GenerateGiteaContext(t.Context(), run, nil, nil)["retention_days"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateGiteaContextRefProtected(t *testing.T) {
|
func TestGenerateGiteaContextRefProtected(t *testing.T) {
|
||||||
|
|||||||
@@ -1620,6 +1620,22 @@ func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error)
|
|||||||
return diff, nil
|
return diff, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCodeCommentPatch returns the code snippet stored with a code comment
|
||||||
|
func GetCodeCommentPatch(ctx context.Context, gitRepo *git.Repository, mergeBase, headCommitID, treePath string, line int64) (string, error) {
|
||||||
|
patch, err := git.GetFileDiffCutAroundLine(ctx, gitRepo, mergeBase, headCommitID, treePath,
|
||||||
|
int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
|
||||||
|
)
|
||||||
|
contextCommitID := util.Iif(line < 0, mergeBase, headCommitID)
|
||||||
|
if err != nil || patch != "" || contextCommitID == "" {
|
||||||
|
return patch, err
|
||||||
|
}
|
||||||
|
patch, err = GeneratePatchForUnchangedLine(ctx, gitRepo, contextCommitID, treePath, line, setting.UI.CodeCommentLines)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Unable to generate patch for unchanged line (file=%s, line=%d, commit=%s): %v", treePath, line, contextCommitID, err)
|
||||||
|
}
|
||||||
|
return patch, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GeneratePatchForUnchangedLine creates a patch showing code context for an unchanged line
|
// GeneratePatchForUnchangedLine creates a patch showing code context for an unchanged line
|
||||||
func GeneratePatchForUnchangedLine(ctx context.Context, gitRepo *git.Repository, commitID, treePath string, line int64, contextLines int) (string, error) {
|
func GeneratePatchForUnchangedLine(ctx context.Context, gitRepo *git.Repository, commitID, treePath string, line int64, contextLines int) (string, error) {
|
||||||
commit, err := gitRepo.GetCommit(ctx, commitID)
|
commit, err := gitRepo.GetCommit(ctx, commitID)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/uri"
|
"gitea.dev/modules/uri"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
"gitea.dev/services/gitdiff"
|
||||||
"gitea.dev/services/pull"
|
"gitea.dev/services/pull"
|
||||||
repo_service "gitea.dev/services/repository"
|
repo_service "gitea.dev/services/repository"
|
||||||
)
|
)
|
||||||
@@ -905,10 +906,7 @@ func (g *GiteaLocalUploader) CreateReviews(ctx context.Context, reviews ...*base
|
|||||||
// SECURITY: The TreePath must be cleaned! use relative path
|
// SECURITY: The TreePath must be cleaned! use relative path
|
||||||
comment.TreePath = util.PathJoinRel(comment.TreePath)
|
comment.TreePath = util.PathJoinRel(comment.TreePath)
|
||||||
|
|
||||||
patch, _ := git.GetFileDiffCutAroundLine(ctx,
|
patch, _ := gitdiff.GetCodeCommentPatch(ctx, g.gitRepo, pr.MergeBase, headCommitID, comment.TreePath, int64(line+comment.Position-1))
|
||||||
g.gitRepo, pr.MergeBase, headCommitID, comment.TreePath,
|
|
||||||
int64((&issues_model.Comment{Line: int64(line + comment.Position - 1)}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
|
|
||||||
)
|
|
||||||
|
|
||||||
if comment.CreatedAt.IsZero() {
|
if comment.CreatedAt.IsZero() {
|
||||||
comment.CreatedAt = review.CreatedAt
|
comment.CreatedAt = review.CreatedAt
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ type GithubDownloaderV3 struct {
|
|||||||
maxPerPage int
|
maxPerPage int
|
||||||
SkipReactions bool
|
SkipReactions bool
|
||||||
SkipReviews bool
|
SkipReviews bool
|
||||||
|
|
||||||
|
issuePage int
|
||||||
|
issueCursor string
|
||||||
|
nextIssueCursor string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGithubDownloaderV3 creates a github Downloader via github v3 API
|
// NewGithubDownloaderV3 creates a github Downloader via github v3 API
|
||||||
@@ -428,12 +432,21 @@ func (g *GithubDownloaderV3) GetIssues(ctx context.Context, page, perPage int) (
|
|||||||
Page: page,
|
Page: page,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
switch page {
|
||||||
|
case g.issuePage:
|
||||||
|
opt.After = g.issueCursor
|
||||||
|
case g.issuePage + 1:
|
||||||
|
opt.After = g.nextIssueCursor
|
||||||
|
}
|
||||||
|
if opt.After != "" {
|
||||||
|
opt.ListOptions.Page = 0
|
||||||
|
}
|
||||||
|
|
||||||
allIssues := make([]*base.Issue, 0, perPage)
|
allIssues := make([]*base.Issue, 0, perPage)
|
||||||
g.waitAndPickClient(ctx)
|
g.waitAndPickClient(ctx)
|
||||||
issues, resp, err := g.getClient().Issues.ListByRepo(ctx, g.repoOwner, g.repoName, opt)
|
issues, resp, err := g.getClient().Issues.ListByRepo(ctx, g.repoOwner, g.repoName, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("error while listing repos: %w", err)
|
return nil, false, fmt.Errorf("GetIssues: %w", err)
|
||||||
}
|
}
|
||||||
log.Trace("Request get issues %d/%d, but in fact get %d", perPage, page, len(issues))
|
log.Trace("Request get issues %d/%d, but in fact get %d", perPage, page, len(issues))
|
||||||
g.setRate(&resp.Rate)
|
g.setRate(&resp.Rate)
|
||||||
@@ -500,7 +513,8 @@ func (g *GithubDownloaderV3) GetIssues(ctx context.Context, page, perPage int) (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return allIssues, len(issues) < perPage, nil
|
g.issuePage, g.issueCursor, g.nextIssueCursor = page, opt.After, resp.After
|
||||||
|
return allIssues, resp.NextPage == 0 && resp.After == "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportGetRepoComments return true if it supports get repo comments
|
// SupportGetRepoComments return true if it supports get repo comments
|
||||||
|
|||||||
@@ -5,9 +5,13 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,6 +23,101 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestGithubIssuePagination(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
pages []string
|
||||||
|
}{
|
||||||
|
{name: "offset", pages: []string{"page=1", "page=2", "page=3"}},
|
||||||
|
{name: "cursor", pages: []string{"page=1", "after=cursor-2", "after=cursor-3"}},
|
||||||
|
{name: "switch to cursor", pages: []string{"page=1", "page=2", "after=cursor-3"}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var requests []string
|
||||||
|
failReaction := true
|
||||||
|
issue := func(number int) string {
|
||||||
|
return fmt.Sprintf(`{"number":%d,"title":"Issue","user":{},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}`, number)
|
||||||
|
}
|
||||||
|
responses := []string{
|
||||||
|
"[" + issue(1) + `,{"pull_request":{}}]`,
|
||||||
|
`[{"pull_request":{}}]`,
|
||||||
|
"[" + issue(4) + "," + issue(5) + "]",
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("X-RateLimit-Remaining", "100")
|
||||||
|
if r.URL.Path == "/api/v3/repos/o/r/issues/4/reactions" && failReaction {
|
||||||
|
failReaction = false
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"temporary failure"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v3/repos/o/r/issues" {
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := r.URL.Query()
|
||||||
|
assert.Equal(t, "2", query.Get("per_page"))
|
||||||
|
assert.Equal(t, "all", query.Get("state"))
|
||||||
|
assert.Equal(t, "created", query.Get("sort"))
|
||||||
|
assert.Equal(t, "asc", query.Get("direction"))
|
||||||
|
query.Del("per_page")
|
||||||
|
query.Del("state")
|
||||||
|
query.Del("sort")
|
||||||
|
query.Del("direction")
|
||||||
|
pagination := query.Encode()
|
||||||
|
requests = append(requests, pagination)
|
||||||
|
page := slices.Index(tc.pages, pagination)
|
||||||
|
if page < 0 {
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"unexpected pagination"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if page+1 < len(tc.pages) {
|
||||||
|
w.Header().Set("Link", fmt.Sprintf(`<https://api.github.com/repos/o/r/issues?per_page=2&%s>; rel="next"`, tc.pages[page+1]))
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(responses[page]))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
downloader, err := NewGithubDownloaderV3(t.Context(), server.URL, "", "", "", "o", "r")
|
||||||
|
require.NoError(t, err)
|
||||||
|
issues, isEnd, err := downloader.GetIssues(t.Context(), 1, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, issues, 1)
|
||||||
|
assert.EqualValues(t, 1, issues[0].Number)
|
||||||
|
assert.False(t, isEnd)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
issues, isEnd, err = downloader.GetIssues(t.Context(), 2, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, issues)
|
||||||
|
assert.False(t, isEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = downloader.GetIssues(t.Context(), 3, 2)
|
||||||
|
require.Error(t, err)
|
||||||
|
for range 2 {
|
||||||
|
issues, isEnd, err = downloader.GetIssues(t.Context(), 3, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, issues, 2)
|
||||||
|
assert.EqualValues(t, 4, issues[0].Number)
|
||||||
|
assert.EqualValues(t, 5, issues[1].Number)
|
||||||
|
assert.True(t, isEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, isEnd, err = downloader.GetIssues(t.Context(), 1, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, isEnd)
|
||||||
|
issues, isEnd, err = downloader.GetIssues(t.Context(), 2, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, issues)
|
||||||
|
assert.False(t, isEnd)
|
||||||
|
assert.Equal(t, []string{tc.pages[0], tc.pages[1], tc.pages[1], tc.pages[2], tc.pages[2], tc.pages[2], tc.pages[0], tc.pages[1]}, requests)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGitHubDownloadRepo(t *testing.T) {
|
func TestGitHubDownloadRepo(t *testing.T) {
|
||||||
token := os.Getenv("GITHUB_READ_TOKEN")
|
token := os.Getenv("GITHUB_READ_TOKEN")
|
||||||
liveMode := token != ""
|
liveMode := token != ""
|
||||||
|
|||||||
+9
-18
@@ -17,14 +17,13 @@ import (
|
|||||||
"gitea.dev/modules/git/gitcmd"
|
"gitea.dev/modules/git/gitcmd"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/setting"
|
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/services/gitdiff"
|
"gitea.dev/services/gitdiff"
|
||||||
notify_service "gitea.dev/services/notify"
|
notify_service "gitea.dev/services/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
func isErrBlameNotFoundOrNotEnoughLines(err error) bool {
|
func isErrBlameNotFoundOrNotEnoughLines(err error) bool {
|
||||||
return gitcmd.IsStderr(err, gitcmd.StderrNoSuchPath, gitcmd.StderrFileNoEnoughLines)
|
return gitcmd.IsStderr(err, gitcmd.StderrNoSuchPath, gitcmd.StderrFileNoEnoughLines, gitcmd.StderrBadObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrDismissRequestOnClosedPR represents an error when a user tries to dismiss a review associated to a closed or merged PR.
|
// ErrDismissRequestOnClosedPR represents an error when a user tries to dismiss a review associated to a closed or merged PR.
|
||||||
@@ -248,10 +247,15 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo
|
|||||||
// Get latest commit referencing the commented line
|
// Get latest commit referencing the commented line
|
||||||
// No need for get commit for base branch changes
|
// No need for get commit for base branch changes
|
||||||
commit, err := lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
|
commit, err := lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
|
||||||
|
// A force push may have removed the review's stored commit.
|
||||||
|
if err != nil && head != pr.GetGitHeadRefName() {
|
||||||
|
head = pr.GetGitHeadRefName()
|
||||||
|
commit, err = lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
commitID = commit.ID.String()
|
commitID = commit.ID.String()
|
||||||
} else if !isErrBlameNotFoundOrNotEnoughLines(err) {
|
} else {
|
||||||
return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", pr.GetGitHeadRefName(), gitRepo.LogString(), treePath, line, err)
|
log.Debug("LineBlame[%s, %s, %s, %d]: %v", head, gitRepo.LogString(), treePath, line, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,23 +270,10 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo
|
|||||||
commitID = headCommitID
|
commitID = headCommitID
|
||||||
}
|
}
|
||||||
|
|
||||||
patch, err = git.GetFileDiffCutAroundLine(ctx,
|
patch, err = gitdiff.GetCodeCommentPatch(ctx, gitRepo, pr.MergeBase, headCommitID, treePath, line)
|
||||||
gitRepo, pr.MergeBase, headCommitID, treePath,
|
|
||||||
int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If patch is still empty (unchanged line), generate code context
|
|
||||||
contextCommitID := util.Iif(line < 0, pr.MergeBase, headCommitID) // the line number is relative to the diff sides, not to the blamed commitID
|
|
||||||
if patch == "" && contextCommitID != "" {
|
|
||||||
patch, err = gitdiff.GeneratePatchForUnchangedLine(ctx, gitRepo, contextCommitID, treePath, line, setting.UI.CodeCommentLines)
|
|
||||||
if err != nil {
|
|
||||||
// Log the error but don't fail comment creation
|
|
||||||
log.Debug("Unable to generate patch for unchanged line (file=%s, line=%d, commit=%s): %v", treePath, line, contextCommitID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
||||||
Type: issues_model.CommentTypeCode,
|
Type: issues_model.CommentTypeCode,
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ func getReleasePayloadInfo(p *api.ReleasePayload, linkFormatter linkFormatter, w
|
|||||||
text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)
|
text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.Action != api.HookReleaseDeleted && len(p.Release.Attachments) > 0 {
|
||||||
|
text += fmt.Sprintf(" (assets: %d)", len(p.Release.Attachments))
|
||||||
|
}
|
||||||
|
|
||||||
return text, color
|
return text, color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ func TestGetWikiPayloadInfo(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetReleasePayloadInfo(t *testing.T) {
|
func TestGetReleasePayloadInfo(t *testing.T) {
|
||||||
p := pullReleaseTestPayload()
|
p := pullReleaseTestPayload()
|
||||||
|
p.Release.Attachments = []*api.Attachment{{}, {}}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
action api.HookReleaseAction
|
action api.HookReleaseAction
|
||||||
@@ -587,12 +588,12 @@ func TestGetReleasePayloadInfo(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
api.HookReleasePublished,
|
api.HookReleasePublished,
|
||||||
"[test/repo] Release created: v1.0 by user1",
|
"[test/repo] Release created: v1.0 by user1 (assets: 2)",
|
||||||
greenColor,
|
greenColor,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
api.HookReleaseUpdated,
|
api.HookReleaseUpdated,
|
||||||
"[test/repo] Release updated: v1.0 by user1",
|
"[test/repo] Release updated: v1.0 by user1 (assets: 2)",
|
||||||
yellowColor,
|
yellowColor,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
<div class="ui buttons"><button type="button" class="ui button active">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
|
<div class="ui buttons"><button type="button" class="ui button active">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
|
||||||
<div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button active">2</button><button type="button" class="ui button">3</button></div>
|
<div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button active">2</button><button type="button" class="ui button">3</button></div>
|
||||||
<div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button active">3</button></div>
|
<div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button active">3</button></div>
|
||||||
|
<div class="ui primary buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
|
||||||
|
<div class="ui red buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
|
||||||
|
<div class="ui green buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+2
-2
@@ -5686,7 +5686,7 @@
|
|||||||
"x-go-name": "Description"
|
"x-go-name": "Description"
|
||||||
},
|
},
|
||||||
"due_on": {
|
"due_on": {
|
||||||
"description": "Deadline is the updated due date for the milestone",
|
"description": "Deadline is the due date for the milestone, use null or empty string to remove the deadline",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Deadline"
|
"x-go-name": "Deadline"
|
||||||
@@ -7416,7 +7416,7 @@
|
|||||||
"description": "IssueLabelsOption a collection of labels",
|
"description": "IssueLabelsOption a collection of labels",
|
||||||
"properties": {
|
"properties": {
|
||||||
"labels": {
|
"labels": {
|
||||||
"description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names",
|
"description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names.\nIf a repository label and an organization label share a name,\nthe repository label is used.",
|
||||||
"items": {},
|
"items": {},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"x-go-name": "Labels"
|
"x-go-name": "Labels"
|
||||||
|
|||||||
+2
-2
@@ -28779,7 +28779,7 @@
|
|||||||
"x-go-name": "Description"
|
"x-go-name": "Description"
|
||||||
},
|
},
|
||||||
"due_on": {
|
"due_on": {
|
||||||
"description": "Deadline is the updated due date for the milestone",
|
"description": "Deadline is the due date for the milestone, use null or empty string to remove the deadline",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"x-go-name": "Deadline"
|
"x-go-name": "Deadline"
|
||||||
@@ -30525,7 +30525,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"labels": {
|
"labels": {
|
||||||
"description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names",
|
"description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names.\nIf a repository label and an organization label share a name,\nthe repository label is used.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {},
|
"items": {},
|
||||||
"x-go-name": "Labels"
|
"x-go-name": "Labels"
|
||||||
|
|||||||
@@ -175,6 +175,40 @@ func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
|
|||||||
MakeRequest(t, req, http.StatusNoContent)
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIAddIssueLabelsWithSharedRepoAndOrgLabelName(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6, RepoID: repo.ID})
|
||||||
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
repoLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 10, RepoID: repo.ID})
|
||||||
|
orgOnlyLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 4, OrgID: owner.ID})
|
||||||
|
|
||||||
|
assert.NoError(t, issues_model.NewLabel(t.Context(), &issues_model.Label{OrgID: owner.ID, Name: repoLabel.Name, Color: "#abcdef"}))
|
||||||
|
|
||||||
|
token := getTokenForLoggedInUser(t, loginUser(t, "user1"), auth_model.AccessTokenScopeWriteIssue)
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner.Name, repo.Name, issue.Index)
|
||||||
|
|
||||||
|
for _, method := range []string{"POST", "PUT"} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
req = NewRequestWithJSON(t, method, urlStr, &api.IssueLabelsOption{
|
||||||
|
Labels: []any{repoLabel.Name, orgOnlyLabel.Name},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
apiLabels := DecodeJSON(t, resp, []*api.Label{})
|
||||||
|
|
||||||
|
var apiLabelIDs []int64
|
||||||
|
for _, label := range apiLabels {
|
||||||
|
apiLabelIDs = append(apiLabelIDs, label.ID)
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []int64{repoLabel.ID, orgOnlyLabel.ID}, apiLabelIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIReplaceIssueLabels(t *testing.T) {
|
func TestAPIReplaceIssueLabels(t *testing.T) {
|
||||||
assert.NoError(t, unittest.LoadFixtures())
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func TestAPIIssuesMilestone(t *testing.T) {
|
|||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
apiMilestone := DecodeJSON(t, resp, &structs.Milestone{})
|
apiMilestone := DecodeJSON(t, resp, &structs.Milestone{})
|
||||||
assert.EqualValues(t, "closed", apiMilestone.State)
|
assert.EqualValues(t, "closed", apiMilestone.State)
|
||||||
|
assert.NotNil(t, apiMilestone.Deadline, "an omitted due_on keeps the due date")
|
||||||
|
|
||||||
req = NewRequest(t, "GET", urlStr).
|
req = NewRequest(t, "GET", urlStr).
|
||||||
AddTokenAuth(token)
|
AddTokenAuth(token)
|
||||||
@@ -48,6 +49,10 @@ func TestAPIIssuesMilestone(t *testing.T) {
|
|||||||
apiMilestone2 := DecodeJSON(t, resp, &structs.Milestone{})
|
apiMilestone2 := DecodeJSON(t, resp, &structs.Milestone{})
|
||||||
assert.EqualValues(t, "closed", apiMilestone2.State)
|
assert.EqualValues(t, "closed", apiMilestone2.State)
|
||||||
|
|
||||||
|
req = NewRequestWithJSON(t, "PATCH", urlStr, map[string]any{"due_on": ""}).AddTokenAuth(token)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
assert.Nil(t, DecodeJSON(t, resp, &structs.Milestone{}).Deadline)
|
||||||
|
|
||||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner.Name, repo.Name), structs.CreateMilestoneOption{
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner.Name, repo.Name), structs.CreateMilestoneOption{
|
||||||
Title: "wow",
|
Title: "wow",
|
||||||
Description: "closed one",
|
Description: "closed one",
|
||||||
|
|||||||
@@ -117,6 +117,24 @@ func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
|
|||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIPublicOnlyRejectedRoutes(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteOrganization,
|
||||||
|
auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopePublicOnly)
|
||||||
|
for _, route := range []struct{ method, url string }{
|
||||||
|
{"GET", "/api/v1/admin/orgs"},
|
||||||
|
{"POST", "/api/v1/orgs"},
|
||||||
|
{"POST", "/api/v1/org/org3/repos"},
|
||||||
|
{"POST", "/api/v1/orgs/org3/repos"},
|
||||||
|
{"DELETE", "/api/v1/orgs/org3/repos"},
|
||||||
|
{"POST", "/api/v1/orgs/org3/actions/runners/registration-token"},
|
||||||
|
{"POST", "/api/v1/repos/user2/repo1/generate"},
|
||||||
|
} {
|
||||||
|
MakeRequest(t, NewRequest(t, route.method, route.url).AddTokenAuth(token), http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPITeamReposPublicOnly(t *testing.T) {
|
func TestAPITeamReposPublicOnly(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
|||||||
@@ -35,4 +35,8 @@ func TestUserHeatmap(t *testing.T) {
|
|||||||
dummyheatmap = append(dummyheatmap, &activities_model.UserHeatmapData{Timestamp: 1603227600, Contributions: 1})
|
dummyheatmap = append(dummyheatmap, &activities_model.UserHeatmapData{Timestamp: 1603227600, Contributions: 1})
|
||||||
|
|
||||||
assert.Equal(t, dummyheatmap, heatmap)
|
assert.Equal(t, dummyheatmap, heatmap)
|
||||||
|
|
||||||
|
publicOnlyToken := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/heatmap", normalUsername)).AddTokenAuth(publicOnlyToken)
|
||||||
|
assert.Empty(t, DecodeJSON(t, MakeRequest(t, req, http.StatusOK), []*activities_model.UserHeatmapData{}))
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
|
unit_model "gitea.dev/models/unit"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
repo_service "gitea.dev/services/repository"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFeedRepo(t *testing.T) {
|
func TestFeedRepo(t *testing.T) {
|
||||||
@@ -33,6 +38,19 @@ func TestFeedRepo(t *testing.T) {
|
|||||||
assert.Equal(t, "issue5", rss.Channel.Items[0].Description)
|
assert.Equal(t, "issue5", rss.Channel.Items[0].Description)
|
||||||
assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
|
assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("TagsWithoutReleasesAccess", func(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), repo, nil, []unit_model.Type{unit_model.TypeReleases}))
|
||||||
|
|
||||||
|
resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/tags.rss"), http.StatusOK)
|
||||||
|
data := resp.Body.String()
|
||||||
|
assert.Contains(t, data, "/user2/repo1/src/tag/v1.0")
|
||||||
|
assert.NotContains(t, data, "pre-release")
|
||||||
|
assert.NotContains(t, data, "some text for a pre release")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the
|
// TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the
|
||||||
|
|||||||
@@ -107,3 +107,27 @@ func TestProfileActivityPublicOnlyToken(t *testing.T) {
|
|||||||
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
|
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
|
||||||
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
|
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProfileReposAndLimitedOwnersPublicOnlyToken(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
|
||||||
|
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
|
||||||
|
for link, privateRepoLink := range map[string]string{
|
||||||
|
"/user2?tab=repositories&q=repo2": `"/user2/repo2"`,
|
||||||
|
"/user2?tab=stars": `"/user2/repo2"`,
|
||||||
|
"/org3": `"/org3/repo3"`,
|
||||||
|
} {
|
||||||
|
reqFull := NewRequest(t, "GET", link)
|
||||||
|
reqFull.SetBasicAuth("user2", fullToken)
|
||||||
|
assert.Contains(t, MakeRequest(t, reqFull, http.StatusOK).Body.String(), privateRepoLink, link)
|
||||||
|
reqPublicOnly := NewRequest(t, "GET", link)
|
||||||
|
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
|
||||||
|
assert.NotContains(t, MakeRequest(t, reqPublicOnly, http.StatusOK).Body.String(), privateRepoLink, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, limitedLink := range []string{"/user33", "/user33.rss", "/limited_org"} {
|
||||||
|
assertBasicAuthStatus(t, limitedLink, fullToken, http.StatusOK)
|
||||||
|
assertBasicAuthStatus(t, limitedLink, publicOnlyToken, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,27 @@ func TestViewIssuesSortByType(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIssueIDsSortByLabelScope(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
high := &issues_model.Label{RepoID: 1, Name: "priority/high", Exclusive: true, ExclusiveOrder: 1}
|
||||||
|
low := &issues_model.Label{RepoID: 1, Name: "priority/low", Exclusive: true, ExclusiveOrder: 2}
|
||||||
|
assert.NoError(t, db.Insert(t.Context(), high, low))
|
||||||
|
assert.NoError(t, db.Insert(t.Context(),
|
||||||
|
&issues_model.IssueLabel{IssueID: 1, LabelID: low.ID},
|
||||||
|
&issues_model.IssueLabel{IssueID: 2, LabelID: high.ID},
|
||||||
|
))
|
||||||
|
for page, expectedIDs := range [][]int64{{2, 1, 11}, {5, 3}} {
|
||||||
|
issueIDs, total, err := issues_model.IssueIDs(t.Context(), &issues_model.IssuesOptions{
|
||||||
|
RepoIDs: []int64{1},
|
||||||
|
SortType: issues_model.ScopeSortPrefix + "priority",
|
||||||
|
Paginator: &db.ListOptions{Page: page + 1, PageSize: 3},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedIDs, issueIDs)
|
||||||
|
assert.EqualValues(t, 5, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestViewIssuesKeyword(t *testing.T) {
|
func TestViewIssuesKeyword(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
|||||||
@@ -1123,6 +1123,12 @@ func testOAuthGrantScopesClaimPublicOnlyGroups(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed))
|
require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed))
|
||||||
assert.Contains(t, userinfoParsed.Email, "user2@example.com")
|
assert.Contains(t, userinfoParsed.Email, "user2@example.com")
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.DisableQueryAuthToken, false)()
|
||||||
|
queryUserinfoResp := MakeRequest(t, NewRequest(t, "GET", "/login/oauth/userinfo?access_token="+parsed.AccessToken), http.StatusOK)
|
||||||
|
queryUserinfoParsed := new(userinfoResponse)
|
||||||
|
require.NoError(t, json.Unmarshal(queryUserinfoResp.Body.Bytes(), queryUserinfoParsed))
|
||||||
|
assert.Equal(t, userinfoParsed.Groups, queryUserinfoParsed.Groups)
|
||||||
|
|
||||||
// test both id_token and call to /login/oauth/userinfo
|
// test both id_token and call to /login/oauth/userinfo
|
||||||
for _, publicGroup := range []string{
|
for _, publicGroup := range []string{
|
||||||
"org17",
|
"org17",
|
||||||
|
|||||||
@@ -245,16 +245,19 @@
|
|||||||
border-color: var(--color-red-dark-2);
|
border-color: var(--color-red-dark-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.green.button {
|
.ui.green.button,
|
||||||
|
.ui.green.buttons .button {
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
background: var(--color-green);
|
background: var(--color-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.green.button:hover {
|
.ui.green.button:hover,
|
||||||
|
.ui.green.buttons .button:hover {
|
||||||
background: var(--color-green-dark-1);
|
background: var(--color-green-dark-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.green.button:active {
|
.ui.green.button:active,
|
||||||
|
.ui.green.buttons .button:active {
|
||||||
background: var(--color-green-dark-2);
|
background: var(--color-green-dark-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +340,14 @@ It needs some tricks to tweak the left/right borders with active state */
|
|||||||
border-left: 1px solid var(--color-secondary-dark-2);
|
border-left: 1px solid var(--color-secondary-dark-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui:is(.primary, .red, .green).buttons .button:hover {
|
||||||
|
border-color: transparent; /* let the colored hover background fill the border */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui:is(.primary, .red, .green).buttons .button:hover + .button {
|
||||||
|
border-left-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* TODO: these "tw-hidden" selectors are only used by "blame.tmpl" buttons: Raw/Normal View/History/Unescape, need to be refactored to a clear solution later */
|
/* TODO: these "tw-hidden" selectors are only used by "blame.tmpl" buttons: Raw/Normal View/History/Unescape, need to be refactored to a clear solution later */
|
||||||
.ui.buttons .button:first-child,
|
.ui.buttons .button:first-child,
|
||||||
.ui.buttons .button.tw-hidden:first-child + .button {
|
.ui.buttons .button.tw-hidden:first-child + .button {
|
||||||
|
|||||||
Reference in new issue
Block a user