mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-13 16:33:26 +09:00
Compare commits
30
Commits
main
...
b2794f96b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2794f96b9 | ||
|
|
b6aa2b61c6 | ||
|
|
74ad781db9 | ||
|
|
ed493f0684 | ||
|
|
af7ba2edef | ||
|
|
8b4252e7f6 | ||
|
|
500a09e044 | ||
|
|
bfebc4b0e1 | ||
|
|
d9534e7bfa | ||
|
|
532828cc82 | ||
|
|
8cf988f0a6 | ||
|
|
04a26f40a0 | ||
|
|
fc4eac390a | ||
|
|
64d559a8e6 | ||
|
|
03372836ab | ||
|
|
1af1e06ac4 | ||
|
|
e07a5de53f | ||
|
|
d4d81af583 | ||
|
|
6cd2c647ec | ||
|
|
37c8604105 | ||
|
|
a9814e789c | ||
|
|
ab10e37acf | ||
|
|
f7bc6b89c1 | ||
|
|
fabc5e6fcb | ||
|
|
a016d678db | ||
|
|
b6e409badd | ||
|
|
2734504cfc | ||
|
|
eee7967b81 | ||
|
|
1df8f91691 | ||
|
|
4654e7eccd |
+268
@@ -4,6 +4,274 @@ This changelog goes through the changes that have been made in each release
|
||||
without substantial changes to our git log; to see the highlights of what has
|
||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
|
||||
## [1.27.0-rc0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0-rc0) - 2026-06-28
|
||||
|
||||
* BREAKING
|
||||
* Feat(actions)!: improve support for reusable workflows (#37478)
|
||||
* Use Content-Security-Policy: script nonce (#37232)
|
||||
|
||||
* SECURITY
|
||||
* Fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#37786)
|
||||
* Fix(oauth): restrict introspection to the token's client (#38042)
|
||||
* Fix(api): don't expose private org membership via public_members (#38145)
|
||||
* Fix(actions): deny fork-PR cross-repo access via collaborative owner (#38214)
|
||||
* Fix(migrations): prevent path traversal in repository restore (#38215)
|
||||
|
||||
* FEATURES
|
||||
* Feat(actions): add workflow status badge modal (#38196)
|
||||
* Feat(actions): support owner-level and global scoped workflows (#38154)
|
||||
* Feat(api): support ref suffixes in compare (#38148)
|
||||
* Feat(actions): implement `jobs.<job_id>.continue-on-error` (#38100)
|
||||
* Feat(actions): show run status on browser tab favicon (#38071)
|
||||
* Feat(api): add token introspection and self-deletion endpoint (#37995)
|
||||
* Feat(api): add q parameter to list branches API for server-side filtering (#37982)
|
||||
* Feat(repo): split repository creation limit into user and org scopes (#37872)
|
||||
* Feat(actions): bulk delete, disable and enable runners in admin UI (#37869)
|
||||
* Feat(actions): List workflows that were executed once but got removed from the default branch (#37835)
|
||||
* Feat(org): add team visibility so org members can discover teams (#37680)
|
||||
* Feat: add raw diff/patch endpoint for repository comparisons (#37632)
|
||||
* Feat: Add avatar stacks (#37594)
|
||||
* Feat(actions): add job summaries (GITHUB_STEP_SUMMARY) (#37500)
|
||||
* Feat(web): Add Jupyter Notebook (.ipynb) Rendering Support (#37433)
|
||||
* Support for Custom URI Schemes in OAuth2 Redirect URIs (#37356)
|
||||
* Feat(orgs): Add search bar for organization members tab page (#37347)
|
||||
* Feat(api): Add assignees APIs (#37330)
|
||||
* Feat(api): Add GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs (#37196)
|
||||
* Serve OpenAPI 3.0 spec at /openapi.v1.json (#37038)
|
||||
* Add project column picker to issue and pull request sidebar (#37037)
|
||||
* Allow multiple projects per issue and pull requests (#36784)
|
||||
* Feat(ui): add "follow rename" to file commit history list (#34994)
|
||||
* Feat(ssh): auto generate additional ssh keys (#33974)
|
||||
|
||||
* ENHANCEMENTS
|
||||
* Enhance: allow builtin default git config options to be overridden (#38172)
|
||||
* Enhance: allow MathML core elements (#38034)
|
||||
* Enhance(markup): improve issue title rendering (#37908)
|
||||
* Enhance(actions): set descriptive browser tab title on run view (#37870)
|
||||
* Enhance: Migrate remaining gopkg.in/yaml.v3 usages to go.yaml.in/yaml/v4 (#37866)
|
||||
* Enhance(actions): show workflow name from YAML instead of filename (#37833)
|
||||
* Feat(actions): add before/after to PR synchronize event payload (#37827)
|
||||
* Enhance(actions): add branch filters to run list (#37826)
|
||||
* Enhance(actions): Make Summary UI more beautiful with more infos (#37824)
|
||||
* Feat: add copy button to action step header, improve other copy buttons (#37744)
|
||||
* Fix(icon): use repo-forked icon to display forks count (#37731)
|
||||
* Feat(api): add sort and order query parameters to job list endpoints (#37672)
|
||||
* Feat(api): add last_sync to repository API (#37566)
|
||||
* Enhance: Adjust Workflow Graph styling (#37497)
|
||||
* Improve code editor text selection and clean up lint enablement (#37474)
|
||||
* Add mirror auth updates to repo edit API and settings (#37468)
|
||||
* Replace `olivere/elastic` with REST API client, add OpenSearch support (#37411)
|
||||
* Feat: Add default PR branch update style setting (#37410)
|
||||
* Fix inconsistent disabled styling on logged-out repo header buttons (#37406)
|
||||
* Allow fast-forward-only merge when signed commits are required (#37335)
|
||||
* Enhance styling in actions page (#37323)
|
||||
* Fix: improve actions status icons and texts (#37206)
|
||||
* Make Markdown fenced code block work with more syntaxes (#37154)
|
||||
* Fix: Sort action run jobs by JobID and Name with matrix examples (#37046)
|
||||
* Add API endpoint to reply to pull request review comments (#36683)
|
||||
|
||||
* PERFORMANCE
|
||||
* Perf(web): sort the action_run query by a repo-scoped index when possible (#38155)
|
||||
* Perf: Various performance regression fixes (#38078)
|
||||
* Perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076)
|
||||
* Batch-load related data in actions run, job, and task API endpoints (#37032)
|
||||
|
||||
* BUGFIXES
|
||||
* Fix: update npm dependencies, fix misc issues (#38257)
|
||||
* Fix(api): respect since/until when counting commits for X-Total-Count (#38204)
|
||||
* Fix: codemirror regressions (#38248)
|
||||
* Fix(api): support HEAD requests on all API GET endpoints (#38245)
|
||||
* Fix(actions): Cleanup workflow status badge code (#38241)
|
||||
* Fix(web): Correctly align the "disabled" label on larger workflow names (#38240)
|
||||
* Fix(actions): don't swallow HTML entities into linkified URLs (#38239)
|
||||
* Fix(packages): accept npm "repository" and "bin" in string form (#38236)
|
||||
* Fix(actions): fix 500 error when canceling a canceling task (#38223)
|
||||
* Fix(deps): update module golang.org/x/image to v0.43.0 [security] (#38219)
|
||||
* Fix(mssql): convert legacy DATETIME columns to DATETIME2 (#38216)
|
||||
* Fix(api): deny private org member enumeration via /members (#38213)
|
||||
* Fix(actions): ensure all waiting jobs get runners in large workflows (#38200)
|
||||
* Fix(deps): update go dependencies (#38194)
|
||||
* Fix(deps): update npm dependencies (#38193)
|
||||
* Fix(cli): default must-change-password to false for bot users (#38175)
|
||||
* Fix(actions): show run index in run view and fix summary graph height (#38165)
|
||||
* Fix: csp (#38162)
|
||||
* Fix(deps): update npm dependencies (#38123)
|
||||
* Fix(mssql): expand legacy issue and comment long-text columns (#38120)
|
||||
* Fix(packages): validate debian distribution and component names (#38116)
|
||||
* Fix(packages): validate module version in goproxy ParsePackage (#38104)
|
||||
* Fix(deps): update dependency esbuild to v0.28.1 [security] (#38097)
|
||||
* Fix: git push hook post receive (#38089)
|
||||
* Fix(ui): prevent commit status popup overflowing its row (#38081)
|
||||
* Fix: validate gem name in rubygems parseMetadataFile (#38061)
|
||||
* Fix: commit display name (#38057)
|
||||
* Fix: csp regressions (#38047)
|
||||
* Fix: api error message (#38031)
|
||||
* Fix(deps): update npm dependencies (#38029)
|
||||
* Fix: pgsql lint (#38022)
|
||||
* Fix(indexer): fix assignee filters in issue search (#38021)
|
||||
* Fix: various dropdown problems (#38020)
|
||||
* Fix: refactor git error handling and make archive streaming handle non-existing commit id (#38007)
|
||||
* Fix: raise git required version to 2.13 (#37996)
|
||||
* Fix: remove "no-transfrom" from the cache-control header (#37985)
|
||||
* Fix(deps): update module github.com/google/go-github/v87 to v88 (#37971)
|
||||
* Fix: use committer time where ever possible as default (#37969)
|
||||
* Fix(deps): update npm dependencies, remove nolyfill (#37968)
|
||||
* Fix(deps): update go dependencies (#37967)
|
||||
* Fix(pull): preserve squash message trailers and additional commit messages (#37954)
|
||||
* Fix(deps): update module golang.org/x/image to v0.41.0 [security] (#37904)
|
||||
* Fix: support ##[command] log prefix in action run UI (#37882)
|
||||
* Fix(deps): update module github.com/google/go-github/v86 to v87 (#37845)
|
||||
* Fix(deps): update npm dependencies (#37844)
|
||||
* Fix(deps): update go dependencies (#37841)
|
||||
* Fix(frontend): resolve Vite assets by manifest source path (#37836)
|
||||
* Fix(locales): Replace hardcoded strings (#37788)
|
||||
* Fix(packages): render markdown links relative to linked repo (#37676)
|
||||
* Fix: persist mirror repository metadata (#37519)
|
||||
* Fix cmd tests by mocking builtin paths (#37369)
|
||||
* Add `form-fetch-action` to some forms, fix "fetch action" resp bug (#37305)
|
||||
* Feat: execute post run cleanup when workflow is cancelled (#37275)
|
||||
* Fix `relative-time` error and improve global error handler (#37241)
|
||||
* Refactor flash message and remove SanitizeHTML template func (#37179)
|
||||
|
||||
* TESTING
|
||||
* Test: speed up two tests (#37905)
|
||||
* Test: Fix random failure test (#37887)
|
||||
* Test: fix flaky `issue-comment` close test (#37880)
|
||||
* Test: enable WAL for sqlite integration tests (#37861)
|
||||
* Test: fix flaky `TestResourceIndex` and reduce its runtime (#37847)
|
||||
* Test: run `TestAPIRepoMigrate` offline via a local clone source (#37817)
|
||||
* Ci: shard tests and reduce redundant work (#37618)
|
||||
* Test(e2e): run playwright via container (#37300)
|
||||
* Remove external service dependencies in migration tests (#36866)
|
||||
|
||||
* BUILD
|
||||
* Fix(actions): authenticate snapcraft before nightly remote build (#38252)
|
||||
* Ci: cap Elasticsearch heap in db-tests (#37816)
|
||||
* Build(snap): publish nightly version to snapcraft via actions (#37814)
|
||||
* Ci: split pgsql shards into plain jobs, dedupe setup actions (#37802)
|
||||
* Ci: narrow files-changed frontend filter (#37749)
|
||||
* Ci: add `zizmor` to `lint-actions` (#37720)
|
||||
* Chore: clean up "contrib" dir (#37690)
|
||||
* Fix: snap build (main branch) (#37685)
|
||||
* Ci: Also lint json5 files (#37659)
|
||||
* Feat(editor): broaden language detection in web code editor (#37619)
|
||||
* Build: update pnpm to v11 (#37591)
|
||||
* Refactor(deps): migrate from `nektos/act` fork to `gitea/runner` (#37557)
|
||||
* Refactor: lint bare `fill`/`stroke` colors, add vars for git graph color series (#37543)
|
||||
* Update go js py dependencies (#37525)
|
||||
* Ci: lint PR titles with commitlint (#37498)
|
||||
* Chore: upgrade Go version in devcontainer image to 1.26 (#37374)
|
||||
* Update GitHub Actions to latest major versions (#37313)
|
||||
* Update go js dependencies (#37312)
|
||||
* Fail vite build on rolldown warnings via NODE_ENV=test (#37270)
|
||||
* Remove htmx (#37224)
|
||||
* Replace custom Go formatter with `golangci-lint fmt` (#37194)
|
||||
* Refactor htmx and fetch-action related code (#37186)
|
||||
* Integrate renovate bot for all dependency updates (#37050)
|
||||
* Build(sign): move to sigstore (#38250)
|
||||
|
||||
* DOCS
|
||||
* Docs: update changelog for 1.26.3 & 1.26.4 (#38178)
|
||||
* Docs: fix duplicated word in foreachref doc comment (#38161)
|
||||
* Docs: Clarify criteria for becoming a merger (#38113)
|
||||
* Docs: Publish TOC Election Result 2026 (#38111)
|
||||
* Docs: mark openapi3 as autogenerated in attributes (#37963)
|
||||
* Docs: add development setup guide (#37960)
|
||||
|
||||
* MISC
|
||||
* Revert(sign): restore gpg (#38251)
|
||||
* Refactor: replace legacy `delete-button` with `link-action` (#38143)
|
||||
* Refactor(actions): read runner capabilities from proto field (#38068)
|
||||
* Refactor(api): clarify APIError message usage and fix legacy lint error (#38012)
|
||||
* Refactor: Use db.Get[] instead of db.GetEngine(ctx).Get(bean) to avoid zero value fetching wrong database record (#37977)
|
||||
* Fix(deps): update go dependencies (#37851)
|
||||
* Ci: Fix sync PR labels from the conventional-commit title (#37784) (#37825)
|
||||
* Ci: tweak `files-changed`, add `free-disk-space` (#37819)
|
||||
* Fix(deps): update module golang.org/x/crypto to v0.52.0 [security] (#37806)
|
||||
* Test(e2e): add comment, release, star, PR and fork tests (#37800)
|
||||
* Chore: simplify issue and pull request templates (#37799)
|
||||
* Chore: Update giteabot to fix failure when backport (#37789)
|
||||
* Fix(api): handle partial failures in push mirror synchronization gracefully (#37782)
|
||||
* Fix(deps): update module gitlab.com/gitlab-org/api/client-go/v2 to v2.26.0 (#37771)
|
||||
* Ci: split giteabot workflow (#37770)
|
||||
* Fix(deps): update npm dependencies (#37768)
|
||||
* Refactor(waitgroup): replace Add/Done goroutines with WaitGroup.Go (#37764)
|
||||
* Fix(deps): update module google.golang.org/grpc to v1.81.1 (#37762)
|
||||
* Ci: fix cache-related issues (#37761)
|
||||
* Chore: fix tests (#37760)
|
||||
* Fix(deps): update module github.com/google/go-github/v85 to v86 (#37754)
|
||||
* Fix(deps): update npm dependencies (#37753)
|
||||
* Fix(deps): update go dependencies (#37752)
|
||||
* Chore(deps): update action dependencies (#37751)
|
||||
* Fix(markup): wrap indented code blocks for the code-copy button (#37748)
|
||||
* Chore(db): introduce db.Session and db.EngineMigration interfaces (#37746)
|
||||
* Feat(web): also display PR counts in repo list (#37739)
|
||||
* Refactor(glob): use strings.Builder for regexp compilation (#37730)
|
||||
* Chore(doctor): remove four obsolete doctor check implementations (#37728)
|
||||
* Refactor(org): simplify owner-team org repo creation logic (#37727)
|
||||
* Refactor: move `workflowpattern` into `modules/actions` (#37717)
|
||||
* Chore: clean up tests (#37715)
|
||||
* Style: misc UI fixes (#37691)
|
||||
* Ci: add shellcheck linter (#37682)
|
||||
* Fix: catch and fix more lint problems (#37674)
|
||||
* Fix(deps): update dependency mermaid to v11.15.0 [security], add e2e test (#37662)
|
||||
* Fix(deps): update npm dependencies (#37647)
|
||||
* Ci(renovate): update Go import paths on major bumps (#37641)
|
||||
* Fix(deps): update go dependencies (major) (#37639)
|
||||
* Chore(deps): update action dependencies (major) (#37638)
|
||||
* Fix(deps): update module code.gitea.io/sdk/gitea to v0.25.0 (#37637)
|
||||
* Fix(deps): update npm dependencies (#37636)
|
||||
* Refactor(log): replace log.Critical with log.Error (#37624)
|
||||
* Build(deps): bump fast-uri from 3.1.0 to 3.1.2 (#37616)
|
||||
* Feat(oauth): Support AWS Cognito OAuth2 provider (#37607)
|
||||
* Chore(deps): update action dependencies (#37603)
|
||||
* Ci: allow `chore` type in PR title lint (#37575)
|
||||
* Refactor: only reset a database table when the table's data was changed (#37573)
|
||||
* Ci: increase renovate frequency and fix RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS (#37565)
|
||||
* Refactor: use modernc sqlite driver as default (#37562)
|
||||
* Docs: fix 4 typos in CHANGELOG.md (#37549)
|
||||
* Fix(deps): update go dependencies (#37541)
|
||||
* Chore(deps): update action dependencies (#37540)
|
||||
* Refactor pull request view (6) (#37522)
|
||||
* Fix: redirect early CLI console logger to stderr (#37507)
|
||||
* Refactor "flex-list" to "flex-divided-list" (#37505)
|
||||
* Refactor compare diff/pull page (1) (#37481)
|
||||
* Refactor pull request view (4) (#37451)
|
||||
* Update 1.26.1 changelog in main (#37442)
|
||||
* Refactor: use named `Permission` field in `Repository` struct instead of anonymous embedding (#37441)
|
||||
* Refactor: serve site manifest via `/assets/site-manifest.json` endpoint (#37405)
|
||||
* Remove IsValidExternalURL/IsAPIURL and use IsValidURL at call sites (#37364)
|
||||
* Update `Block a user` form (#37359)
|
||||
* Move review request functions to a standalone file (#37358)
|
||||
* Feat(security): set X-Content-Type-Options: nosniff by default (#37354)
|
||||
* Enable strict TypeScript, add `errorMessage` helper (#37292)
|
||||
* Refactor frontend `tw-justify-between` layouts to `flex-left-right` (#37291)
|
||||
* Update Nix flake (#37284)
|
||||
* Fix Repository transferring page (#37277)
|
||||
* Remove `SubmitEvent` polyfill (#37276)
|
||||
* Remove dead code identified by `deadcode` tool (#37271)
|
||||
* Upgrade go-git to v5.18.0 (#37268)
|
||||
* Don't add useless labels which will bother changelog generation (#37267)
|
||||
* Move heatmap to first-party code (#37262)
|
||||
* Tests/integration: simplify code (#37249)
|
||||
* Add pagination and search box to org teams list (#37245)
|
||||
* Remove error returns from crypto random helpers and callers (#37240)
|
||||
* Add `ExternalIDClaim` option for OAuth2 OIDC auth source (#37229)
|
||||
* Refactor: simplify ParseCatFileTreeLine and catBatchParseTreeEntries (#37210)
|
||||
* Refactor "htmx" to "fetch action" (#37208)
|
||||
* Update go js py dependencies (#37204)
|
||||
* Add comment for the design of "user activity time" (#37195)
|
||||
* Remove outdated RunUser logic (#37180)
|
||||
* Models/fixtures: add "DO NOT add more test data" comment to all yml fixture files (#37150)
|
||||
* Update javascript dependencies (#37142)
|
||||
* Update go dependencies (#37141)
|
||||
* Frontport changelog of v1.26.0-rc0 (#37138)
|
||||
* Introduce `ActionRunAttempt` to represent each execution of a run (#37119)
|
||||
* Workflow Artifact Info Hover (#37100)
|
||||
* Extend issue context popup beyond markdown content (#36908)
|
||||
* Add bulk repository deletion for organizations (#36763)
|
||||
* Feat: Add bypass allowlist for branch protection (#36514)
|
||||
|
||||
## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
|
||||
|
||||
* SECURITY
|
||||
|
||||
@@ -3008,6 +3008,10 @@ LEVEL = Info
|
||||
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
|
||||
;; Maximum number of attempts a single workflow run can have. Default value is 50.
|
||||
;MAX_RERUN_ATTEMPTS = 50
|
||||
;; Maximum number of runners that may run the task-assignment query concurrently, per Gitea instance.
|
||||
;; Caps this instance's DB load when many runners poll at once; excess runners retry on their next poll.
|
||||
;; In a multi-instance deployment the cluster-wide limit is this value times the number of instances. Default value is 16.
|
||||
;MAX_CONCURRENT_TASK_PICKS = 16
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -121,7 +121,7 @@ func (run *ActionRun) PrettyRef() string {
|
||||
return refName.ShortName()
|
||||
}
|
||||
|
||||
// RefTooltip return a tooltop of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
// RefTooltip return a tooltip of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
func (run *ActionRun) RefTooltip() string {
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err == nil && payload != nil && payload.PullRequest != nil {
|
||||
|
||||
@@ -135,8 +135,8 @@ type StatusInfo struct {
|
||||
|
||||
// GetStatusInfoList returns a slice of StatusInfo
|
||||
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
|
||||
// same as those in aggregateJobStatus
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
|
||||
// same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
|
||||
statusInfoList := make([]StatusInfo, 0, len(allStatus))
|
||||
for _, s := range allStatus {
|
||||
statusInfoList = append(statusInfoList, StatusInfo{
|
||||
|
||||
@@ -73,8 +73,11 @@ func TestGetStatusInfoList(t *testing.T) {
|
||||
assert.Equal(t, []StatusInfo{
|
||||
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
|
||||
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
|
||||
{Status: int(StatusCancelled), StatusName: StatusCancelled.String(), DisplayedStatus: "actions.status.cancelled"},
|
||||
{Status: int(StatusSkipped), StatusName: StatusSkipped.String(), DisplayedStatus: "actions.status.skipped"},
|
||||
{Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
|
||||
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
|
||||
{Status: int(StatusBlocked), StatusName: StatusBlocked.String(), DisplayedStatus: "actions.status.blocked"},
|
||||
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
|
||||
}, statusInfoList)
|
||||
}
|
||||
|
||||
@@ -75,8 +75,28 @@ type ActionRunner struct {
|
||||
const (
|
||||
RunnerOfflineTime = time.Minute
|
||||
RunnerIdleTime = 10 * time.Second
|
||||
// RunnerHeartbeatInterval is how often last_online is persisted on poll.
|
||||
// Must stay well below RunnerOfflineTime so runners don't flap to offline.
|
||||
RunnerHeartbeatInterval = 30 * time.Second
|
||||
// RunnerActiveInterval is how often last_active is persisted while a runner
|
||||
// streams task updates and logs. Must stay well below RunnerIdleTime so a
|
||||
// busy runner keeps showing ACTIVE without a DB write on every RPC.
|
||||
RunnerActiveInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// ShouldPersistLastOnline reports whether last_online is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every runner poll.
|
||||
func ShouldPersistLastOnline(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerHeartbeatInterval
|
||||
}
|
||||
|
||||
// ShouldPersistLastActive reports whether last_active is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every UpdateTask/UpdateLog RPC while
|
||||
// a runner is actively streaming logs.
|
||||
func ShouldPersistLastActive(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerActiveInterval
|
||||
}
|
||||
|
||||
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
|
||||
func (r *ActionRunner) BelongsToOwnerName() string {
|
||||
if r.RepoID != 0 {
|
||||
@@ -251,21 +271,24 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
|
||||
}
|
||||
|
||||
func (opts FindRunnerOptions) ToOrders() string {
|
||||
// A unique tiebreaker (id) is appended so that runners sharing the same
|
||||
// last_online or name keep a deterministic order across paginated queries,
|
||||
// otherwise the same runner may appear on more than one page.
|
||||
switch opts.Sort {
|
||||
case "online":
|
||||
return "last_online DESC"
|
||||
return "last_online DESC, id ASC"
|
||||
case "offline":
|
||||
return "last_online ASC"
|
||||
return "last_online ASC, id ASC"
|
||||
case "alphabetically":
|
||||
return "name ASC"
|
||||
return "name ASC, id ASC"
|
||||
case "reversealphabetically":
|
||||
return "name DESC"
|
||||
return "name DESC, id ASC"
|
||||
case "newest":
|
||||
return "id DESC"
|
||||
case "oldest":
|
||||
return "id ASC"
|
||||
}
|
||||
return "last_online DESC"
|
||||
return "last_online DESC, id ASC"
|
||||
}
|
||||
|
||||
// GetRunnerByUUID returns a runner via uuid
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldPersistLastOnline(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-5 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastOnline(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistLastActive(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-1 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastActive(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
|
||||
// Sorts on a non-unique column must end with the unique id tiebreaker so
|
||||
// pagination is deterministic; without it, runners sharing the same
|
||||
// last_online or name can appear on more than one page. Sorts already on
|
||||
// the unique id need no tiebreaker.
|
||||
expected := map[string]string{
|
||||
"": "last_online DESC, id ASC",
|
||||
"online": "last_online DESC, id ASC",
|
||||
"offline": "last_online ASC, id ASC",
|
||||
"alphabetically": "name ASC, id ASC",
|
||||
"reversealphabetically": "name DESC, id ASC",
|
||||
"newest": "id DESC",
|
||||
"oldest": "id ASC",
|
||||
}
|
||||
for sort, want := range expected {
|
||||
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Create several runners that all share the same last_online value so the
|
||||
// primary sort key (last_online) is tied for all of them.
|
||||
const ownerID = 1000
|
||||
const count = 6
|
||||
for i := range count {
|
||||
runner := &ActionRunner{
|
||||
Name: "paginated-runner",
|
||||
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
|
||||
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
|
||||
OwnerID: ownerID,
|
||||
RepoID: 0,
|
||||
LastOnline: 42,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, runner))
|
||||
}
|
||||
|
||||
// Page through the runners and ensure every id is returned exactly once.
|
||||
seen := make(map[int64]int)
|
||||
const pageSize = 2
|
||||
for page := 1; ; page++ {
|
||||
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
|
||||
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if len(runners) == 0 {
|
||||
break
|
||||
}
|
||||
for _, r := range runners {
|
||||
seen[r.ID]++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
|
||||
for id, n := range seen {
|
||||
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
|
||||
}
|
||||
}
|
||||
+42
-17
@@ -231,6 +231,11 @@ func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||
// another runner won the optimistic-lock race; it is never returned to callers.
|
||||
var errJobAlreadyClaimed = errors.New("job already claimed by another runner")
|
||||
|
||||
// pickTaskBatchSize bounds how many waiting jobs each CreateTaskForRunner query loads,
|
||||
// so a large backlog is not fetched into memory on every runner poll.
|
||||
// It is a var only so tests can shrink it to exercise pagination cheaply.
|
||||
var pickTaskBatchSize = 100
|
||||
|
||||
// CreateTaskForRunner finds a waiting job that matches the runner's labels and
|
||||
// atomically claims it. It iterates through all matching jobs so that a
|
||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
||||
@@ -249,31 +254,51 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
|
||||
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
||||
}
|
||||
if jobCond.IsValid() {
|
||||
jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond))
|
||||
}
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where("task_id=? AND status=? AND is_reusable_caller=?", 0, StatusWaiting, false).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
|
||||
|
||||
// TODO: a more efficient way to filter labels
|
||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||
for _, v := range jobs {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
continue
|
||||
|
||||
// Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
|
||||
// Keyset pagination on (updated, id) is safe under concurrent claims:
|
||||
// updated only moves forward, so the advancing cursor never skips a still-waiting job even as claimed jobs drop out.
|
||||
var cursorUpdated timeutil.TimeStamp
|
||||
var cursorID int64
|
||||
for {
|
||||
cond := baseCond
|
||||
if cursorID > 0 {
|
||||
cond = cond.And(builder.Or(
|
||||
builder.Gt{"updated": cursorUpdated},
|
||||
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
||||
))
|
||||
}
|
||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
||||
if err != nil {
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
return task, true, nil
|
||||
|
||||
for _, v := range jobs {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
continue
|
||||
}
|
||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
return task, true, nil
|
||||
}
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
}
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
if len(jobs) < pickTaskBatchSize {
|
||||
return nil, false, nil
|
||||
}
|
||||
last := jobs[len(jobs)-1]
|
||||
cursorUpdated, cursorID = last.Updated, last.ID
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
||||
|
||||
@@ -371,3 +371,74 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
||||
unittest.AssertNotExistsBean(t, &ActionTask{ID: task.ID})
|
||||
unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: task.ID})
|
||||
}
|
||||
|
||||
// TestCreateTaskForRunnerPagination verifies that a job sitting beyond the first page is still claimed
|
||||
func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
defer func(orig int) { pickTaskBatchSize = orig }(pickTaskBatchSize)
|
||||
pickTaskBatchSize = 2
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "pagination-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 9903,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
// Five waiting jobs the runner cannot run, then one it can.
|
||||
// With a page size of 2 the matching job only appears on the third page.
|
||||
for i := range 5 {
|
||||
mismatch := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "mismatch-" + string(rune('a'+i)),
|
||||
Attempt: 1,
|
||||
JobID: "mismatch-" + string(rune('a'+i)),
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"windows-latest"},
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), mismatch))
|
||||
}
|
||||
|
||||
target := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "target-job",
|
||||
Attempt: 1,
|
||||
JobID: "target-job",
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: []byte("on: push\njobs:\n target-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), target))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "pagination-runner-uuid",
|
||||
Name: "pagination-runner",
|
||||
AgentLabels: []string{"ubuntu-latest"},
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, task)
|
||||
|
||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
||||
assert.Equal(t, StatusRunning, claimed.Status)
|
||||
assert.Equal(t, task.ID, claimed.TaskID)
|
||||
}
|
||||
|
||||
@@ -324,6 +324,59 @@ func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organizatio
|
||||
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
|
||||
}
|
||||
|
||||
// RecalculateReviewsOfficial re-evaluates the "official" flag of the latest approve
|
||||
// and reject reviews of an issue against its pull request's current base branch.
|
||||
// It must be called whenever the target branch changes, otherwise an approval that
|
||||
// was official on the previous (possibly unprotected) branch would keep satisfying
|
||||
// the new branch's protection rules.
|
||||
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clearing and restoring the official flags must happen atomically, otherwise a
|
||||
// failure in between would leave the reviews without any official flag set.
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Only the latest approve/reject review of each reviewer counts as official, so
|
||||
// clear the flag on all of them first and restore it only where it still applies.
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("issue_id = ?", issue.ID).
|
||||
In("type", ReviewTypeApprove, ReviewTypeReject).
|
||||
Cols("official").
|
||||
Update(&Review{Official: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
|
||||
Types: []ReviewType{ReviewTypeApprove, ReviewTypeReject},
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, review := range reviews {
|
||||
if err := review.LoadReviewer(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if review.Reviewer == nil {
|
||||
continue
|
||||
}
|
||||
official, err := IsOfficialReviewer(ctx, issue, review.Reviewer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if official {
|
||||
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(&Review{Official: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateReview creates a new review based on opts
|
||||
func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Review, error) {
|
||||
|
||||
@@ -6,6 +6,8 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -386,3 +388,45 @@ func TestAddReviewRequest(t *testing.T) {
|
||||
assert.NotNil(t, comment.CommentMetaData)
|
||||
assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName)
|
||||
}
|
||||
|
||||
func TestRecalculateReviewsOfficial(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// PR #2 targets repo1's "master" branch. Simulate an approval that became
|
||||
// official while the PR targeted an unprotected branch.
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||
Type: issues_model.ReviewTypeApprove,
|
||||
Issue: issue,
|
||||
Reviewer: reviewer,
|
||||
Official: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Protect the (now current) target branch with an approvals whitelist that
|
||||
// does not include the reviewer, mirroring a retarget onto a protected branch.
|
||||
rule := &git_model.ProtectedBranch{
|
||||
RepoID: issue.RepoID,
|
||||
RuleName: "master",
|
||||
EnableApprovalsWhitelist: true,
|
||||
ApprovalsWhitelistUserIDs: []int64{2},
|
||||
RequiredApprovals: 1,
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), rule))
|
||||
|
||||
// Re-evaluating must strip the stale official flag, otherwise the approval
|
||||
// would still satisfy the protected branch's required approvals.
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.False(t, review.Official)
|
||||
|
||||
// Once the reviewer is whitelisted, re-evaluating restores the official flag.
|
||||
rule.ApprovalsWhitelistUserIDs = []int64{2, reviewer.ID}
|
||||
_, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("approvals_whitelist_user_i_ds").Update(rule)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.True(t, review.Official)
|
||||
}
|
||||
|
||||
+23
-8
@@ -31,18 +31,28 @@ func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ e
|
||||
type SearchTeamRepoOptions struct {
|
||||
db.ListOptions
|
||||
TeamID int64
|
||||
// PublicOnly restricts the result (and count) to non-private repositories.
|
||||
PublicOnly bool
|
||||
}
|
||||
|
||||
func (opts *SearchTeamRepoOptions) toCond() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.TeamID > 0 {
|
||||
cond = cond.And(builder.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
))
|
||||
}
|
||||
if opts.PublicOnly {
|
||||
cond = cond.And(builder.Eq{"is_private": false})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetTeamRepositories returns paginated repositories in team of organization.
|
||||
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
||||
sess := db.GetEngine(ctx)
|
||||
if opts.TeamID > 0 {
|
||||
sess = sess.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
)
|
||||
}
|
||||
sess := db.GetEngine(ctx).Where(opts.toCond())
|
||||
if opts.PageSize > 0 {
|
||||
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||||
}
|
||||
@@ -51,6 +61,11 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (Repo
|
||||
Find(&repos)
|
||||
}
|
||||
|
||||
// CountTeamRepositories returns the number of repositories in team of organization matching opts.
|
||||
func CountTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(opts.toCond()).Count(new(Repository))
|
||||
}
|
||||
|
||||
// AccessibleReposEnvironment operations involving the repositories that are
|
||||
// accessible to a particular user
|
||||
type AccessibleReposEnvironment interface {
|
||||
|
||||
@@ -22,6 +22,9 @@ type StarredReposOptions struct {
|
||||
StarrerID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
// Actor is the user the private repositories are gated on: a private repo is only
|
||||
// returned when Actor still has access to it, even if it was starred while access was granted.
|
||||
Actor *user_model.User
|
||||
}
|
||||
|
||||
func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
@@ -39,7 +42,10 @@ func (opts *StarredReposOptions) ToConds() builder.Cond {
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
if opts.IncludePrivate {
|
||||
// only include private repos the actor can still access, so metadata does not leak after access revocation
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
} else {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
@@ -66,6 +72,9 @@ type WatchedReposOptions struct {
|
||||
WatcherID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
// Actor is the user the private repositories are gated on: a private repo is only
|
||||
// returned when Actor still has access to it, even if it was watched while access was granted.
|
||||
Actor *user_model.User
|
||||
}
|
||||
|
||||
func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
@@ -83,7 +92,10 @@ func (opts *WatchedReposOptions) ToConds() builder.Cond {
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
if opts.IncludePrivate {
|
||||
// only include private repos the actor can still access, so metadata does not leak after access revocation
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
} else {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
|
||||
@@ -55,29 +55,34 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event, returning those whose `on:` matches.
|
||||
// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event.
|
||||
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
|
||||
func MatchScopedWorkflows(
|
||||
parsed []*ParsedScopedWorkflow,
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
triggedEvent webhook_module.HookEventType,
|
||||
payload api.Payloader,
|
||||
) []*DetectedWorkflow {
|
||||
workflows := make([]*DetectedWorkflow, 0, len(parsed))
|
||||
) (matched, filtered []*DetectedWorkflow) {
|
||||
for _, p := range parsed {
|
||||
for _, evt := range p.Events {
|
||||
if evt.IsSchedule() {
|
||||
// schedule is a non-target for scoped workflows
|
||||
continue
|
||||
}
|
||||
if detectMatched(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||
workflows = append(workflows, &DetectedWorkflow{
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
})
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
}
|
||||
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
matched = append(matched, dwf)
|
||||
case detectFilteredOut:
|
||||
filtered = append(filtered, dwf)
|
||||
case detectNotApplicable:
|
||||
}
|
||||
}
|
||||
}
|
||||
return workflows
|
||||
return matched, filtered
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ type DetectedWorkflow struct {
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type detectResult int
|
||||
|
||||
const (
|
||||
detectMatched detectResult = iota // event matched; run normally
|
||||
detectNotApplicable // event/type doesn't apply; create nothing
|
||||
detectFilteredOut // matched but excluded by a branch/paths filter; posts a skipped commit status when the context is a required check
|
||||
)
|
||||
|
||||
func init() {
|
||||
model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
||||
// Log the error instead of panic or fatal.
|
||||
@@ -172,18 +180,16 @@ func DetectWorkflows(
|
||||
triggedEvent webhook_module.HookEventType,
|
||||
payload api.Payloader,
|
||||
detectSchedule bool,
|
||||
) ([]*DetectedWorkflow, []*DetectedWorkflow, error) {
|
||||
) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
|
||||
_, entries, err := ListWorkflows(commit)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
workflows := make([]*DetectedWorkflow, 0, len(entries))
|
||||
schedules := make([]*DetectedWorkflow, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
content, err := GetContentFromEntry(entry)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// one workflow may have multiple events
|
||||
@@ -203,18 +209,24 @@ func DetectWorkflows(
|
||||
}
|
||||
schedules = append(schedules, dwf)
|
||||
}
|
||||
} else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) {
|
||||
} else {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
}
|
||||
workflows = append(workflows, dwf)
|
||||
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
workflows = append(workflows, dwf)
|
||||
case detectFilteredOut:
|
||||
filtered = append(filtered, dwf)
|
||||
case detectNotApplicable:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return workflows, schedules, nil
|
||||
return workflows, schedules, filtered, nil
|
||||
}
|
||||
|
||||
func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
|
||||
@@ -252,9 +264,9 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) bool {
|
||||
func detectWorkflowMatch(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) detectResult {
|
||||
if !canGithubEventMatch(evt.Name, triggedEvent) {
|
||||
return false
|
||||
return detectNotApplicable
|
||||
}
|
||||
|
||||
switch triggedEvent {
|
||||
@@ -268,7 +280,7 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
||||
log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
|
||||
}
|
||||
// no special filter parameters for these events, just return true if name matched
|
||||
return true
|
||||
return detectMatched
|
||||
|
||||
case // push
|
||||
webhook_module.HookEventPush:
|
||||
@@ -279,14 +291,20 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
||||
webhook_module.HookEventIssueAssign,
|
||||
webhook_module.HookEventIssueLabel,
|
||||
webhook_module.HookEventIssueMilestone:
|
||||
return matchIssuesEvent(payload.(*api.IssuePayload), evt)
|
||||
if matchIssuesEvent(payload.(*api.IssuePayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // issue_comment
|
||||
webhook_module.HookEventIssueComment,
|
||||
// `pull_request_comment` is same as `issue_comment`
|
||||
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
|
||||
webhook_module.HookEventPullRequestComment:
|
||||
return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt)
|
||||
if matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // pull_request
|
||||
webhook_module.HookEventPullRequest,
|
||||
@@ -300,34 +318,49 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
||||
case // pull_request_review
|
||||
webhook_module.HookEventPullRequestReviewApproved,
|
||||
webhook_module.HookEventPullRequestReviewRejected:
|
||||
return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt)
|
||||
if matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // pull_request_review_comment
|
||||
webhook_module.HookEventPullRequestReviewComment:
|
||||
return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt)
|
||||
if matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // release
|
||||
webhook_module.HookEventRelease:
|
||||
return matchReleaseEvent(payload.(*api.ReleasePayload), evt)
|
||||
if matchReleaseEvent(payload.(*api.ReleasePayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // registry_package
|
||||
webhook_module.HookEventPackage:
|
||||
return matchPackageEvent(payload.(*api.PackagePayload), evt)
|
||||
if matchPackageEvent(payload.(*api.PackagePayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // workflow_run
|
||||
webhook_module.HookEventWorkflowRun:
|
||||
return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
|
||||
if matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
default:
|
||||
log.Warn("unsupported event %q", triggedEvent)
|
||||
return false
|
||||
return detectNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) bool {
|
||||
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) detectResult {
|
||||
// with no special filter parameters
|
||||
if len(evt.Acts()) == 0 {
|
||||
return true
|
||||
return detectMatched
|
||||
}
|
||||
|
||||
matchTimes := 0
|
||||
@@ -393,14 +426,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
||||
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
case "paths-ignore":
|
||||
if refName.IsTag() {
|
||||
@@ -410,14 +443,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
||||
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
default:
|
||||
log.Warn("push event unsupported condition %q", cond)
|
||||
@@ -427,7 +460,10 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
||||
if hasBranchFilter && hasTagFilter {
|
||||
matchTimes++
|
||||
}
|
||||
return matchTimes == len(evt.Acts())
|
||||
if matchTimes == len(evt.Acts()) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectFilteredOut
|
||||
}
|
||||
|
||||
func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
|
||||
@@ -478,7 +514,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
|
||||
return matchTimes == len(evt.Acts())
|
||||
}
|
||||
|
||||
func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
|
||||
func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) detectResult {
|
||||
acts := evt.Acts()
|
||||
activityTypeMatched := false
|
||||
matchTimes := 0
|
||||
@@ -525,7 +561,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
|
||||
headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
|
||||
if err != nil {
|
||||
log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err)
|
||||
return false
|
||||
return detectNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,33 +593,39 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
|
||||
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
case "paths-ignore":
|
||||
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
default:
|
||||
log.Warn("pull request event unsupported condition %q", cond)
|
||||
}
|
||||
}
|
||||
return activityTypeMatched && matchTimes == len(evt.Acts())
|
||||
if !activityTypeMatched {
|
||||
return detectNotApplicable
|
||||
}
|
||||
if matchTimes != len(evt.Acts()) {
|
||||
return detectFilteredOut
|
||||
}
|
||||
return detectMatched
|
||||
}
|
||||
|
||||
func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {
|
||||
|
||||
@@ -101,49 +101,49 @@ func TestDetectMatched(t *testing.T) {
|
||||
triggedEvent webhook_module.HookEventType
|
||||
payload api.Payloader
|
||||
yamlOn string
|
||||
expected bool
|
||||
expected detectResult
|
||||
}{
|
||||
{
|
||||
desc: "HookEventCreate(create) matches GithubEventCreate(create)",
|
||||
triggedEvent: webhook_module.HookEventCreate,
|
||||
payload: nil,
|
||||
yamlOn: "on: create",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
|
||||
triggedEvent: webhook_module.HookEventIssues,
|
||||
payload: &api.IssuePayload{Action: api.HookIssueOpened},
|
||||
yamlOn: "on: issues",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
|
||||
triggedEvent: webhook_module.HookEventIssues,
|
||||
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
|
||||
yamlOn: "on: issues",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
|
||||
triggedEvent: webhook_module.HookEventPullRequestSync,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
|
||||
yamlOn: "on: pull_request",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
||||
triggedEvent: webhook_module.HookEventPullRequest,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
||||
yamlOn: "on: pull_request",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
||||
triggedEvent: webhook_module.HookEventPullRequest,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
|
||||
yamlOn: "on: pull_request",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches",
|
||||
@@ -155,56 +155,56 @@ func TestDetectMatched(t *testing.T) {
|
||||
},
|
||||
},
|
||||
yamlOn: "on:\n pull_request:\n branches: [main]",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
|
||||
triggedEvent: webhook_module.HookEventPullRequest,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
||||
yamlOn: "on:\n pull_request:\n types: [labeled]",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
|
||||
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
||||
yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)",
|
||||
triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
||||
yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type",
|
||||
triggedEvent: webhook_module.HookEventRelease,
|
||||
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
|
||||
yamlOn: "on:\n release:\n types: [published]",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
||||
triggedEvent: webhook_module.HookEventPackage,
|
||||
payload: &api.PackagePayload{Action: api.HookPackageCreated},
|
||||
yamlOn: "on:\n registry_package:\n types: [updated]",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
|
||||
triggedEvent: webhook_module.HookEventWiki,
|
||||
payload: nil,
|
||||
yamlOn: "on: gollum",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
|
||||
triggedEvent: webhook_module.HookEventSchedule,
|
||||
payload: nil,
|
||||
yamlOn: "on: schedule",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "push to tag matches workflow with paths condition (should skip paths check)",
|
||||
@@ -222,7 +222,19 @@ func TestDetectMatched(t *testing.T) {
|
||||
},
|
||||
commit: nil,
|
||||
yamlOn: "on:\n push:\n paths:\n - src/**",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "push branch filter excludes -> filtered out",
|
||||
triggedEvent: webhook_module.HookEventPush,
|
||||
payload: &api.PushPayload{
|
||||
Ref: "refs/heads/feature/x",
|
||||
Before: "0000000",
|
||||
Commits: []*api.PayloadCommit{{ID: "abc", Added: []string{"a.go"}, Message: "x"}},
|
||||
},
|
||||
commit: nil,
|
||||
yamlOn: "on:\n push:\n branches: [main]",
|
||||
expected: detectFilteredOut,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -231,7 +243,7 @@ func TestDetectMatched(t *testing.T) {
|
||||
evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn))
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, evts, 1)
|
||||
assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
|
||||
assert.Equal(t, tc.expected, detectWorkflowMatch(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,22 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer.
|
||||
const CoAuthoredByTrailer = "Co-authored-by"
|
||||
|
||||
const (
|
||||
commitIdentityRoleAuthor = 1
|
||||
commitIdentityRoleCommitter = 2
|
||||
commitIdentityRoleCoAuthor = 3
|
||||
)
|
||||
|
||||
type CommitIdentity struct {
|
||||
Name string
|
||||
Email string
|
||||
role int
|
||||
}
|
||||
|
||||
// CommitMessageTrailerValues keys are all in lower-case
|
||||
@@ -33,7 +39,9 @@ type CommitMessage struct {
|
||||
|
||||
trailerValues CommitMessageTrailerValues
|
||||
|
||||
allParticipants []*CommitIdentity
|
||||
allParticipants []*CommitIdentity
|
||||
committerCoAuthorIdx int
|
||||
committerCoAuthor *CommitIdentity
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageUTF8() string {
|
||||
@@ -105,29 +113,57 @@ func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
||||
return c.allParticipants
|
||||
}
|
||||
|
||||
exclude := container.Set[string]{}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: c.Author.Name, Email: c.Author.Email})
|
||||
exclude.Add(strings.ToLower(c.Author.Email))
|
||||
|
||||
addParticipant := func(name, email string) {
|
||||
exclude := map[string]int{}
|
||||
addParticipant := func(name, email string, role int) (existingRole int) {
|
||||
if name == "" && email == "" {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
emailLower := strings.ToLower(email)
|
||||
if emailLower != "" && exclude.Contains(emailLower) {
|
||||
return
|
||||
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 {
|
||||
return existingRole
|
||||
}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email})
|
||||
exclude.Add(emailLower)
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role})
|
||||
exclude[emailLower] = role
|
||||
return 0
|
||||
}
|
||||
addParticipant(c.Committer.Name, c.Committer.Email)
|
||||
|
||||
c.committerCoAuthorIdx = -1
|
||||
addParticipant(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
||||
addParticipant(c.Committer.Name, c.Committer.Email, commitIdentityRoleCommitter)
|
||||
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
||||
addr, err := mail.ParseAddress(coAuthorValue)
|
||||
coAuthorName, coAuthorEmail := coAuthorValue, ""
|
||||
if err == nil {
|
||||
addParticipant(addr.Name, addr.Address)
|
||||
} else {
|
||||
addParticipant(coAuthorValue, "")
|
||||
coAuthorName, coAuthorEmail = addr.Name, addr.Address
|
||||
}
|
||||
existingRole := addParticipant(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
|
||||
if existingRole == commitIdentityRoleCommitter && c.committerCoAuthorIdx == -1 {
|
||||
c.committerCoAuthorIdx = len(c.allParticipants)
|
||||
c.committerCoAuthor = &CommitIdentity{coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor}
|
||||
}
|
||||
}
|
||||
return c.allParticipants
|
||||
}
|
||||
|
||||
// CoAuthorIdentities returns co-author identities defined by "Co-authored-by:" in the git message trailer
|
||||
// Only the commit's author is excluded. If committer is declared as co-author, it will be included in the result.
|
||||
// * Author & Co-author: they changed the code (attribution)
|
||||
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
|
||||
// So, a committer can also be a co-author if they changed the code.
|
||||
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
|
||||
all := c.AllParticipantIdentities()
|
||||
if len(all) <= 1 {
|
||||
return nil // no co-author list
|
||||
}
|
||||
if all[1].role != commitIdentityRoleCommitter {
|
||||
return all[1:] // no committer, so all after author are co-authors
|
||||
}
|
||||
if c.committerCoAuthorIdx == -1 {
|
||||
return all[2:] // the committer is not in the co-author list, so just return the co-author list
|
||||
}
|
||||
// the committer is in the co-author list but de-duplicated, so include them as co-author again
|
||||
coAuthors = append(coAuthors, all[2:c.committerCoAuthorIdx]...)
|
||||
coAuthors = append(coAuthors, c.committerCoAuthor)
|
||||
coAuthors = append(coAuthors, all[c.committerCoAuthorIdx:]...)
|
||||
return coAuthors
|
||||
}
|
||||
|
||||
@@ -47,36 +47,83 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitMessageAllParticipantIdentities(t *testing.T) {
|
||||
func TestCommitMessageParticipants(t *testing.T) {
|
||||
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
|
||||
idt := func(n, e string) *CommitIdentity { return &CommitIdentity{Name: n, Email: e} }
|
||||
cases := []struct {
|
||||
commit *Commit
|
||||
participant []*CommitIdentity
|
||||
}{
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com"), idt("c", "c@m.com"), idt("", "x@m.com")},
|
||||
},
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com")},
|
||||
},
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com"), idt("Full Name", "X@M.com")},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
|
||||
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
|
||||
roleAuthor, roleCommitter, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
||||
type testCase struct {
|
||||
name string
|
||||
commit *Commit
|
||||
identities []*CommitIdentity
|
||||
}
|
||||
t.Run("AllParticipants", func(t *testing.T) {
|
||||
cases := []testCase{
|
||||
{
|
||||
"DifferentUsers",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("c", "c@m.com", roleCommitter), idt("", "x@m.com", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"SameUser",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
|
||||
},
|
||||
{
|
||||
"NoCommitter",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "X@M.com", roleCoAuthor)},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.identities, c.commit.AllParticipantIdentities(), "case: %s", c.name)
|
||||
}
|
||||
})
|
||||
t.Run("CoAuthors", func(t *testing.T) {
|
||||
cases := []testCase{
|
||||
{
|
||||
"GenuineCoAuthor",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"CoAuthorIsCommitter",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"CoAuthorIsAuthor",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: a <a@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{},
|
||||
},
|
||||
{
|
||||
"CoAuthorCommitterNameWithIndex", // restore the committer co-author to the co-author list by the index with correct name
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>\nCo-authored-by: c-other <c@m.com>\nCo-authored-by: y <y@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor), idt("c-other", "c@m.com", roleCoAuthor), idt("y", "y@m.com", roleCoAuthor)},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.identities, c.commit.CoAuthorIdentities(), "case: %s", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,19 +21,6 @@ func TestCommitsCount(t *testing.T) {
|
||||
assert.Equal(t, int64(3), commitsCount)
|
||||
}
|
||||
|
||||
func TestCommitsCountWithoutBase(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
|
||||
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
|
||||
CommitsCountOptions{
|
||||
Not: "master",
|
||||
Revision: []string{"branch1"},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), commitsCount)
|
||||
}
|
||||
|
||||
func TestCommitsCountWithSinceUntil(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
revision := []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}
|
||||
@@ -65,6 +52,19 @@ func TestCommitsCountWithSinceUntil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsCountWithoutBase(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
|
||||
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
|
||||
CommitsCountOptions{
|
||||
Not: "master",
|
||||
Revision: []string{"branch1"},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), commitsCount)
|
||||
}
|
||||
|
||||
func TestGetLatestCommitTime(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
const defaultMaxRerunAttempts = 50
|
||||
|
||||
const defaultMaxConcurrentTaskPicks = 16
|
||||
|
||||
// Actions settings
|
||||
var (
|
||||
Actions = struct {
|
||||
@@ -31,13 +33,18 @@ var (
|
||||
WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
|
||||
ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"`
|
||||
MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"`
|
||||
// MaxConcurrentTaskPicks bounds how many runners may run the task-assignment
|
||||
// transaction at once per Gitea instance, to avoid a thundering herd when many
|
||||
// runners poll together. It is a per-process limit, not a cluster-wide one.
|
||||
MaxConcurrentTaskPicks int `ini:"MAX_CONCURRENT_TASK_PICKS"`
|
||||
}{
|
||||
Enabled: true,
|
||||
DefaultActionsURL: defaultActionsURLGitHub,
|
||||
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
|
||||
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
|
||||
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
|
||||
MaxRerunAttempts: defaultMaxRerunAttempts,
|
||||
Enabled: true,
|
||||
DefaultActionsURL: defaultActionsURLGitHub,
|
||||
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
|
||||
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
|
||||
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
|
||||
MaxRerunAttempts: defaultMaxRerunAttempts,
|
||||
MaxConcurrentTaskPicks: defaultMaxConcurrentTaskPicks,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -128,6 +135,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
|
||||
Actions.MaxRerunAttempts = defaultMaxRerunAttempts
|
||||
}
|
||||
|
||||
if Actions.MaxConcurrentTaskPicks <= 0 {
|
||||
Actions.MaxConcurrentTaskPicks = defaultMaxConcurrentTaskPicks
|
||||
}
|
||||
|
||||
if !Actions.LogCompression.IsValid() {
|
||||
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
|
||||
}
|
||||
|
||||
+14
-13
@@ -73,17 +73,18 @@ func TestInitKeys(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keyFiles, len(keyTypes))
|
||||
|
||||
metadata := map[string]os.FileInfo{}
|
||||
// Record file contents so regeneration can be detected
|
||||
content := map[string][]byte{}
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
info, err := os.Stat(privKeyPath)
|
||||
data, err := os.ReadFile(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[privKeyPath] = info
|
||||
content[privKeyPath] = data
|
||||
|
||||
info, err = os.Stat(pubKeyPath)
|
||||
data, err = os.ReadFile(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[pubKeyPath] = info
|
||||
content[pubKeyPath] = data
|
||||
}
|
||||
|
||||
// Test recreation on missing private key and noop for missing pub key
|
||||
@@ -98,26 +99,26 @@ func TestInitKeys(t *testing.T) {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
|
||||
infoPriv, err := os.Stat(privKeyPath)
|
||||
dataPriv, err := os.ReadFile(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
// No modification to RSA key
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
dataPub, err := os.ReadFile(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.Equal(t, metadata[pubKeyPath], infoPub)
|
||||
assert.Equal(t, content[privKeyPath], dataPriv)
|
||||
assert.Equal(t, content[pubKeyPath], dataPub)
|
||||
case "ecdsa":
|
||||
// ECDSA public key should be missing, private unchanged
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.Equal(t, content[privKeyPath], dataPriv)
|
||||
assert.NoFileExists(t, pubKeyPath)
|
||||
case "ed25519":
|
||||
// ed25519 private key was removed, so both keys regenerated
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
dataPub, err := os.ReadFile(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, metadata[privKeyPath], infoPriv)
|
||||
assert.NotEqual(t, metadata[pubKeyPath], infoPub)
|
||||
assert.NotEqual(t, content[privKeyPath], dataPriv)
|
||||
assert.NotEqual(t, content[pubKeyPath], dataPub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-33
@@ -6,6 +6,7 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -47,40 +48,42 @@ type MinioStorage struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
func convertMinioErr(err error) error {
|
||||
func convertMinioErr(err error, optMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
errResp, ok := err.(minio.ErrorResponse)
|
||||
|
||||
wrapErr := func(err error) error {
|
||||
if len(optMsg) == 0 {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s: %w", optMsg[0], err)
|
||||
}
|
||||
|
||||
errResp, ok := errors.AsType[minio.ErrorResponse](err)
|
||||
if !ok {
|
||||
return err
|
||||
return wrapErr(err)
|
||||
}
|
||||
|
||||
// Convert two responses to standard analogues
|
||||
switch errResp.Code {
|
||||
case "NoSuchKey":
|
||||
return os.ErrNotExist
|
||||
return wrapErr(os.ErrNotExist)
|
||||
case "AccessDenied":
|
||||
return os.ErrPermission
|
||||
return wrapErr(os.ErrPermission)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
|
||||
_, err := minioClient.GetBucketVersioning(ctx, bucket)
|
||||
return err
|
||||
return wrapErr(err)
|
||||
}
|
||||
|
||||
// NewMinioStorage returns a minio storage
|
||||
func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
|
||||
config := cfg.MinioConfig
|
||||
log.Info("Creating minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
|
||||
if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
|
||||
return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
|
||||
}
|
||||
|
||||
log.Info("Creating Minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
|
||||
|
||||
var lookup minio.BucketLookupType
|
||||
switch config.BucketLookUpType {
|
||||
case "auto", "":
|
||||
@@ -93,6 +96,13 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
|
||||
return nil, fmt.Errorf("invalid minio bucket lookup type: %s", config.BucketLookUpType)
|
||||
}
|
||||
|
||||
// The request error message is something like:
|
||||
// * "The request signature we calculated does not match the signature you provided. Check your key and signing method."
|
||||
// It doesn't contain useful information to site admin, so here we wrap the error with our error message
|
||||
// to tell the site admin what is the problem.
|
||||
makeErrMsg := func(hint string) string {
|
||||
return fmt.Sprintf("ObjectStorage.%s: endpoint=%s, location=%s, bucket=%s", hint, config.Endpoint, config.Location, config.Bucket)
|
||||
}
|
||||
minioClient, err := minio.New(config.Endpoint, &minio.Options{
|
||||
Creds: buildMinioCredentials(config),
|
||||
Secure: config.UseSSL,
|
||||
@@ -101,37 +111,21 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
|
||||
BucketLookup: lookup,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, convertMinioErr(err)
|
||||
}
|
||||
|
||||
// The GetBucketVersioning is only used for checking whether the Object Storage parameters are generally good. It doesn't need to succeed.
|
||||
// The assumption is that if the API returns the HTTP code 400, then the parameters could be incorrect.
|
||||
// Otherwise even if the request itself fails (403, 404, etc), the code should still continue because the parameters seem "good" enough.
|
||||
// Keep in mind that GetBucketVersioning requires "owner" to really succeed, so it can't be used to check the existence.
|
||||
// Not using "BucketExists (HeadBucket)" because it doesn't include detailed failure reasons.
|
||||
err = getBucketVersioning(ctx, minioClient, config.Bucket)
|
||||
if err != nil {
|
||||
errResp, ok := err.(minio.ErrorResponse)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
if errResp.StatusCode == http.StatusBadRequest {
|
||||
log.Error("S3 storage connection failure at %s:%s with base path %s and region: %s", config.Endpoint, config.Bucket, config.Location, errResp.Message)
|
||||
return nil, err
|
||||
}
|
||||
return nil, convertMinioErr(err, makeErrMsg("NewClient"))
|
||||
}
|
||||
|
||||
// Check to see if we already own this bucket
|
||||
exists, err := minioClient.BucketExists(ctx, config.Bucket)
|
||||
if err != nil {
|
||||
return nil, convertMinioErr(err)
|
||||
return nil, convertMinioErr(err, makeErrMsg("BucketExists"))
|
||||
}
|
||||
|
||||
// If the bucket doesn't exist, try to create one
|
||||
if !exists {
|
||||
if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
|
||||
Region: config.Location,
|
||||
}); err != nil {
|
||||
return nil, convertMinioErr(err)
|
||||
return nil, convertMinioErr(err, makeErrMsg("MakeBucket"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -84,31 +81,18 @@ func TestMinioStoragePath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestS3StorageBadRequest(t *testing.T) {
|
||||
if os.Getenv("CI") == "" {
|
||||
t.Skip("S3Storage not present outside of CI")
|
||||
return
|
||||
}
|
||||
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
|
||||
cfg := &setting.Storage{
|
||||
MinioConfig: setting.MinioStorageConfig{
|
||||
Endpoint: "minio:9000",
|
||||
Endpoint: endpoint,
|
||||
AccessKeyID: "123456",
|
||||
SecretAccessKey: "12345678",
|
||||
SecretAccessKey: "invalid-secret",
|
||||
Bucket: "bucket",
|
||||
Location: "us-east-1",
|
||||
},
|
||||
}
|
||||
message := "ERROR"
|
||||
old := getBucketVersioning
|
||||
defer func() { getBucketVersioning = old }()
|
||||
getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
|
||||
return minio.ErrorResponse{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Code: "FixtureError",
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
_, err := NewStorage(setting.MinioStorageType, cfg)
|
||||
assert.ErrorContains(t, err, message)
|
||||
assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+endpoint)
|
||||
}
|
||||
|
||||
func TestMinioCredentials(t *testing.T) {
|
||||
|
||||
@@ -92,9 +92,12 @@ func TestTemplateEscape(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("Golang URL Escape", func(t *testing.T) {
|
||||
// Golang template considers "href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
|
||||
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: demo cases (html/template/attr.go):
|
||||
// Golang template considers "href", "data-href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
|
||||
actual := execTmpl(`<a href="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a href="?a=%25"></a>`, actual)
|
||||
actual = execTmpl(`<a data-href="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-href="?a=%25"></a>`, actual)
|
||||
actual = execTmpl(`<a data-xxx-url="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-xxx-url="?a=%25"></a>`, actual)
|
||||
})
|
||||
@@ -102,6 +105,10 @@ func TestTemplateEscape(t *testing.T) {
|
||||
// non-URL content isn't auto-escaped
|
||||
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-link="?a=%"></a>`, actual)
|
||||
// the attr names like "data-href" and "data-action" are treated as URL (as the "data-" prefix is stripped)
|
||||
// but "data-xxx-href" and "data-xxx-action" are not, so no escaping.
|
||||
actual = execTmpl(`<a data-xxx-href="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-xxx-href="?a=%"></a>`, actual)
|
||||
})
|
||||
t.Run("QueryBuild", func(t *testing.T) {
|
||||
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)
|
||||
|
||||
@@ -12,6 +12,24 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// maxAcceptLanguageLen bounds the Accept-Language header before it reaches
|
||||
// language.ParseAcceptLanguage. That parser has quadratic-time behavior on long
|
||||
// malformed inputs, and its built-in guard only counts "-" separators while the
|
||||
// scanner treats "_" as an alias for "-", so a "_"-heavy header slips past the
|
||||
// guard and burns CPU. Only the leading (highest-priority) languages are used, so
|
||||
// truncating a longer header is safe.
|
||||
const maxAcceptLanguageLen = 200
|
||||
|
||||
// parseAcceptLanguage parses the Accept-Language header after bounding its length
|
||||
// to avoid a quadratic-time DoS on attacker-controlled input.
|
||||
func parseAcceptLanguage(header string) []language.Tag {
|
||||
if len(header) > maxAcceptLanguageLen {
|
||||
header = header[:maxAcceptLanguageLen]
|
||||
}
|
||||
tags, _, _ := language.ParseAcceptLanguage(header)
|
||||
return tags
|
||||
}
|
||||
|
||||
// Locale handle locale
|
||||
func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
||||
// 1. Check URL arguments.
|
||||
@@ -35,7 +53,7 @@ func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
||||
// 3. Get language information from 'Accept-Language'.
|
||||
// The first element in the list is chosen to be the default language automatically.
|
||||
if len(lang) == 0 {
|
||||
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
|
||||
tags := parseAcceptLanguage(req.Header.Get("Accept-Language"))
|
||||
tag := translation.Match(tags...)
|
||||
lang = tag.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseAcceptLanguage(t *testing.T) {
|
||||
// a normal header is parsed and its leading language preserved
|
||||
tags := parseAcceptLanguage("de-DE,de;q=0.9,en;q=0.8")
|
||||
assert.NotEmpty(t, tags)
|
||||
assert.Equal(t, "de-DE", tags[0].String())
|
||||
|
||||
// an oversized "_"-separated header would drive ParseAcceptLanguage into its
|
||||
// quadratic-time path (the built-in guard only counts "-"); the length bound
|
||||
// keeps the input passed to the parser small so it cannot be used for a DoS.
|
||||
malicious := strings.Repeat("_aaaaaaaaa", 1<<16) // ~640 KiB, zero "-" characters
|
||||
assert.Greater(t, len(malicious), maxAcceptLanguageLen)
|
||||
tags = parseAcceptLanguage(malicious)
|
||||
// no panic / hang, and nothing meaningful is parsed out of the garbage
|
||||
assert.Empty(t, tags)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
@@ -45,14 +46,26 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
|
||||
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
|
||||
}
|
||||
|
||||
cols := []string{"last_online"}
|
||||
runner.LastOnline = timeutil.TimeStampNow()
|
||||
if methodName == "UpdateTask" || methodName == "UpdateLog" {
|
||||
runner.LastActive = timeutil.TimeStampNow()
|
||||
now := time.Now()
|
||||
cols := make([]string, 0, 2)
|
||||
// Debounce last_active too: while a runner streams logs, UpdateLog fires
|
||||
// many times per second and writing on each is a major source of DB load.
|
||||
// Persist only when stale enough to affect the active/idle status.
|
||||
if (methodName == "UpdateTask" || methodName == "UpdateLog") &&
|
||||
actions_model.ShouldPersistLastActive(runner.LastActive, now) {
|
||||
runner.LastActive = timeutil.TimeStamp(now.Unix())
|
||||
cols = append(cols, "last_active")
|
||||
}
|
||||
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
|
||||
log.Error("can't update runner status: %v", err)
|
||||
// Debounce last_online: writing on every poll is a major source of DB load
|
||||
// with many runners. Persist only when stale enough to affect offline status.
|
||||
if actions_model.ShouldPersistLastOnline(runner.LastOnline, now) {
|
||||
runner.LastOnline = timeutil.TimeStamp(now.Unix())
|
||||
cols = append(cols, "last_online")
|
||||
}
|
||||
if len(cols) > 0 {
|
||||
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
|
||||
log.Error("can't update runner status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, runnerCtxKey{}, runner)
|
||||
|
||||
@@ -194,9 +194,15 @@ func (s *Service) FetchTask(
|
||||
// if the task version in request is not equal to the version in db,
|
||||
// it means there may still be some tasks that haven't been assigned.
|
||||
// try to pick a task for the runner that send the request.
|
||||
if t, ok, err := actions_service.PickTask(ctx, freshRunner); err != nil {
|
||||
if t, ok, throttled, err := actions_service.TryPickTask(ctx, freshRunner); err != nil {
|
||||
log.Error("pick task failed: %v", err)
|
||||
return nil, status.Errorf(codes.Internal, "pick task: %v", err)
|
||||
} else if throttled {
|
||||
// Concurrency limit reached: don't advance the runner's tasks version,
|
||||
// so it retries on its next poll instead of sleeping until the next bump.
|
||||
latestVersion = tasksVersion
|
||||
// A steady stream here means MAX_CONCURRENT_TASK_PICKS is too low for the fleet.
|
||||
log.Debug("task pick throttled for runner %q (id %d); it will retry on its next poll", freshRunner.Name, freshRunner.ID)
|
||||
} else if ok {
|
||||
task = t
|
||||
}
|
||||
|
||||
@@ -145,6 +145,13 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
|
||||
|
||||
op := api.OrganizationPermissions{}
|
||||
|
||||
// A public-only token must not disclose membership/permission details of a
|
||||
// non-public org, even for the token owner's own private orgs.
|
||||
if ctx.PublicOnly && !o.Visibility.IsPublic() {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
|
||||
@@ -567,10 +567,20 @@ func GetTeamRepos(ctx *context.APIContext) {
|
||||
|
||||
team := ctx.Org.Team
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
teamRepos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
|
||||
// A public-only token must not expose (or count) private repos, even when the
|
||||
// doer owning the token otherwise has access to them, so filter them out at the
|
||||
// query level to keep the returned page and the total-count header consistent.
|
||||
searchOpts := &repo_model.SearchTeamRepoOptions{
|
||||
ListOptions: listOptions,
|
||||
TeamID: team.ID,
|
||||
})
|
||||
PublicOnly: ctx.PublicOnly,
|
||||
}
|
||||
teamRepos, err := repo_model.GetTeamRepositories(ctx, searchOpts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
count, err := repo_model.CountTeamRepositories(ctx, searchOpts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -584,14 +594,16 @@ func GetTeamRepos(ctx *context.APIContext) {
|
||||
}
|
||||
// A team's repo list is reachable by non-team-members through the team's
|
||||
// visibility tier, so never expose repos (incl. their names) the doer
|
||||
// cannot access.
|
||||
// cannot access. This per-repo visibility trim can't be expressed in the
|
||||
// SQL count above without regressing per-unit public access, so for such
|
||||
// non-members the total-count header may be a small upper bound.
|
||||
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
continue
|
||||
}
|
||||
repos = append(repos, convert.ToRepo(ctx, repo, permission))
|
||||
}
|
||||
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(int64(team.NumRepos))
|
||||
ctx.SetLinkHeader(count, listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, repos)
|
||||
}
|
||||
|
||||
@@ -630,6 +642,12 @@ func GetTeamRepo(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// A public-only token must not confirm the existence of a private repo.
|
||||
if !ctx.TokenCanAccessRepo(repo) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if !organization.HasTeamRepo(ctx, ctx.Org.Team.OrgID, ctx.Org.Team.ID, repo.ID) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
@@ -664,6 +682,22 @@ func getRepositoryByParams(ctx *context.APIContext) *repo_model.Repository {
|
||||
return repo
|
||||
}
|
||||
|
||||
func canChangeTeamRepository(ctx *context.APIContext) bool {
|
||||
if ctx.Org.Organization.RepoAdminChangeTeamAccess {
|
||||
return true
|
||||
}
|
||||
isOwner, err := ctx.Org.Organization.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return false
|
||||
}
|
||||
if !isOwner {
|
||||
ctx.APIError(http.StatusForbidden, "user is nor repo admin nor owner")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AddTeamRepository api for adding a repository to a team
|
||||
func AddTeamRepository(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /teams/{id}/repos/{org}/{repo} organization orgAddTeamRepository
|
||||
@@ -700,6 +734,9 @@ func AddTeamRepository(ctx *context.APIContext) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if !canChangeTeamRepository(ctx) {
|
||||
return
|
||||
}
|
||||
if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -752,6 +789,9 @@ func RemoveTeamRepository(ctx *context.APIContext) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if !canChangeTeamRepository(ctx) {
|
||||
return
|
||||
}
|
||||
if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -889,6 +929,8 @@ func ListTeamActivityFeeds(ctx *context.APIContext) {
|
||||
Date: ctx.FormString("date"),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
// A public-only token must not receive private activity entries.
|
||||
opts.ApplyPublicOnly(ctx.PublicOnly)
|
||||
|
||||
feeds, count, err := feed_service.GetFeeds(ctx, opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -258,8 +258,27 @@ func GetAllCommits(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if commitsCountTotal == 0 {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
// when date filters are active, a zero count may just mean no
|
||||
// commits in the requested range — not that the path is invalid
|
||||
if since == "" && until == "" {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
// verify the path actually exists in the revision history
|
||||
totalWithoutDate, err := gitrepo.CommitsCount(ctx, ctx.Repo.Repository,
|
||||
gitrepo.CommitsCountOptions{
|
||||
Not: not,
|
||||
Revision: []string{sha},
|
||||
RelPath: []string{path},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if totalWithoutDate == 0 {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
commits, _, err = ctx.Repo.GitRepo.CommitsByFileAndRange(
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
api "gitea.dev/modules/structs"
|
||||
@@ -132,11 +133,33 @@ func ListTrackedTimes(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
trackedTimes, err = filterTrackedTimesByAccess(ctx, trackedTimes)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
|
||||
}
|
||||
|
||||
func filterTrackedTimesByAccess(ctx *context.APIContext, trackedTimes issues_model.TrackedTimeList) (issues_model.TrackedTimeList, error) {
|
||||
filtered := make(issues_model.TrackedTimeList, 0, len(trackedTimes))
|
||||
for _, trackedTime := range trackedTimes {
|
||||
if trackedTime.Issue == nil || trackedTime.Issue.Repo == nil {
|
||||
continue
|
||||
}
|
||||
permission, err := access_model.GetIndividualUserRepoPermission(ctx, trackedTime.Issue.Repo, ctx.Doer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
filtered = append(filtered, trackedTime)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// AddTime add time manual to the given issue
|
||||
func AddTime(ctx *context.APIContext) {
|
||||
// swagger:operation Post /repos/{owner}/{repo}/issues/{index}/times issue issueAddTime
|
||||
@@ -542,6 +565,11 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
trackedTimes, err = filterTrackedTimesByAccess(ctx, trackedTimes)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
|
||||
@@ -604,6 +632,11 @@ func ListMyTrackedTimes(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
trackedTimes, err = filterTrackedTimesByAccess(ctx, trackedTimes)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
|
||||
|
||||
@@ -24,6 +24,7 @@ func getStarredRepos(ctx *context.APIContext, user *user_model.User, private boo
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
StarrerID: user.ID,
|
||||
IncludePrivate: private,
|
||||
Actor: user,
|
||||
}
|
||||
opts.ApplyPublicOnly(ctx.PublicOnly)
|
||||
|
||||
@@ -32,13 +33,16 @@ func getStarredRepos(ctx *context.APIContext, user *user_model.User, private boo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repos := make([]*api.Repository, len(starredRepos))
|
||||
for i, starred := range starredRepos {
|
||||
repos := make([]*api.Repository, 0, len(starredRepos))
|
||||
for _, starred := range starredRepos {
|
||||
permission, err := access_model.GetIndividualUserRepoPermission(ctx, starred, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repos[i] = convert.ToRepo(ctx, starred, permission)
|
||||
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
continue
|
||||
}
|
||||
repos = append(repos, convert.ToRepo(ctx, starred, permission))
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private boo
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
WatcherID: user.ID,
|
||||
IncludePrivate: private,
|
||||
Actor: user,
|
||||
}
|
||||
opts.ApplyPublicOnly(ctx.PublicOnly)
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ func MustInitSessioner() func(next http.Handler) http.Handler {
|
||||
Secure: setting.SessionConfig.Secure,
|
||||
SameSite: setting.SessionConfig.SameSite,
|
||||
Domain: setting.SessionConfig.Domain,
|
||||
|
||||
// in the future, if websocket is used, the websocket handler should manage its own session sync (release)
|
||||
IgnoreReleaseForWebSocket: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("common.Sessioner failed: %v", err)
|
||||
|
||||
@@ -160,10 +160,17 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
||||
return false
|
||||
}
|
||||
|
||||
// FIXME: these options are not quite right, for example: changing visibility should do more works than just setting the is_private flag
|
||||
// These options should only be used for "push-to-create"
|
||||
// Only honor these options while the repo is still empty (the push-to-create
|
||||
// case). On a populated repo a bare "git push -o repo.private=..." would
|
||||
// silently flip visibility, bypassing the audit log, webhooks and notifications.
|
||||
if !repo.IsEmpty {
|
||||
return true
|
||||
}
|
||||
|
||||
// The repo is empty and being initialized by this push, so there is no
|
||||
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
|
||||
// yet; setting the flags directly is sufficient in this push-to-create case.
|
||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
||||
// TODO: it needs to do more work
|
||||
repo.IsPrivate = isPrivate.Value()
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
log.Error("failed to update repo is_private: %v", err)
|
||||
|
||||
@@ -31,9 +31,7 @@ import (
|
||||
type preReceiveContext struct {
|
||||
*gitea_context.PrivateContext
|
||||
|
||||
// loadedPusher indicates that where the following information are loaded
|
||||
loadedPusher bool
|
||||
user *user_model.User // it's the org user if a DeployKey is used
|
||||
user *user_model.User // the "pusher", it's the org user if a DeployKey is used
|
||||
userPerm access_model.Permission
|
||||
deployKeyAccessMode perm_model.AccessMode
|
||||
|
||||
@@ -53,10 +51,7 @@ type preReceiveContext struct {
|
||||
|
||||
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||
if ctx.canWriteCodeUnitCached == nil {
|
||||
var canWrite bool
|
||||
if ctx.loadPusherAndPermission() {
|
||||
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
}
|
||||
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
ctx.canWriteCodeUnitCached = &canWrite
|
||||
}
|
||||
return *ctx.canWriteCodeUnitCached
|
||||
@@ -91,9 +86,6 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||
// CanCreatePullRequest returns true if pusher can create pull requests
|
||||
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
|
||||
if !ctx.checkedCanCreatePullRequest {
|
||||
if !ctx.loadPusherAndPermission() {
|
||||
return false
|
||||
}
|
||||
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
|
||||
ctx.checkedCanCreatePullRequest = true
|
||||
}
|
||||
@@ -124,6 +116,10 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
if !ourCtx.loadPusherAndPermission() {
|
||||
return // if error occurs, loadPusherAndPermission had written the error response
|
||||
}
|
||||
|
||||
// Iterate across the provided old commit IDs
|
||||
for i := range opts.OldCommitIDs {
|
||||
oldCommitID := opts.OldCommitIDs[i]
|
||||
@@ -281,18 +277,10 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
|
||||
}
|
||||
} else {
|
||||
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserByID for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
|
||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||
Err: fmt.Sprintf("Unable to GetUserByID for commits from %s to %s: %v", oldCommitID, newCommitID, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
if isForcePush {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, user)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
|
||||
} else {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, user)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,12 +342,6 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
return
|
||||
}
|
||||
|
||||
// although we should have called `loadPusherAndPermission` before, here we call it explicitly again because we need to access ctx.user below
|
||||
if !ctx.loadPusherAndPermission() {
|
||||
// if error occurs, loadPusherAndPermission had written the error response
|
||||
return
|
||||
}
|
||||
|
||||
// Now check if the user is allowed to merge PRs for this repository
|
||||
// Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
|
||||
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
|
||||
@@ -499,10 +481,6 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
|
||||
|
||||
// loadPusherAndPermission returns false if an error occurs, and it writes the error response
|
||||
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
||||
if ctx.loadedPusher {
|
||||
return true
|
||||
}
|
||||
|
||||
if ctx.opts.UserID == user_model.ActionsUserID {
|
||||
taskID := ctx.opts.ActionsTaskID
|
||||
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
|
||||
@@ -555,7 +533,5 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
||||
}
|
||||
ctx.deployKeyAccessMode = deployKey.Mode
|
||||
}
|
||||
|
||||
ctx.loadedPusher = true
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||
ctx := &preReceiveContext{
|
||||
PrivateContext: mockCtx,
|
||||
loadedPusher: true,
|
||||
user: maintainer,
|
||||
userPerm: headPerm,
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
||||
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
@@ -357,7 +357,7 @@ func SignInPost(ctx *context.Context) {
|
||||
// User will need to use WebAuthn, save data
|
||||
updates["totpEnrolled"] = u.ID
|
||||
}
|
||||
if err := updateSession(ctx, nil, updates); err != nil {
|
||||
if err := regenerateSession(ctx, nil, updates); err != nil {
|
||||
ctx.ServerError("UserSignIn: Unable to update session", err)
|
||||
return
|
||||
}
|
||||
@@ -398,7 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, []string{
|
||||
if err := regenerateSession(ctx, []string{
|
||||
// Delete the openid, 2fa and link_account data
|
||||
"openid_verified_uri",
|
||||
"openid_signin_remember",
|
||||
@@ -884,7 +884,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
|
||||
|
||||
log.Trace("User activated: %s", user.Name)
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
"uid": user.ID,
|
||||
"uname": user.Name,
|
||||
}); err != nil {
|
||||
@@ -936,7 +936,7 @@ func ActivateEmail(ctx *context.Context) {
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
}
|
||||
|
||||
func updateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
|
||||
func regenerateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
|
||||
if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil {
|
||||
return fmt.Errorf("regenerate session: %w", err)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,12 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
if err := Oauth2SetLinkAccountData(ctx, *linkAccountData); err != nil {
|
||||
ctx.ServerError("Oauth2SetLinkAccountData", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": remember,
|
||||
|
||||
@@ -285,9 +285,7 @@ func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
|
||||
}
|
||||
|
||||
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
|
||||
return updateSession(ctx, nil, map[string]any{
|
||||
"linkAccountData": linkAccountData,
|
||||
})
|
||||
return ctx.Session.Set("linkAccountData", linkAccountData)
|
||||
}
|
||||
|
||||
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
|
||||
@@ -409,7 +407,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
@@ -434,7 +432,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": false,
|
||||
|
||||
@@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) {
|
||||
if u != nil {
|
||||
nickname = u.LowerName
|
||||
}
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
"openid_verified_uri": id,
|
||||
"openid_determined_email": email,
|
||||
"openid_determined_username": nickname,
|
||||
|
||||
@@ -450,7 +450,7 @@ func ViewProject(ctx *context.Context) {
|
||||
ctx.Data["MilestoneID"] = milestoneID
|
||||
|
||||
// Get assignees.
|
||||
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap)
|
||||
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, project.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("LoadIssuesAssigneesForProject", err)
|
||||
return
|
||||
|
||||
@@ -186,6 +186,22 @@ func ServeAttachment(ctx *context.Context, uuid string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Draft release attachments must not be exposed to anyone without write
|
||||
// access, matching the API-side canAccessReleaseDraft gate. Otherwise the
|
||||
// UUID-based web endpoints would leak draft attachments to any recipient of
|
||||
// the (leaked) download URL.
|
||||
if unitType == unit.TypeReleases && attach.ReleaseID != 0 && !perm.CanWrite(unit.TypeReleases) {
|
||||
rel, err := repo_model.GetReleaseByID(ctx, attach.ReleaseID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetReleaseByID", err)
|
||||
return
|
||||
}
|
||||
if rel.IsDraft {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if requiredScope, ok := attachmentReadScope(unitType); ok {
|
||||
context.CheckTokenScopes(ctx, repo, requiredScope)
|
||||
if ctx.Written() {
|
||||
|
||||
@@ -398,7 +398,7 @@ func Diff(ctx *context.Context) {
|
||||
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
||||
ctx.Data["Verification"] = verification
|
||||
ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit)
|
||||
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.AllParticipantIdentities(), nil).Participants[1:]
|
||||
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.CoAuthorIdentities(), nil).Participants
|
||||
ctx.Data["Parents"] = parents
|
||||
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
|
||||
|
||||
|
||||
@@ -130,6 +130,25 @@ func RemoveDependency(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Existing cross-repo dependencies must remain removable even when
|
||||
// AllowCrossRepositoryDependencies is disabled, so only enforce that the
|
||||
// doer can read the dependency's repository.
|
||||
if issue.RepoID != dep.RepoID {
|
||||
if err := dep.LoadRepo(ctx); err != nil {
|
||||
ctx.ServerError("loadRepo", err)
|
||||
return
|
||||
}
|
||||
depRepoPerm, err := access_model.GetDoerRepoPermission(ctx, dep.Repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDoerRepoPermission", err)
|
||||
return
|
||||
}
|
||||
if !depRepoPerm.CanReadIssuesOrPulls(dep.IsPull) {
|
||||
ctx.Redirect(issue.Link())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = issues_model.RemoveIssueDependency(ctx, ctx.Doer, issue, dep, depType); err != nil {
|
||||
if issues_model.IsErrDependencyNotExists(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist"))
|
||||
|
||||
@@ -14,17 +14,6 @@ import (
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
type userSearchInfo struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"username"`
|
||||
AvatarLink string `json:"avatar_link"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
type userSearchResponse struct {
|
||||
Results []*userSearchInfo `json:"results"`
|
||||
}
|
||||
|
||||
func IssuePullPosters(ctx *context.Context) {
|
||||
isPullList := ctx.PathParam("type") == "pulls"
|
||||
issuePosters(ctx, isPullList)
|
||||
@@ -46,14 +35,5 @@ func issuePosters(ctx *context.Context, isPullList bool) {
|
||||
posters = append(posters, ctx.Doer)
|
||||
}
|
||||
}
|
||||
|
||||
posters = shared_user.MakeSelfOnTop(ctx.Doer, posters)
|
||||
|
||||
resp := &userSearchResponse{}
|
||||
resp.Results = make([]*userSearchInfo, len(posters))
|
||||
for i, user := range posters {
|
||||
resp.Results[i] = &userSearchInfo{UserID: user.ID, UserName: user.Name, AvatarLink: user.AvatarLink(ctx)}
|
||||
resp.Results[i].FullName = user.FullName
|
||||
}
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
ctx.JSON(http.StatusOK, shared_user.ToSearchUserResponse(ctx, ctx.Doer, posters))
|
||||
}
|
||||
|
||||
+24
-55
@@ -27,7 +27,6 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/web/feed"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/context/upload"
|
||||
"gitea.dev/services/forms"
|
||||
@@ -338,7 +337,6 @@ func LatestRelease(ctx *context.Context) {
|
||||
|
||||
func newReleaseCommon(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
|
||||
ctx.Data["PageIsReleaseList"] = true
|
||||
|
||||
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
@@ -346,17 +344,8 @@ func newReleaseCommon(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
ctx.Data["Tags"] = tags
|
||||
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepoAssignees", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
||||
|
||||
upload.AddUploadContext(ctx, "release")
|
||||
|
||||
PrepareBranchList(ctx) // for New Release page
|
||||
}
|
||||
|
||||
@@ -425,8 +414,8 @@ func GenerateReleaseNotes(ctx *context.Context) {
|
||||
|
||||
// NewReleasePost response for creating a release
|
||||
func NewReleasePost(ctx *context.Context) {
|
||||
newReleaseCommon(ctx)
|
||||
if ctx.Written() {
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -450,35 +439,28 @@ func NewReleasePost(ctx *context.Context) {
|
||||
// Or another choice is "always show the tag-only button" if error occurs.
|
||||
ctx.Data["ShowCreateTagOnlyButton"] = form.TagOnly || rel == nil
|
||||
|
||||
// do some form checks
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplReleaseNew)
|
||||
return
|
||||
}
|
||||
|
||||
form.Target = util.IfZero(form.Target, ctx.Repo.Repository.DefaultBranch)
|
||||
if exist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, form.Target); !exist {
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form)
|
||||
ctx.JSONError(ctx.Tr("form.target_branch_not_exist"))
|
||||
return
|
||||
}
|
||||
|
||||
if !form.TagOnly && form.Title == "" {
|
||||
// if not "tag only", then the title of the release cannot be empty
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.title_empty"), tplReleaseNew, &form)
|
||||
ctx.JSONError(ctx.Tr("repo.release.title_empty"))
|
||||
return
|
||||
}
|
||||
|
||||
handleTagReleaseError := func(err error) {
|
||||
ctx.Data["Err_TagName"] = true
|
||||
switch {
|
||||
case release_service.IsErrTagAlreadyExists(err):
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("repo.branch.tag_collision", form.TagName), tplReleaseNew, &form)
|
||||
ctx.JSONError(ctx.Tr("repo.branch.tag_collision", form.TagName))
|
||||
case repo_model.IsErrReleaseAlreadyExist(err):
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
|
||||
ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
|
||||
case release_service.IsErrInvalidTagName(err):
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form)
|
||||
ctx.JSONError(ctx.Tr("repo.release.tag_name_invalid"))
|
||||
case release_service.IsErrProtectedTagName(err):
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form)
|
||||
ctx.JSONError(ctx.Tr("repo.release.tag_name_protected"))
|
||||
default:
|
||||
ctx.ServerError("handleTagReleaseError", err)
|
||||
}
|
||||
@@ -497,7 +479,7 @@ func NewReleasePost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.TagName))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.TagName))
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.TagName))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -522,7 +504,7 @@ func NewReleasePost(ctx *context.Context) {
|
||||
handleTagReleaseError(err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -530,8 +512,7 @@ func NewReleasePost(ctx *context.Context) {
|
||||
// old logic: if the release is not a tag (it is a real release), do not update it on the "new release" page
|
||||
// add new logic: if tag-only, do not convert the tag to a release
|
||||
if form.TagOnly || !rel.IsTag {
|
||||
ctx.Data["Err_TagName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
|
||||
ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -547,7 +528,7 @@ func NewReleasePost(ctx *context.Context) {
|
||||
handleTagReleaseError(err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
|
||||
}
|
||||
|
||||
// EditRelease render release edit page
|
||||
@@ -589,55 +570,39 @@ func EditRelease(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
ctx.Data["attachments"] = rel.Attachments
|
||||
|
||||
// Get assignees.
|
||||
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, rel.Repo)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepoAssignees", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
||||
|
||||
ctx.HTML(http.StatusOK, tplReleaseNew)
|
||||
}
|
||||
|
||||
// EditReleasePost response for edit release
|
||||
func EditReleasePost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.EditReleaseForm)
|
||||
|
||||
newReleaseCommon(ctx)
|
||||
if ctx.Written() {
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
|
||||
ctx.Data["PageIsEditRelease"] = true
|
||||
form := web.GetForm(ctx).(*forms.EditReleaseForm)
|
||||
|
||||
tagName := ctx.PathParam("*")
|
||||
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrReleaseNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
ctx.JSONErrorNotFound(err.Error())
|
||||
} else {
|
||||
ctx.ServerError("GetRelease", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if rel.IsTag {
|
||||
ctx.NotFound(err) // for a pure tag release, don't allow to edit it as a release
|
||||
ctx.JSONErrorNotFound() // for a pure tag release, don't allow to edit it as a release
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["tag_name"] = rel.TagName
|
||||
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
|
||||
ctx.Data["title"] = rel.Title
|
||||
ctx.Data["content"] = rel.Note
|
||||
ctx.Data["prerelease"] = rel.IsPrerelease
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplReleaseNew)
|
||||
return
|
||||
}
|
||||
|
||||
const delPrefix = "attachment-del-"
|
||||
const editPrefix = "attachment-edit-"
|
||||
var addAttachmentUUIDs, delAttachmentUUIDs []string
|
||||
@@ -659,10 +624,14 @@ func EditReleasePost(ctx *context.Context) {
|
||||
rel.IsPrerelease = form.Prerelease
|
||||
if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo,
|
||||
rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil {
|
||||
ctx.ServerError("UpdateRelease", err)
|
||||
if upload.IsErrFileTypeForbidden(err) {
|
||||
ctx.JSONError(err.Error())
|
||||
} else {
|
||||
ctx.ServerError("UpdateRelease", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
|
||||
}
|
||||
|
||||
// DeleteRelease deletes a release
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/contexttest"
|
||||
@@ -39,15 +41,15 @@ func TestNewReleasePost(t *testing.T) {
|
||||
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"])
|
||||
})
|
||||
|
||||
post := func(t *testing.T, form forms.NewReleaseForm) *context.Context {
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new")
|
||||
post := func(t *testing.T, form forms.NewReleaseForm) (*context.Context, *httptest.ResponseRecorder) {
|
||||
ctx, resp := contexttest.MockContext(t, "user2/repo1/releases/new")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
web.SetForm(ctx, &form)
|
||||
NewReleasePost(ctx)
|
||||
return ctx
|
||||
return ctx, resp
|
||||
}
|
||||
|
||||
loadRelease := func(t *testing.T, tagName string) *repo_model.Release {
|
||||
@@ -70,7 +72,7 @@ func TestNewReleasePost(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ReleaseExistsDoUpdate(non-tag)", func(t *testing.T) {
|
||||
ctx := post(t, forms.NewReleaseForm{
|
||||
_, resp := post(t, forms.NewReleaseForm{
|
||||
TagName: "v1.1",
|
||||
Target: "master",
|
||||
Title: "updated-title",
|
||||
@@ -80,11 +82,11 @@ func TestNewReleasePost(t *testing.T) {
|
||||
require.NotNil(t, rel)
|
||||
assert.False(t, rel.IsTag)
|
||||
assert.Equal(t, "testing-release", rel.Title)
|
||||
assert.NotEmpty(t, ctx.Flash.ErrorMsg)
|
||||
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("ReleaseExistsDoUpdate(tag-only)", func(t *testing.T) {
|
||||
ctx := post(t, forms.NewReleaseForm{
|
||||
ctx, resp := post(t, forms.NewReleaseForm{
|
||||
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
|
||||
Target: "master",
|
||||
Title: "updated-title",
|
||||
@@ -95,12 +97,12 @@ func TestNewReleasePost(t *testing.T) {
|
||||
require.NotNil(t, rel)
|
||||
assert.True(t, rel.IsTag) // the record should not be updated because the request is "tag-only". TODO: need to improve the logic?
|
||||
assert.Equal(t, "delete-tag", rel.Title)
|
||||
assert.NotEmpty(t, ctx.Flash.ErrorMsg)
|
||||
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
|
||||
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"]) // still show the "tag-only" button
|
||||
})
|
||||
|
||||
t.Run("ReleaseExistsDoUpdate(tag-release)", func(t *testing.T) {
|
||||
ctx := post(t, forms.NewReleaseForm{
|
||||
ctx, _ := post(t, forms.NewReleaseForm{
|
||||
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
|
||||
Target: "master",
|
||||
Title: "updated-title",
|
||||
@@ -114,7 +116,7 @@ func TestNewReleasePost(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("TagOnly", func(t *testing.T) {
|
||||
ctx := post(t, forms.NewReleaseForm{
|
||||
ctx, _ := post(t, forms.NewReleaseForm{
|
||||
TagName: "new-tag-only",
|
||||
Target: "master",
|
||||
Title: "title",
|
||||
@@ -128,7 +130,7 @@ func TestNewReleasePost(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("TagOnlyConflict", func(t *testing.T) {
|
||||
ctx := post(t, forms.NewReleaseForm{
|
||||
_, resp := post(t, forms.NewReleaseForm{
|
||||
TagName: "v1.1",
|
||||
Target: "master",
|
||||
Title: "title",
|
||||
@@ -138,7 +140,7 @@ func TestNewReleasePost(t *testing.T) {
|
||||
rel := loadRelease(t, "v1.1")
|
||||
require.NotNil(t, rel)
|
||||
assert.False(t, rel.IsTag)
|
||||
assert.NotEmpty(t, ctx.Flash.ErrorMsg)
|
||||
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,22 +11,44 @@ import (
|
||||
"gitea.dev/models/user"
|
||||
)
|
||||
|
||||
type SearchUserInfo struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"username"`
|
||||
AvatarLink string `json:"avatar_link"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
type SearchUserResponse struct {
|
||||
Results []*SearchUserInfo `json:"results"`
|
||||
}
|
||||
|
||||
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
|
||||
if doer != nil {
|
||||
idx := slices.IndexFunc(users, func(u *user.User) bool {
|
||||
return u.ID == doer.ID
|
||||
})
|
||||
if idx > 0 {
|
||||
newUsers := make([]*user.User, len(users))
|
||||
newUsers[0] = users[idx]
|
||||
copy(newUsers[1:], users[:idx])
|
||||
copy(newUsers[idx+1:], users[idx+1:])
|
||||
return newUsers
|
||||
}
|
||||
if doer == nil {
|
||||
return users
|
||||
}
|
||||
idx := slices.IndexFunc(users, func(u *user.User) bool {
|
||||
return u.ID == doer.ID
|
||||
})
|
||||
if idx > 0 {
|
||||
newUsers := make([]*user.User, len(users))
|
||||
newUsers[0] = users[idx]
|
||||
copy(newUsers[1:], users[:idx])
|
||||
copy(newUsers[idx+1:], users[idx+1:])
|
||||
return newUsers
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func ToSearchUserResponse(ctx context.Context, self *user.User, users []*user.User) (ret *SearchUserResponse) {
|
||||
infos := MakeSelfOnTop(self, users)
|
||||
resp := &SearchUserResponse{Results: make([]*SearchUserInfo, len(infos))}
|
||||
for i, u := range infos {
|
||||
resp.Results[i] = &SearchUserInfo{UserID: u.ID, UserName: u.Name, AvatarLink: u.AvatarLink(ctx)}
|
||||
resp.Results[i].FullName = u.FullName
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// GetFilterUserIDByName tries to get the user ID from the given username.
|
||||
// Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list.
|
||||
// So it's better to make it work like GitHub: users could input username directly.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
stdCtx "context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -12,8 +13,10 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
@@ -97,6 +100,12 @@ func prepareUserNotificationsData(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
failCount += len(failures)
|
||||
notifications, failures, err = filterNotificationsByRepoAccess(ctx, ctx.Doer, notifications)
|
||||
if err != nil {
|
||||
ctx.ServerError("filterNotificationsByRepoAccess", err)
|
||||
return
|
||||
}
|
||||
failCount += len(failures)
|
||||
|
||||
failures, err = notifications.LoadIssues(ctx)
|
||||
if err != nil {
|
||||
@@ -135,6 +144,23 @@ func prepareUserNotificationsData(ctx *context.Context) {
|
||||
ctx.Data["Page"] = pager
|
||||
}
|
||||
|
||||
func filterNotificationsByRepoAccess(ctx stdCtx.Context, doer *user_model.User, notifications activities_model.NotificationList) (activities_model.NotificationList, []int, error) {
|
||||
failures := make([]int, 0)
|
||||
for i, notification := range notifications {
|
||||
if notification.Repository == nil {
|
||||
continue
|
||||
}
|
||||
perm, err := access_model.GetIndividualUserRepoPermission(ctx, notification.Repository, doer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !perm.HasAnyUnitAccessOrPublicAccess() {
|
||||
failures = append(failures, i)
|
||||
}
|
||||
}
|
||||
return notifications.Without(failures), failures, nil
|
||||
}
|
||||
|
||||
// NotificationStatusPost is a route for changing the status of a notification
|
||||
func NotificationStatusPost(ctx *context.Context) {
|
||||
notificationID := ctx.FormInt64("notification_id")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFilterNotificationsByRepoAccess(t *testing.T) {
|
||||
require.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
|
||||
inaccessibleRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
require.True(t, inaccessibleRepo.IsPrivate)
|
||||
accessibleRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
notifications := activities_model.NotificationList{
|
||||
{ID: 1, Repository: inaccessibleRepo},
|
||||
{ID: 2, Repository: accessibleRepo},
|
||||
}
|
||||
|
||||
filtered, failures, err := filterNotificationsByRepoAccess(t.Context(), doer, notifications)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []int{0}, failures)
|
||||
require.Len(t, filtered, 1)
|
||||
assert.EqualValues(t, 2, filtered[0].ID)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
@@ -14,10 +15,14 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/log"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
commitstatus_service "gitea.dev/services/repository/commitstatus"
|
||||
)
|
||||
|
||||
@@ -147,21 +152,113 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
|
||||
// scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks.
|
||||
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
|
||||
}
|
||||
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
||||
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job))
|
||||
}
|
||||
|
||||
// getAllRequiredStatusContextGlobs returns the compiled globs of every status-check context required in the repo:
|
||||
// its protected branch rules' contexts and its required scoped workflows' patterns (see EffectiveRequiredContexts).
|
||||
func getAllRequiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository) ([]glob.Glob, error) {
|
||||
rules, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindRepoProtectedBranchRules: %w", err)
|
||||
}
|
||||
required, err := pull_service.EffectiveRequiredContexts(ctx, repo, rules...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EffectiveRequiredContexts: %w", err)
|
||||
}
|
||||
globs := make([]glob.Glob, 0, len(required))
|
||||
for _, c := range required {
|
||||
if gp, err := glob.Compile(c); err != nil {
|
||||
log.Error("glob.Compile %q: %v", c, err)
|
||||
} else {
|
||||
globs = append(globs, gp)
|
||||
}
|
||||
}
|
||||
return globs, nil
|
||||
}
|
||||
|
||||
// CreateSkippedCommitStatusForFilteredWorkflow posts a skipped commit status for each job of a
|
||||
// workflow that matched the triggering event but was excluded by a branch/paths filter.
|
||||
// This lets a required status check tied to that context be satisfied without the workflow running.
|
||||
// Only contexts matching requiredGlobs are posted; a non-required context gets no skipped status.
|
||||
// No ActionRun is created, so the status has no target URL (there is no run/job to link to).
|
||||
// A non-empty scopedPrefix prefixes each context with its source repo, matching scoped runs.
|
||||
//
|
||||
// TODO: requiredGlobs over-approximates by including every protected branch rule's required contexts.
|
||||
// If possible, the set should be narrowed to the required contexts of a specific branch protection rule (and the contexts from required scoped workflows).
|
||||
// Currently, a `push` event must keep the repo-wide union since its future pull request base branch is unknown.
|
||||
func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *repo_model.Repository, event webhook_module.HookEventType, triggerEvent, workflowID string, content []byte, payload api.Payloader, scopedPrefix string, requiredGlobs []glob.Glob) error {
|
||||
if len(requiredGlobs) == 0 {
|
||||
return nil // nothing is required, so no skipped status is needed
|
||||
}
|
||||
|
||||
// Derive the status event name and target commit from the payload.
|
||||
// TODO: this mirrors getCommitStatusEventNameAndCommitID, which derives the same from a persisted run. Should merge the logic if possible.
|
||||
var statusEvent, commitID string
|
||||
switch event {
|
||||
case webhook_module.HookEventPush:
|
||||
if p, ok := payload.(*api.PushPayload); ok && p.HeadCommit != nil {
|
||||
statusEvent, commitID = "push", p.HeadCommit.ID
|
||||
}
|
||||
case webhook_module.HookEventPullRequest,
|
||||
webhook_module.HookEventPullRequestSync,
|
||||
webhook_module.HookEventPullRequestAssign,
|
||||
webhook_module.HookEventPullRequestLabel,
|
||||
webhook_module.HookEventPullRequestReviewRequest,
|
||||
webhook_module.HookEventPullRequestMilestone:
|
||||
if p, ok := payload.(*api.PullRequestPayload); ok && p.PullRequest != nil && p.PullRequest.Head != nil {
|
||||
statusEvent, commitID = "pull_request", p.PullRequest.Head.Sha
|
||||
if triggerEvent == actions_module.GithubEventPullRequestTarget {
|
||||
statusEvent = "pull_request_target"
|
||||
}
|
||||
}
|
||||
}
|
||||
if statusEvent == "" || commitID == "" {
|
||||
return nil // unsupported event or missing commit id, nothing to post
|
||||
}
|
||||
|
||||
workflows, err := jobparser.Parse(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jobparser.Parse: %w", err)
|
||||
}
|
||||
|
||||
displayName := actions_module.WorkflowDisplayName(workflowID, content)
|
||||
for _, sw := range workflows {
|
||||
_, job := sw.Job()
|
||||
if job == nil {
|
||||
continue
|
||||
}
|
||||
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
|
||||
ctxName := actions_module.WorkflowStatusContextName(displayName, jobName, statusEvent)
|
||||
if scopedPrefix != "" {
|
||||
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, jobName, statusEvent)
|
||||
}
|
||||
// Only a required context needs the skipped status.
|
||||
if !slices.ContainsFunc(requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) }) {
|
||||
continue
|
||||
}
|
||||
// "Skipped" mirrors toCommitStatusDescription for StatusSkipped.
|
||||
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createWorkflowCommitStatus posts the commit status for one workflow-job context.
|
||||
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string) error {
|
||||
// Mix the workflow file path into the hash so two workflow files that
|
||||
// share the same `name:` and job name produce distinct commit statuses
|
||||
// even though they render identically — matching GitHub's behavior
|
||||
// (issue #35699).
|
||||
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + run.WorkflowID)
|
||||
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
|
||||
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
|
||||
// the legacy hash is still the "latest" for this SHA, reuse that hash so
|
||||
// the new row supersedes it; otherwise the old pending status would stay
|
||||
// stuck forever (it lives in its own dedupe group). Only relevant for
|
||||
// in-flight workflows at upgrade time.
|
||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
||||
state := toCommitStatus(job.Status)
|
||||
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
||||
description := toCommitStatusDescription(job)
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
|
||||
if err != nil {
|
||||
|
||||
@@ -183,8 +183,9 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
}
|
||||
|
||||
var detectedWorkflows []*actions_module.DetectedWorkflow
|
||||
var filteredWorkflows []*actions_module.DetectedWorkflow
|
||||
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
||||
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
|
||||
workflows, schedules, filtered, err := actions_module.DetectWorkflows(gitRepo, commit,
|
||||
input.Event,
|
||||
input.Payload,
|
||||
shouldDetectSchedules,
|
||||
@@ -212,6 +213,17 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, wf := range filtered {
|
||||
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
|
||||
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
|
||||
continue
|
||||
}
|
||||
|
||||
if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
|
||||
filteredWorkflows = append(filteredWorkflows, wf)
|
||||
}
|
||||
}
|
||||
|
||||
if input.PullRequest != nil {
|
||||
// detect pull_request_target workflows
|
||||
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
|
||||
@@ -219,7 +231,7 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitRepo.GetCommit: %w", err)
|
||||
}
|
||||
baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
|
||||
baseWorkflows, _, baseFiltered, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DetectWorkflows: %w", err)
|
||||
}
|
||||
@@ -227,11 +239,24 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RelativePath(), baseCommit.ID)
|
||||
} else {
|
||||
for _, wf := range baseWorkflows {
|
||||
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
|
||||
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
|
||||
continue
|
||||
}
|
||||
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
|
||||
detectedWorkflows = append(detectedWorkflows, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, wf := range baseFiltered {
|
||||
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
|
||||
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
|
||||
continue
|
||||
}
|
||||
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
|
||||
filteredWorkflows = append(filteredWorkflows, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shouldDetectSchedules {
|
||||
@@ -244,6 +269,8 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
return err
|
||||
}
|
||||
|
||||
handleFilteredWorkflows(ctx, input, filteredWorkflows)
|
||||
|
||||
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
|
||||
}
|
||||
|
||||
@@ -369,6 +396,28 @@ func buildApproveAndInsertRun(
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFilteredWorkflows posts a skipped commit status for each filtered-out workflow whose context is a required status check;
|
||||
// a non-required one posts nothing, so it cannot leak into a pull request.
|
||||
func handleFilteredWorkflows(ctx context.Context, input *notifyInput, filteredWorkflows []*actions_module.DetectedWorkflow) {
|
||||
if len(filteredWorkflows) == 0 {
|
||||
return
|
||||
}
|
||||
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
|
||||
if err != nil {
|
||||
log.Error("repo %s: required status contexts: %v", input.Repo.RelativePath(), err)
|
||||
return
|
||||
}
|
||||
if len(requiredGlobs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, dwf := range filteredWorkflows {
|
||||
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, "", requiredGlobs); err != nil {
|
||||
log.Error("repo %s: skipped commit status for workflow %s: %v", input.Repo.RelativePath(), dwf.EntryName, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
|
||||
return newNotifyInput(issue.Repo, issue.Poster, event)
|
||||
}
|
||||
@@ -616,6 +665,12 @@ func detectAndHandleScopedWorkflows(
|
||||
isForkPullRequest := isForkPullRequestInput(input)
|
||||
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
||||
|
||||
// A filtered-out scoped workflow only posts a skipped status when its context is a required check.
|
||||
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
|
||||
if err != nil {
|
||||
log.Error("scoped workflows: required status contexts for %s: %v", input.Repo.RelativePath(), err)
|
||||
}
|
||||
|
||||
// The same source repo may be registered at both the owner and instance level; dedup
|
||||
// the IDs and batch-load them in one query instead of one round-trip per source.
|
||||
seen := make(container.Set[int64], len(sources))
|
||||
@@ -640,7 +695,7 @@ func detectAndHandleScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
|
||||
sourceCommitSHA, detected, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||
if err != nil {
|
||||
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
|
||||
continue
|
||||
@@ -658,23 +713,40 @@ func detectAndHandleScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// A filtered-out scoped workflow posts a skipped commit status for its required-check contexts.
|
||||
if len(filtered) > 0 && len(requiredGlobs) > 0 {
|
||||
scopedPrefix := actions_model.ScopedStatusContextPrefix(ctx, sourceRepo.ID)
|
||||
for _, dwf := range filtered {
|
||||
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
|
||||
continue
|
||||
}
|
||||
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, scopedPrefix, requiredGlobs); err != nil {
|
||||
log.Error("scoped workflows: skipped commit status for source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch
|
||||
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch.
|
||||
// detected are the workflows to run; filtered matched the event but were excluded by a branch/paths
|
||||
// filter, and later post a skipped commit status only for a required-check context.
|
||||
func detectScopedWorkflowsForSource(
|
||||
ctx context.Context,
|
||||
input *notifyInput,
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
sourceRepo *repo_model.Repository,
|
||||
) (sourceCommitSHA string, detected []*actions_module.DetectedWorkflow, err error) {
|
||||
) (sourceCommitSHA string, detected, filtered []*actions_module.DetectedWorkflow, err error) {
|
||||
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
|
||||
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, nil, err
|
||||
}
|
||||
return sourceCommitSHA, actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload), nil
|
||||
detected, filtered = actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload)
|
||||
return sourceCommitSHA, detected, filtered, nil
|
||||
}
|
||||
|
||||
@@ -7,16 +7,47 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
secret_model "gitea.dev/models/secret"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
var (
|
||||
taskPickSem chan struct{}
|
||||
taskPickSemOnce sync.Once
|
||||
)
|
||||
|
||||
func taskPickLimiter() chan struct{} {
|
||||
taskPickSemOnce.Do(func() {
|
||||
taskPickSem = make(chan struct{}, setting.Actions.MaxConcurrentTaskPicks)
|
||||
})
|
||||
return taskPickSem
|
||||
}
|
||||
|
||||
// TryPickTask attempts to assign a task to the runner, bounding the number of
|
||||
// concurrent assignment transactions to avoid a thundering herd when many
|
||||
// runners poll at once. When the concurrency limit is reached it returns
|
||||
// throttled=true without touching the DB, so the caller can let the runner
|
||||
// retry on its next poll instead of advancing its tasks version.
|
||||
func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task *runnerv1.Task, ok, throttled bool, err error) {
|
||||
sem := taskPickLimiter()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
default:
|
||||
return nil, false, true, nil
|
||||
}
|
||||
task, ok, err = PickTask(ctx, runner)
|
||||
return task, ok, false, err
|
||||
}
|
||||
|
||||
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
|
||||
var (
|
||||
task *runnerv1.Task
|
||||
@@ -76,6 +107,15 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
|
||||
NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID)
|
||||
}
|
||||
|
||||
// The job is claimed and its payload assembled, but if the request context was cancelled meanwhile, response can no longer reach the runner.
|
||||
// Release the claim so another runner can pick the job up.
|
||||
if err := ctx.Err(); err != nil {
|
||||
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
|
||||
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return task, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTryPickTaskThrottled(t *testing.T) {
|
||||
sem := taskPickLimiter()
|
||||
|
||||
// Saturate every assignment slot so the next attempt must be throttled.
|
||||
for range cap(sem) {
|
||||
sem <- struct{}{}
|
||||
}
|
||||
defer func() {
|
||||
for range cap(sem) {
|
||||
<-sem
|
||||
}
|
||||
}()
|
||||
|
||||
// No DB access happens on the throttled path, so this is safe without fixtures.
|
||||
task, ok, throttled, err := TryPickTask(t.Context(), &actions_model.ActionRunner{})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, task)
|
||||
assert.False(t, ok)
|
||||
assert.True(t, throttled)
|
||||
}
|
||||
+20
-24
@@ -254,6 +254,17 @@ func BatchHandler(ctx *context.Context) {
|
||||
|
||||
var responseObject *lfs_module.ObjectResponse
|
||||
if isUpload {
|
||||
if exists && meta == nil {
|
||||
// The object exists in the content store but is not linked to this
|
||||
// repo. Do not auto-link it based on cross-repo access: the token
|
||||
// only authorizes this repo, and for deploy keys ctx.Doer is the
|
||||
// repo owner, so trusting it here would let a single-repo key pull
|
||||
// objects from any repo the owner can see. Require proof of
|
||||
// possession by making the client upload the (hash-verified) bytes,
|
||||
// and treat it as a new upload for size-limit enforcement below.
|
||||
exists = false
|
||||
}
|
||||
|
||||
var err *lfs_module.ObjectError
|
||||
if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize {
|
||||
err = &lfs_module.ObjectError{
|
||||
@@ -262,25 +273,6 @@ func BatchHandler(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if exists && meta == nil {
|
||||
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
|
||||
writeStatus(ctx, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if accessible {
|
||||
_, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)
|
||||
if err != nil {
|
||||
log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err)
|
||||
writeStatus(ctx, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
exists = false
|
||||
}
|
||||
}
|
||||
|
||||
responseObject = buildObjectResponse(rc, p, false, !exists, err)
|
||||
} else {
|
||||
var err *lfs_module.ObjectError
|
||||
@@ -337,13 +329,17 @@ func UploadHandler(ctx *context.Context) {
|
||||
|
||||
uploadOrVerify := func() error {
|
||||
if exists {
|
||||
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
|
||||
// The bytes already exist in the content store. Only skip proof of
|
||||
// possession when the object is already linked to *this* repo; never
|
||||
// trust cross-repo access (ctx.Doer is the repo owner for deploy keys),
|
||||
// which would let a caller link an object it cannot produce.
|
||||
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid)
|
||||
if err != nil && err != git_model.ErrLFSObjectNotExist {
|
||||
log.Error("Unable to get LFS MetaObject [%s]. Error: %v", p.Oid, err)
|
||||
return err
|
||||
}
|
||||
if !accessible {
|
||||
// The file exists but the user has no access to it.
|
||||
if meta == nil {
|
||||
// The file exists but is not linked to this repo.
|
||||
// The upload gets verified by hashing and size comparison to prove access to it.
|
||||
hash := sha256.New()
|
||||
written, err := io.Copy(hash, ctx.Req.Body)
|
||||
|
||||
@@ -22,6 +22,9 @@ func WarnAndNotice(fmtStr string, args ...any) {
|
||||
}
|
||||
|
||||
func hasBaseURL(toCheck, baseURL string) bool {
|
||||
if baseURL == "" {
|
||||
return false
|
||||
}
|
||||
if len(baseURL) > 0 && baseURL[len(baseURL)-1] != '/' {
|
||||
baseURL += "/"
|
||||
}
|
||||
|
||||
@@ -87,15 +87,14 @@ func IsMigrateURLAllowed(remoteURL string, doer *user_model.User) error {
|
||||
}
|
||||
|
||||
func checkByAllowBlockList(hostName string, addrList []net.IP) error {
|
||||
var ipAllowed bool
|
||||
ipAllowed := len(addrList) > 0
|
||||
var ipBlocked bool
|
||||
for _, addr := range addrList {
|
||||
ipAllowed = ipAllowed || allowList.MatchIPAddr(addr)
|
||||
ipAllowed = ipAllowed && allowList.MatchIPAddr(addr)
|
||||
ipBlocked = ipBlocked || blockList.MatchIPAddr(addr)
|
||||
}
|
||||
var blockedError error
|
||||
if blockList.MatchHostName(hostName) || ipBlocked {
|
||||
blockedError = &git.ErrInvalidCloneAddr{Host: hostName, IsPermissionDenied: true}
|
||||
return &git.ErrInvalidCloneAddr{Host: hostName, IsPermissionDenied: true}
|
||||
}
|
||||
// if we have an allow-list, check the allow-list before return to get the more accurate error
|
||||
if !allowList.IsEmpty() {
|
||||
@@ -104,7 +103,7 @@ func checkByAllowBlockList(hostName string, addrList []net.IP) error {
|
||||
}
|
||||
}
|
||||
// otherwise, we always follow the blocked list
|
||||
return blockedError
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateRepository migrate repository according MigrateOptions
|
||||
@@ -524,9 +523,10 @@ func Init() error {
|
||||
if setting.Migrations.AllowLocalNetworks {
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinPrivate)
|
||||
allowList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
|
||||
} else {
|
||||
blockList.AppendBuiltin(hostmatcher.MatchBuiltinPrivate)
|
||||
blockList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
|
||||
}
|
||||
// TODO: at the moment, if ALLOW_LOCALNETWORKS=false, ALLOWED_DOMAINS=domain.com, and domain.com has IP 127.0.0.1, then it's still allowed.
|
||||
// if we want to block such case, the private&loopback should be added to the blockList when ALLOW_LOCALNETWORKS=false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,17 +93,19 @@ func TestAllowBlockList(t *testing.T) {
|
||||
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
|
||||
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
|
||||
|
||||
// allow wildcard, block some subdomains. if the domain name is allowed, then the local network check is skipped
|
||||
// allow wildcard, block some subdomains. every resolved address must still be allowed.
|
||||
init("*.domain.com", "blocked.domain.com", false)
|
||||
assert.NoError(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
|
||||
assert.NoError(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
|
||||
assert.Error(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
|
||||
assert.Error(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("1.2.3.4"), net.ParseIP("127.0.0.1")}))
|
||||
assert.Error(t, checkByAllowBlockList("blocked.domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
|
||||
assert.Error(t, checkByAllowBlockList("sub.other.com", []net.IP{net.ParseIP("1.2.3.4")}))
|
||||
|
||||
// allow wildcard (it could lead to SSRF in production)
|
||||
// allow wildcard still follows the local network policy for resolved addresses.
|
||||
init("*", "", false)
|
||||
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
|
||||
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
|
||||
assert.Error(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
|
||||
assert.Error(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("1.2.3.4"), net.ParseIP("127.0.0.1")}))
|
||||
|
||||
// local network can still be blocked
|
||||
init("*", "127.0.0.*", false)
|
||||
|
||||
@@ -239,7 +239,7 @@ func (r *RepositoryRestorer) GetPullRequests(_ context.Context, page, perPage in
|
||||
if pr.PatchURL != "" {
|
||||
pr.PatchURL = "file://" + util.FilePathJoinAbs(r.baseDir, pr.PatchURL)
|
||||
}
|
||||
CheckAndEnsureSafePR(pr, "", r)
|
||||
CheckAndEnsureSafePR(pr, "file://"+r.baseDir, r)
|
||||
}
|
||||
return pulls, true, nil
|
||||
}
|
||||
|
||||
@@ -45,3 +45,30 @@ func TestRepositoryRestorer_GetReleases_LocalFileInclusion(t *testing.T) {
|
||||
assert.Equal(t, "file://"+filepath.Join(baseDir, "good.txt"), optional.FromPtr(assets[0].DownloadURL).Value())
|
||||
assert.Equal(t, "file://"+filepath.Join(baseDir, "etc/passwd"), optional.FromPtr(assets[1].DownloadURL).Value())
|
||||
}
|
||||
|
||||
func TestRepositoryRestorer_GetPullRequestsStripsUnsafeCloneURL(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(baseDir, "change.patch"), []byte("patch"), 0o644))
|
||||
|
||||
pullRequestYML := `
|
||||
- number: 1
|
||||
patch_url: change.patch
|
||||
head:
|
||||
clone_url: http://127.0.0.1/private.git
|
||||
ref: feature
|
||||
base:
|
||||
ref: main
|
||||
`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(baseDir, "pull_request.yml"), []byte(pullRequestYML), 0o644))
|
||||
|
||||
r, err := NewRepositoryRestorer(t.Context(), baseDir, "owner", "repo", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
pulls, _, err := r.GetPullRequests(t.Context(), 1, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pulls, 1)
|
||||
|
||||
assert.Equal(t, "file://"+filepath.Join(baseDir, "change.patch"), pulls[0].PatchURL)
|
||||
assert.Empty(t, pulls[0].Head.CloneURL)
|
||||
assert.True(t, pulls[0].EnsuredSafe)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ func UpdateAddress(ctx context.Context, m *repo_model.Mirror, addr string) error
|
||||
}
|
||||
|
||||
func pruneBrokenReferences(ctx context.Context, m *repo_model.Mirror, gitRepo gitrepo.Repository, timeout time.Duration) error {
|
||||
cmd := gitcmd.NewCommand("remote", "prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
|
||||
// Never follow HTTP redirects, see cmdFetch in runSync.
|
||||
cmd := gitcmd.NewCommand("remote", "prune").AddConfig("http.followRedirects", "false").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
|
||||
stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd)
|
||||
if pruneErr != nil {
|
||||
// sanitize the output, since it may contain the remote address, which may contain a password
|
||||
@@ -119,7 +120,9 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
||||
|
||||
// use fetch but not remote update because git fetch support --tags but remote update doesn't
|
||||
cmdFetch := func() *gitcmd.Command {
|
||||
cmd := gitcmd.NewCommand("fetch", "--tags")
|
||||
// Never follow HTTP redirects: a mirror remote that later starts redirecting to an
|
||||
// otherwise-blocked address would be an SSRF/exfiltration vector on scheduled syncs.
|
||||
cmd := gitcmd.NewCommand("fetch", "--tags").AddConfig("http.followRedirects", "false")
|
||||
if m.EnablePrune {
|
||||
cmd.AddArguments("--prune")
|
||||
}
|
||||
@@ -200,7 +203,8 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
||||
}
|
||||
|
||||
cmdRemoteUpdatePrune := func() *gitcmd.Command {
|
||||
return gitcmd.NewCommand("remote", "update", "--prune").
|
||||
// Never follow HTTP redirects, see cmdFetch above.
|
||||
return gitcmd.NewCommand("remote", "update", "--prune").AddConfig("http.followRedirects", "false").
|
||||
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
issues_model "gitea.dev/models/issues"
|
||||
project_model "gitea.dev/models/project"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
|
||||
@@ -100,28 +101,15 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
||||
})
|
||||
}
|
||||
|
||||
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) {
|
||||
var issueList issues_model.IssueList
|
||||
for _, colIssues := range issuesMap {
|
||||
issueList = append(issueList, colIssues...)
|
||||
}
|
||||
err := issueList.LoadAssignees(ctx)
|
||||
func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
|
||||
sub := builder.Select("distinct issue_assignees.assignee_id").
|
||||
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
|
||||
Where(builder.Eq{"project_issue.project_id": projectID})
|
||||
err := db.GetEngine(ctx).Table("`user`").Where(builder.In("id", sub)).Find(&users)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]*user_model.User, 0, len(issueList))
|
||||
usersAdded := container.Set[int64]{}
|
||||
for _, issue := range issueList {
|
||||
for _, assignee := range issue.Assignees {
|
||||
if !usersAdded.Contains(assignee.ID) {
|
||||
usersAdded.Add(assignee.ID)
|
||||
users = append(users, assignee)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(users, func(a, b *user_model.User) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
slices.SortFunc(users, func(a, b *user_model.User) int { return strings.Compare(a.Name, b.Name) })
|
||||
return users, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_Projects(t *testing.T) {
|
||||
@@ -198,18 +197,4 @@ func Test_Projects(t *testing.T) {
|
||||
assert.Len(t, columnIssues[3], 1)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
|
||||
issuesMap := map[int64]issues_model.IssueList{}
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
||||
issuesMap[1] = issues_model.IssueList{issue1}
|
||||
issuesMap[2] = issues_model.IssueList{issue6}
|
||||
assignees, err := LoadIssuesAssigneesForProject(t.Context(), issuesMap)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assignees, 3)
|
||||
require.Equal(t, "user1", assignees[0].Name)
|
||||
require.Equal(t, "user10", assignees[1].Name)
|
||||
require.Equal(t, "user2", assignees[2].Name)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,11 +156,16 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
|
||||
return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil
|
||||
}
|
||||
|
||||
// EffectiveRequiredContexts returns the required status-check contexts for a PR head:
|
||||
// EffectiveRequiredContexts returns the required status-check contexts to enforce, drawn from:
|
||||
// 1. every required scoped workflow's status-check patterns effective for the repo (always)
|
||||
// 2. the branch protection's own configured contexts, only when its status check is enabled
|
||||
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pb *git_model.ProtectedBranch) ([]string, error) {
|
||||
if pb == nil {
|
||||
// 2. each given protected branch rule's own configured contexts, only when that rule's status check is enabled
|
||||
//
|
||||
// Passing no rule or a single nil rule yields nothing, not even scoped patterns.
|
||||
// A single rule yields that rule's effective contexts.
|
||||
// Passing several rules unions their effective contexts; this is used when the governing rule is not yet known.
|
||||
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pbs ...*git_model.ProtectedBranch) ([]string, error) {
|
||||
// No protection rule in effect: nothing is required.
|
||||
if len(pbs) == 0 || (len(pbs) == 1 && pbs[0] == nil) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -169,36 +174,28 @@ func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository,
|
||||
return nil, fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
|
||||
}
|
||||
|
||||
required := make(container.Set[string])
|
||||
|
||||
// Every required scoped workflow's admin-authored status-check patterns, matched must-present-and-pass:
|
||||
// a required scoped check that posts no matching status blocks the merge.
|
||||
seen := make(container.Set[string])
|
||||
var scoped []string
|
||||
for _, source := range sources {
|
||||
for _, cfg := range source.WorkflowConfigs {
|
||||
if !cfg.Required {
|
||||
continue
|
||||
}
|
||||
for _, p := range cfg.Patterns {
|
||||
if seen.Add(p) {
|
||||
scoped = append(scoped, p)
|
||||
}
|
||||
}
|
||||
required.AddMultiple(cfg.Patterns...)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(scoped) // sort for stable output
|
||||
|
||||
// With the branch protection's own status check disabled, only the required scoped checks (mandated by the owner or instance admin) gate the merge.
|
||||
if !pb.EnableStatusCheck {
|
||||
return scoped, nil
|
||||
}
|
||||
|
||||
// Status check enabled: the rule's configured contexts, then the scoped patterns not already among them.
|
||||
required := slices.Clone(pb.StatusCheckContexts)
|
||||
for _, p := range scoped {
|
||||
if !slices.Contains(pb.StatusCheckContexts, p) {
|
||||
required = append(required, p)
|
||||
// Union the configured contexts of every rule whose own status check is enabled (a disabled rule contributes none)
|
||||
for _, pb := range pbs {
|
||||
if pb == nil || !pb.EnableStatusCheck {
|
||||
continue
|
||||
}
|
||||
required.AddMultiple(pb.StatusCheckContexts...)
|
||||
}
|
||||
return required, nil
|
||||
|
||||
values := required.Values()
|
||||
slices.Sort(values) // stable output
|
||||
return values, nil
|
||||
}
|
||||
|
||||
@@ -153,4 +153,14 @@ func TestEffectiveRequiredContexts(t *testing.T) {
|
||||
"org/src: ci.yaml / lint (pull_request)",
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("several rules union their enabled contexts, deduplicated; a disabled rule contributes none", func(t *testing.T) {
|
||||
noSourceRepo := &repo_model.Repository{ID: consumer.ID, OwnerID: 99999} // no scoped sources, so only the rules' contexts gate
|
||||
a := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"a/check", "shared/check"}}
|
||||
b := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"b/check", "shared/check"}}
|
||||
off := &git_model.ProtectedBranch{EnableStatusCheck: false, StatusCheckContexts: []string{"off/check"}}
|
||||
got, err := EffectiveRequiredContexts(t.Context(), noSourceRepo, a, b, off)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"a/check", "b/check", "shared/check"}, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -326,6 +326,14 @@ func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer
|
||||
return fmt.Errorf("syncCommitDivergence: %w", err)
|
||||
}
|
||||
|
||||
// The "official" flag of existing reviews was computed against the previous
|
||||
// target branch's protection rules, so re-evaluate it against the new branch.
|
||||
// Otherwise a stale official approval could bypass the new branch's protection.
|
||||
pr.Issue.PullRequest = pr
|
||||
if err := issues_model.RecalculateReviewsOfficial(ctx, pr.Issue); err != nil {
|
||||
return fmt.Errorf("RecalculateReviewsOfficial: %w", err)
|
||||
}
|
||||
|
||||
// Create comment
|
||||
options := &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypeChangeTargetBranch,
|
||||
|
||||
@@ -20,9 +20,11 @@ import (
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context/upload"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
@@ -319,13 +321,17 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
|
||||
}
|
||||
|
||||
for uuid, newName := range editAttachments {
|
||||
if !deletedUUIDs.Contains(uuid) {
|
||||
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
|
||||
UUID: uuid,
|
||||
Name: newName,
|
||||
}, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if deletedUUIDs.Contains(uuid) {
|
||||
continue
|
||||
}
|
||||
if err = upload.Verify(nil, newName, setting.Repository.Release.AllowedTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
|
||||
UUID: uuid,
|
||||
Name: newName,
|
||||
}, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/services/attachment"
|
||||
"gitea.dev/services/context/upload"
|
||||
|
||||
_ "gitea.dev/models/actions"
|
||||
|
||||
@@ -270,6 +273,17 @@ func TestRelease_Update(t *testing.T) {
|
||||
assert.Equal(t, release.ID, release.Attachments[0].ReleaseID)
|
||||
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
|
||||
|
||||
defer test.MockVariableValue(&setting.Repository.Release.AllowedTypes, ".zip")()
|
||||
err = UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, map[string]string{
|
||||
attach.UUID: "test.exe",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.True(t, upload.IsErrFileTypeForbidden(err))
|
||||
release.Attachments = nil
|
||||
assert.NoError(t, repo_model.GetReleaseAttachments(t.Context(), release))
|
||||
assert.Len(t, release.Attachments, 1)
|
||||
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
|
||||
|
||||
// delete the attachment
|
||||
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, nil))
|
||||
release.Attachments = nil
|
||||
|
||||
@@ -42,6 +42,38 @@ type ArchiveRequest struct {
|
||||
archiveRefShortName string // the ref short name to download the archive, for example: "master", "v1.0.0", "commit id"
|
||||
}
|
||||
|
||||
type archiveQueueItem struct {
|
||||
RepoID int64 `json:"RepoID"`
|
||||
Type repo_model.ArchiveType `json:"Type"`
|
||||
CommitID string `json:"CommitID"`
|
||||
Paths []string `json:"Paths,omitempty"`
|
||||
ArchiveRefShortName string `json:"ArchiveRefShortName,omitempty"`
|
||||
}
|
||||
|
||||
func (aReq *ArchiveRequest) toQueueItem() *archiveQueueItem {
|
||||
return &archiveQueueItem{
|
||||
RepoID: aReq.Repo.ID,
|
||||
Type: aReq.Type,
|
||||
CommitID: aReq.CommitID,
|
||||
Paths: aReq.Paths,
|
||||
ArchiveRefShortName: aReq.archiveRefShortName,
|
||||
}
|
||||
}
|
||||
|
||||
func (item *archiveQueueItem) toArchiveRequest(ctx context.Context) (*ArchiveRequest, error) {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, item.RepoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ArchiveRequest{
|
||||
Repo: repo,
|
||||
Type: item.Type,
|
||||
CommitID: item.CommitID,
|
||||
Paths: item.Paths,
|
||||
archiveRefShortName: item.ArchiveRefShortName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewRequest creates an archival request, based on the URI. The
|
||||
// resulting ArchiveRequest is suitable for being passed to Await()
|
||||
// if it's determined that the request still needs to be satisfied.
|
||||
@@ -227,13 +259,18 @@ func doArchive(ctx context.Context, r *ArchiveRequest) (*repo_model.RepoArchiver
|
||||
return archiver, nil
|
||||
}
|
||||
|
||||
var archiverQueue *queue.WorkerPoolQueue[*ArchiveRequest]
|
||||
var archiverQueue *queue.WorkerPoolQueue[*archiveQueueItem]
|
||||
|
||||
// Init initializes archiver
|
||||
func Init(ctx context.Context) error {
|
||||
handler := func(items ...*ArchiveRequest) []*ArchiveRequest {
|
||||
for _, archiveReq := range items {
|
||||
log.Trace("ArchiverData Process: %#v", archiveReq)
|
||||
handler := func(items ...*archiveQueueItem) []*archiveQueueItem {
|
||||
for _, item := range items {
|
||||
log.Trace("ArchiverData Process: %#v", item)
|
||||
archiveReq, err := item.toArchiveRequest(ctx)
|
||||
if err != nil {
|
||||
log.Error("Archive repo %d: %v", item.RepoID, err)
|
||||
continue
|
||||
}
|
||||
if archiver, err := doArchive(ctx, archiveReq); err != nil {
|
||||
log.Error("Archive %v failed: %v", archiveReq, err)
|
||||
} else {
|
||||
@@ -254,14 +291,15 @@ func Init(ctx context.Context) error {
|
||||
|
||||
// StartArchive push the archive request to the queue
|
||||
func StartArchive(request *ArchiveRequest) error {
|
||||
has, err := archiverQueue.Has(request)
|
||||
item := request.toQueueItem()
|
||||
has, err := archiverQueue.Has(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
return archiverQueue.Push(request)
|
||||
return archiverQueue.Push(item)
|
||||
}
|
||||
|
||||
func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error {
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
@@ -21,6 +23,22 @@ func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
func TestArchiveQueueItemJSON(t *testing.T) {
|
||||
orig := &archiveQueueItem{
|
||||
RepoID: 7,
|
||||
Type: repo_model.ArchiveZip,
|
||||
CommitID: "abc123",
|
||||
Paths: []string{"agents"},
|
||||
ArchiveRefShortName: "main",
|
||||
}
|
||||
bs, err := json.Marshal(orig)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded archiveQueueItem
|
||||
require.NoError(t, json.Unmarshal(bs, &decoded))
|
||||
assert.Equal(t, *orig, decoded)
|
||||
}
|
||||
|
||||
func TestArchive_Basic(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
|
||||
<td>
|
||||
<a class="tw-text-red show-modal" href data-modal="#admin-package-delete-modal"
|
||||
data-modal-form.action="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.Version.ID}}"
|
||||
data-modal-form.url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.Version.ID}}"
|
||||
data-modal-package-name="{{.Package.Name}}" data-modal-package-version="{{.Version.Version}}"
|
||||
>{{svg "octicon-trash"}}</a>
|
||||
</td>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
|
||||
<td>
|
||||
<a class="tw-text-red show-modal" href data-modal="#admin-repo-delete-modal"
|
||||
data-modal-form.action="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.ID}}"
|
||||
data-modal-form.url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.ID}}"
|
||||
data-modal-repo-name="{{.Name}}"
|
||||
>{{svg "octicon-trash"}}</a>
|
||||
</td>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="item-trailing">
|
||||
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
|
||||
<button class="ui red button show-modal" data-modal="#remove-team-member"
|
||||
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}"
|
||||
data-modal-form.url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}"
|
||||
data-modal-name="{{.DisplayName}}"
|
||||
data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
|
||||
{{end}}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="flex-text-block">
|
||||
{{if .Team.IsMember ctx $.SignedUser.ID}}
|
||||
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
|
||||
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
|
||||
data-modal-form.url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
|
||||
data-modal-to-leave-team-name="{{$.Team.Name}}"
|
||||
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
|
||||
{{else if .IsOrganizationOwner}}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a>
|
||||
{{if .IsMember ctx $.SignedUser.ID}}
|
||||
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
|
||||
data-modal-form.action="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
|
||||
data-modal-form.url="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
|
||||
data-modal-to-leave-team-name="{{.Name}}"
|
||||
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
|
||||
{{end}}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-trailing">
|
||||
<div class="run-list-item-trailing">
|
||||
{{if $run.IsRefDeleted}}
|
||||
<span class="ui label run-list-ref gt-ellipsis tw-line-through" data-tooltip-content="{{$run.RefTooltip}}">{{$run.PrettyRef}}</span>
|
||||
{{else}}
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.action="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}">
|
||||
<button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.url="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}">
|
||||
{{svg "octicon-trash"}}
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<table class="ui very basic table unstackable" id="commits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="four wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="three wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="two wide sha">{{StringUtils.ToUpper $.Repository.ObjectFormatName}}</th>
|
||||
<th class="seven wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="eight wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th>
|
||||
<th class="one wide"></th>
|
||||
</tr>
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
{{if $canUserBlock}}
|
||||
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div>
|
||||
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div>
|
||||
{{end}}
|
||||
{{if $canOrgBlock}}
|
||||
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
|
||||
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.url="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</h2>
|
||||
{{template "base/alert" .}}
|
||||
|
||||
<form class="ui form" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm"
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm"
|
||||
data-existing-tags="{{JsonUtils.EncodeToString .Tags}}"
|
||||
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
|
||||
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="ui right">
|
||||
<button class="ui primary tiny button show-modal"
|
||||
data-modal="#add-secret-modal"
|
||||
data-modal-form.action="{{.Link}}"
|
||||
data-modal-form.url="{{.Link}}"
|
||||
data-modal-header="{{ctx.Locale.Tr "secrets.add_secret"}}"
|
||||
data-modal-secret-name.value=""
|
||||
data-modal-secret-name.read-only="false"
|
||||
@@ -39,7 +39,7 @@
|
||||
</span>
|
||||
<button class="btn interact-bg show-modal tw-p-2"
|
||||
data-modal="#add-secret-modal"
|
||||
data-modal-form.action="{{$.Link}}"
|
||||
data-modal-form.url="{{$.Link}}"
|
||||
data-modal-header="{{ctx.Locale.Tr "secrets.edit_secret"}}"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "secrets.edit_secret"}}"
|
||||
data-modal-secret-name.value="{{.Name}}"
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
{{end}}
|
||||
<li>
|
||||
{{if not .UserBlocking}}
|
||||
<a class="muted show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a>
|
||||
<a class="muted show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a>
|
||||
{{else}}
|
||||
<a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
|
||||
{{end}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="ui right">
|
||||
<button class="ui primary tiny button show-modal"
|
||||
data-modal="#edit-variable-modal"
|
||||
data-modal-form.action="{{.Link}}/new"
|
||||
data-modal-form.url="{{.Link}}/new"
|
||||
data-modal-header="{{ctx.Locale.Tr "actions.variables.creation"}}"
|
||||
data-modal-dialog-variable-name=""
|
||||
data-modal-dialog-variable-data=""
|
||||
@@ -39,7 +39,7 @@
|
||||
<button class="btn interact-bg tw-p-2 show-modal"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}"
|
||||
data-modal="#edit-variable-modal"
|
||||
data-modal-form.action="{{$.Link}}/{{.ID}}/edit"
|
||||
data-modal-form.url="{{$.Link}}/{{.ID}}/edit"
|
||||
data-modal-header="{{ctx.Locale.Tr "actions.variables.edit"}}"
|
||||
data-modal-dialog-variable-name="{{.Name}}"
|
||||
data-modal-dialog-variable-data="{{.Data}}"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="item-trailing">
|
||||
<button class="ui red button show-modal" data-modal="#leave-organization"
|
||||
data-modal-form.action="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}"
|
||||
data-modal-form.url="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}"
|
||||
data-modal-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ test('pdf file', async ({page, request}) => {
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
|
||||
const container = page.locator('.file-view-render-container');
|
||||
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
|
||||
expect((await container.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await expect.poll(async () => (await container.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(container, page.locator('.file-view'));
|
||||
});
|
||||
|
||||
|
||||
@@ -344,6 +344,59 @@ jobs:
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) {
|
||||
// A required scoped workflow excluded by a paths filter posts a skipped (success) commit status,
|
||||
// so the required check is satisfied and the PR can merge.
|
||||
|
||||
const scopedFilteredPRWorkflow = `name: Scoped Filtered PR
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- src/**
|
||||
jobs:
|
||||
scoped-filtered-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo scoped-filtered
|
||||
`
|
||||
source := createTestRepo(t, "sw-filtered-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow)
|
||||
registerUserScopedSource(t, source, "pr.yaml") // required
|
||||
|
||||
consumer := createTestRepo(t, "sw-filtered-consumer", false)
|
||||
// Protect the default branch (its own status check stays off, so only the required scoped check gates the merge).
|
||||
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{
|
||||
"rule_name": consumer.DefaultBranch,
|
||||
"enable_push": "true",
|
||||
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
|
||||
}), http.StatusSeeOther)
|
||||
|
||||
// Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out.
|
||||
prFile := &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change",
|
||||
Author: api.Identity{Name: user2.Name, Email: user2.Email},
|
||||
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
|
||||
}
|
||||
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile)
|
||||
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Filtered: no scoped run is created, but a skipped commit status is posted on the PR head.
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run")
|
||||
assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request")
|
||||
|
||||
// The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed.
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
|
||||
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
|
||||
user2Session.MakeRequest(t, mergeReq, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Settings page required patterns", func(t *testing.T) {
|
||||
source := createTestRepo(t, "sw-settings-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
|
||||
|
||||
@@ -166,6 +166,14 @@ jobs:
|
||||
assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
|
||||
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
|
||||
|
||||
// require the workflow's status check on the base branch, so a filtered-out run posts a skipped status to satisfy it
|
||||
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), baseRepo, &git_model.ProtectedBranch{
|
||||
RepoID: baseRepo.ID,
|
||||
RuleName: "main",
|
||||
EnableStatusCheck: true,
|
||||
StatusCheckContexts: []string{"*"},
|
||||
}, git_model.WhitelistOptions{}))
|
||||
|
||||
// add another file whose name cannot match the specified path
|
||||
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
@@ -215,8 +223,68 @@ jobs:
|
||||
err = pull_service.NewPullRequest(t.Context(), prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// the new pull request cannot trigger actions, so there is still only 1 record
|
||||
// the new pull request is filtered by paths, so no run is created; a skipped commit status is posted instead
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
|
||||
assertSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
|
||||
|
||||
// delete the branch protection rule: with nothing required, a filtered-out run must post no skipped status
|
||||
pb, err := git_model.GetProtectedBranchRuleByName(t.Context(), baseRepo.ID, "main")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pb)
|
||||
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), baseRepo, pb.ID))
|
||||
|
||||
// add another file whose name cannot match the specified path
|
||||
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: "bar.txt",
|
||||
ContentReader: strings.NewReader("bar"),
|
||||
},
|
||||
},
|
||||
Message: "add bar.txt",
|
||||
OldBranch: "main",
|
||||
NewBranch: "fork-branch-3",
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: user4.Name,
|
||||
GitUserEmail: user4.Email,
|
||||
},
|
||||
Committer: &files_service.IdentityOptions{
|
||||
GitUserName: user4.Name,
|
||||
GitUserEmail: user4.Email,
|
||||
},
|
||||
Dates: &files_service.CommitDateOptions{
|
||||
Author: time.Now(),
|
||||
Committer: time.Now(),
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, addFileToForkedResp)
|
||||
|
||||
// create Pull
|
||||
pullIssue = &issues_model.Issue{
|
||||
RepoID: baseRepo.ID,
|
||||
Title: "A mismatched path with no required status check posts no skipped status",
|
||||
PosterID: user4.ID,
|
||||
Poster: user4,
|
||||
IsPull: true,
|
||||
}
|
||||
pullRequest = &issues_model.PullRequest{
|
||||
HeadRepoID: forkedRepo.ID,
|
||||
BaseRepoID: baseRepo.ID,
|
||||
HeadBranch: "fork-branch-3",
|
||||
BaseBranch: "main",
|
||||
HeadRepo: forkedRepo,
|
||||
BaseRepo: baseRepo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
|
||||
err = pull_service.NewPullRequest(t.Context(), prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// filtered by paths and no required status check remains, so no run and no skipped commit status
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
|
||||
assertNoSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,6 +406,10 @@ jobs:
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, addFileToBranchResp)
|
||||
// the push to test-skip-ci is filtered by branches, so no run is created;
|
||||
// its context is not a required status check either, so no skipped commit status is posted.
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||
assertNoSkippedCommitStatusExists(t, repo.ID, addFileToBranchResp.Commit.SHA, "push")
|
||||
|
||||
resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci")
|
||||
|
||||
@@ -345,7 +417,7 @@ jobs:
|
||||
url := test.RedirectURL(resp)
|
||||
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
|
||||
|
||||
// the pr title contains a configured skip-ci string, so there is still only 1 record
|
||||
// the pr title contains a configured skip-ci string, so no run and no skipped status are created
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||
})
|
||||
}
|
||||
@@ -1879,3 +1951,29 @@ jobs:
|
||||
runner.fetchNoTask(t)
|
||||
})
|
||||
}
|
||||
|
||||
// assertSkippedCommitStatusExists asserts that a filtered-out required workflow posted a skipped commit status on sha
|
||||
func assertSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
|
||||
t.Helper()
|
||||
assert.Truef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "missing skipped commit status with event %q on %s", eventSuffix, sha)
|
||||
}
|
||||
|
||||
// assertNoSkippedCommitStatusExists asserts that no skipped commit status for the given event was posted on sha,
|
||||
// e.g. a filtered-out workflow whose context is not a required status check must not leave one behind.
|
||||
func assertNoSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
|
||||
t.Helper()
|
||||
assert.Falsef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "unexpected skipped commit status with event %q on %s", eventSuffix, sha)
|
||||
}
|
||||
|
||||
// hasSkippedCommitStatus reports whether a skipped commit status for the given event was posted on sha.
|
||||
func hasSkippedCommitStatus(t *testing.T, repoID int64, sha, eventSuffix string) bool {
|
||||
t.Helper()
|
||||
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repoID, sha, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
for _, s := range statuses {
|
||||
if s.State == commitstatus.CommitStatusSkipped && strings.Contains(s.Context, "("+eventSuffix+")") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package integration
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
@@ -150,3 +151,47 @@ func TestAPIDeleteIssueDependencyCrossRepoPermission(t *testing.T) {
|
||||
DependencyID: dependencyIssue.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebDeleteIssueDependencyCrossRepoPermission(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
targetRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
targetIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: targetRepo.ID, Index: 1})
|
||||
dependencyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.True(t, dependencyRepo.IsPrivate)
|
||||
dependencyIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: dependencyRepo.ID, Index: 1})
|
||||
|
||||
enableRepoDependencies(t, targetIssue.RepoID)
|
||||
enableRepoDependencies(t, dependencyRepo.ID)
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
assert.NoError(t, issues_model.CreateIssueDependency(t.Context(), user1, targetIssue, dependencyIssue))
|
||||
|
||||
user40 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
|
||||
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), targetRepo, user40, perm.AccessModeWrite))
|
||||
|
||||
session := loginUser(t, user40.Name)
|
||||
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/issues/%d/dependency/delete", targetIssue.Index), map[string]string{
|
||||
"removeDependencyID": strconv.FormatInt(dependencyIssue.ID, 10),
|
||||
"dependencyType": "blockedBy",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueDependency{
|
||||
IssueID: targetIssue.ID,
|
||||
DependencyID: dependencyIssue.ID,
|
||||
})
|
||||
|
||||
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), dependencyRepo, user40, perm.AccessModeRead))
|
||||
|
||||
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/issues/%d/dependency/delete", targetIssue.Index), map[string]string{
|
||||
"removeDependencyID": strconv.FormatInt(dependencyIssue.ID, 10),
|
||||
"dependencyType": "blockedBy",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
unittest.AssertNotExistsBean(t, &issues_model.IssueDependency{
|
||||
IssueID: targetIssue.ID,
|
||||
DependencyID: dependencyIssue.ID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ import (
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -100,6 +103,30 @@ func TestAPIGetTrackedTimesNonExistentUserFilter(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIUserTrackedTimesOmitsInaccessiblePrivateIssues(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
|
||||
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.True(t, privateRepo.IsPrivate)
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: privateRepo.ID})
|
||||
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), privateRepo, user, perm.AccessModeRead))
|
||||
_, err := issues_model.AddTime(t.Context(), user, issue, 60, time.Time{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, repo_service.DeleteCollaboration(t.Context(), privateRepo, user))
|
||||
|
||||
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadUser)
|
||||
req := NewRequest(t, "GET", "/api/v1/user/times").AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
trackedTimes := DecodeJSON(t, resp, api.TrackedTimeList{})
|
||||
for _, trackedTime := range trackedTimes {
|
||||
if assert.NotNil(t, trackedTime.Issue) {
|
||||
assert.NotEqual(t, issue.ID, trackedTime.Issue.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDeleteTrackedTime(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
||||
@@ -5,9 +5,14 @@ package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/tests"
|
||||
|
||||
@@ -95,6 +100,59 @@ func TestAPIActivityFeedsPublicOnly(t *testing.T) {
|
||||
assertPublicActivitiesOnly(t, activities)
|
||||
}
|
||||
|
||||
func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2 is a member of the private org private_org35
|
||||
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "private_org35"})
|
||||
|
||||
// a full org-scoped token can read the membership permissions
|
||||
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization)
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// a public-only token must not disclose permissions for a private org
|
||||
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(publicToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPITeamReposPublicOnly(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// team 1 (Owners of org3) has access to the private repos org3/repo3 and org3/repo5
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
privateRepo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
|
||||
|
||||
// a full org+repo scoped token sees the private repos
|
||||
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
|
||||
req := NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
repos := DecodeJSON(t, resp, []api.Repository{})
|
||||
assert.Contains(t, repoNames(repos), privateRepo.FullName())
|
||||
|
||||
// a public-only token must not receive any private repo
|
||||
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
|
||||
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(publicToken)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
repos = DecodeJSON(t, resp, []api.Repository{})
|
||||
for _, repo := range repos {
|
||||
assert.False(t, repo.Private)
|
||||
}
|
||||
assert.NotContains(t, repoNames(repos), privateRepo.FullName())
|
||||
assert.NotContains(t, repoNames(repos), privateRepo2.FullName())
|
||||
// the total-count header must match the filtered page, otherwise it leaks the
|
||||
// number of hidden private repos
|
||||
assert.Equal(t, strconv.Itoa(len(repos)), resp.Header().Get("X-Total-Count"))
|
||||
|
||||
// the single-repo endpoint must not confirm a private repo for a public-only token
|
||||
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(publicToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -241,3 +241,27 @@ func TestGetFileHistoryNotOnMaster(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "1", resp.Header().Get("X-Total"))
|
||||
}
|
||||
|
||||
func TestGetFileHistoryEmptyDateRange(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
// Login as User2.
|
||||
session := loginUser(t, user.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
||||
|
||||
// readme.md exists in repo16 but no commits fall before 1970, so the date
|
||||
// filter yields an empty range: this must return 200 with an empty list,
|
||||
// not 404 (regression: a valid path with an empty date range was a 404).
|
||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=readme.md&sha=good-sign&until=1970-01-01T00:00:00Z", user.Name).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
apiData := DecodeJSON(t, resp, []api.Commit{})
|
||||
assert.Empty(t, apiData)
|
||||
assert.Equal(t, "0", resp.Header().Get("X-Total"))
|
||||
|
||||
// a path that does not exist must still return 404 even with a date filter
|
||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=does-not-exist.md&sha=good-sign&until=1970-01-01T00:00:00Z", user.Name).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -240,9 +240,14 @@ func TestAPILFSBatch(t *testing.T) {
|
||||
assert.Equal(t, "Size must be less than or equal to 2", br.Objects[0].Error.Message)
|
||||
})
|
||||
|
||||
t.Run("AddMeta", func(t *testing.T) {
|
||||
t.Run("CrossRepoObjectRequiresUpload", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
// An object whose bytes already exist in the store but which is not
|
||||
// linked to this repo must not be silently linked, even when the
|
||||
// caller can access it in another repo. Auto-linking let a deploy key
|
||||
// (whose token carries the repo owner's identity) exfiltrate objects
|
||||
// across repos without proving possession. The client must upload.
|
||||
p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
|
||||
|
||||
contentStore := lfs.NewContentStore()
|
||||
@@ -250,6 +255,7 @@ func TestAPILFSBatch(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exist)
|
||||
|
||||
// The object is linked to another repo owned by the same user.
|
||||
repo2 := createLFSTestRepository(t, "lfs-batch2-repo")
|
||||
storeObjectInRepo(t, repo2.ID, "dummy0")
|
||||
|
||||
@@ -266,11 +272,13 @@ func TestAPILFSBatch(t *testing.T) {
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.Nil(t, br.Objects[0].Error)
|
||||
assert.Empty(t, br.Objects[0].Actions)
|
||||
// The client is told to upload instead of the object being linked.
|
||||
assert.Contains(t, br.Objects[0].Actions, "upload")
|
||||
|
||||
// No meta object may have been created for this repo.
|
||||
meta, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, meta)
|
||||
assert.Nil(t, meta)
|
||||
assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
|
||||
|
||||
// Cleanup
|
||||
err = contentStore.Delete(p.RelativePath())
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.dev/modules/structs"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/services/convert"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -306,6 +307,30 @@ func TestAPIGetTeamRepo(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIAddRemoveTeamRepositoryRequiresOrgOwnerOrSetting(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 12})
|
||||
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: team.OrgID})
|
||||
assert.False(t, org.RepoAdminChangeTeamAccess)
|
||||
targetRepo := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 32, OwnerID: org.ID})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), targetRepo, user, perm.AccessModeAdmin))
|
||||
|
||||
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteOrganization)
|
||||
url := fmt.Sprintf("/api/v1/teams/%d/repos/%s", team.ID, targetRepo.FullName())
|
||||
|
||||
req := NewRequest(t, "PUT", url).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: team.ID, RepoID: targetRepo.ID})
|
||||
|
||||
assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: org.ID, TeamID: team.ID, RepoID: targetRepo.ID}))
|
||||
|
||||
req = NewRequest(t, "DELETE", url).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
unittest.AssertExistsAndLoadBean(t, &organization.TeamRepo{TeamID: team.ID, RepoID: targetRepo.ID})
|
||||
}
|
||||
|
||||
func TestAPITeamVisibilityAccess(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team {
|
||||
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -93,6 +96,52 @@ func TestAPIStar(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIStarredReposAccessRevoked ensures a private repository disappears from a user's
|
||||
// starred list once their access to it has been revoked, so no metadata leaks afterwards.
|
||||
func TestAPIStarredReposAccessRevoked(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2/repo2 is a private repository owned by user2
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
// grant the collaborator read access and star the private repo as them
|
||||
require.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), repo, collaborator, perm.AccessModeRead))
|
||||
require.NoError(t, repo_model.StarRepo(t.Context(), collaborator, repo, true))
|
||||
|
||||
token := getUserToken(t, collaborator.Name, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
|
||||
|
||||
// while access is granted, the private repo is visible in the starred list
|
||||
req := NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
repos := DecodeJSON(t, resp, []api.Repository{})
|
||||
require.Len(t, repos, 1)
|
||||
assert.Equal(t, repo.FullName(), repos[0].FullName)
|
||||
|
||||
// revoke access
|
||||
require.NoError(t, repo_service.DeleteCollaboration(t.Context(), repo, collaborator))
|
||||
|
||||
// the star record still exists, but the repo (and its metadata) must no longer be returned
|
||||
assert.True(t, repo_model.IsStaring(t.Context(), collaborator.ID, repo.ID))
|
||||
req = NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
repos = DecodeJSON(t, resp, []api.Repository{})
|
||||
assert.Empty(t, repos)
|
||||
|
||||
// sanity: the owner still sees their own private repo
|
||||
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
|
||||
require.NoError(t, repo_model.StarRepo(t.Context(), owner, repo, true))
|
||||
req = NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(ownerToken)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
repos = DecodeJSON(t, resp, []api.Repository{})
|
||||
fullNames := make([]string, len(repos))
|
||||
for i, r := range repos {
|
||||
fullNames[i] = r.FullName
|
||||
}
|
||||
assert.Contains(t, fullNames, repo.FullName())
|
||||
}
|
||||
|
||||
func TestAPIStarDisabled(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
@@ -175,3 +224,23 @@ func TestAPIStarPublicOnly(t *testing.T) {
|
||||
require.Len(t, repos, 1)
|
||||
assert.Equal(t, "user5/repo4", repos[0].FullName)
|
||||
}
|
||||
|
||||
func TestAPIStarredReposOmitsInaccessiblePrivateRepos(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
|
||||
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.True(t, privateRepo.IsPrivate)
|
||||
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), privateRepo, user, perm.AccessModeRead))
|
||||
assert.NoError(t, repo_model.StarRepo(t.Context(), user, privateRepo, true))
|
||||
assert.NoError(t, repo_service.DeleteCollaboration(t.Context(), privateRepo, user))
|
||||
|
||||
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
|
||||
req := NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
repos := DecodeJSON(t, resp, []api.Repository{})
|
||||
for _, repo := range repos {
|
||||
assert.NotEqual(t, privateRepo.FullName(), repo.FullName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,11 @@ func testGetAttachment(t *testing.T) {
|
||||
{"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
|
||||
{"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound},
|
||||
{"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound},
|
||||
// draft release attachments must only be reachable by users with write access, even on a public repo
|
||||
{"DraftReleaseByOwner", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user2Session, http.StatusOK},
|
||||
{"DraftReleaseByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, adminSession, http.StatusOK},
|
||||
{"DraftReleaseByNonCollaborator", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user8Session, http.StatusNotFound},
|
||||
{"DraftReleaseByAnonymous", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, emptySession, http.StatusNotFound},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/builder"
|
||||
@@ -489,3 +490,73 @@ func TestOAuth2GroupClaimsManualLinking(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOAuth2AutoLinkWithTwoFactor verifies that automatic account linking completes
|
||||
// after the user passes local 2FA when an OIDC identity matches an existing account.
|
||||
func TestOAuth2AutoLinkWithTwoFactor(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
|
||||
|
||||
const (
|
||||
sourceName = "test-oauth-auto-link-2fa"
|
||||
sub = "oidc-auto-link-2fa-sub"
|
||||
email = "oidc-auto-link-2fa@example.com"
|
||||
userName = "oidc-auto-link-2fa"
|
||||
)
|
||||
|
||||
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: sub, Email: email, Name: "OIDC Auto Link 2FA"})
|
||||
addOAuth2Source(t, sourceName, oauth2.Source{
|
||||
Provider: "openidConnect",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
|
||||
})
|
||||
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
|
||||
require.NoError(t, err)
|
||||
|
||||
localUser := &user_model.User{Name: userName, Email: email}
|
||||
require.NoError(t, user_model.CreateUser(t.Context(), localUser, &user_model.Meta{}))
|
||||
|
||||
otpKey, err := totp.Generate(totp.GenerateOpts{
|
||||
SecretSize: 40,
|
||||
Issuer: "gitea-test",
|
||||
AccountName: localUser.Name,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tfa := &auth_model.TwoFactor{UID: localUser.ID}
|
||||
require.NoError(t, tfa.SetSecret(otpKey.Secret()))
|
||||
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
|
||||
|
||||
unittest.AssertNotExistsBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
|
||||
|
||||
session := emptyTestSession(t)
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
|
||||
|
||||
location := resp.Header().Get("Location")
|
||||
u, err := url.Parse(location)
|
||||
require.NoError(t, err)
|
||||
state := u.Query().Get("state")
|
||||
require.NotEmpty(t, state)
|
||||
|
||||
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
|
||||
resp = session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
|
||||
assert.Contains(t, resp.Header().Get("Location"), "/user/two_factor")
|
||||
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/two_factor"), http.StatusOK)
|
||||
|
||||
passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
req := NewRequestWithValues(t, "POST", "/user/two_factor", map[string]string{
|
||||
"passcode": passcode,
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
externalLink := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
|
||||
assert.Equal(t, localUser.ID, externalLink.UserID)
|
||||
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestUndoDeleteBranch(t *testing.T) {
|
||||
}
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.action")
|
||||
htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.url")
|
||||
assert.Contains(t,
|
||||
htmlDoc.doc.Find(".ui.positive.message").Text(),
|
||||
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -140,6 +141,43 @@ func testGitPush(t *testing.T, u *url.URL) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitPushVisibilityOption(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
|
||||
Name: "repo-visibility-option",
|
||||
AutoInit: false,
|
||||
DefaultBranch: "master",
|
||||
IsPrivate: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, repo)
|
||||
|
||||
gitPath := t.TempDir()
|
||||
doGitInitTestRepository(gitPath)(t)
|
||||
|
||||
oldPath, oldUser := u.Path, u.User
|
||||
defer func() { u.Path, u.User = oldPath, oldUser }()
|
||||
u.Path = repo.FullName() + ".git"
|
||||
u.User = url.UserPassword(user.LowerName, userPassword)
|
||||
doGitAddRemote(gitPath, "origin", u)(t)
|
||||
|
||||
// The first push into an empty repository is a "push-to-create", so the
|
||||
// repo.private push option is honored to set the initial visibility.
|
||||
doGitPushTestRepository(gitPath, "origin", "master", "-o", "repo.private=true")(t)
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||
assert.True(t, repo.IsPrivate, "repo.private option should apply on push-to-create")
|
||||
|
||||
// The repository is now populated; a later push must NOT silently flip
|
||||
// visibility, otherwise a repo admin could change it bypassing the audit
|
||||
// trail, webhooks, and notifications a proper settings change would fire.
|
||||
doGitCreateBranch(gitPath, "branch2")(t)
|
||||
doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t)
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||
assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository")
|
||||
})
|
||||
}
|
||||
|
||||
func runTestGitPush(t *testing.T, u *url.URL, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user