mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-10 03:10:00 +00:00
Compare commits
23 Commits
v1.28.0-de
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfebc4b0e1 | ||
|
|
d9534e7bfa | ||
|
|
532828cc82 | ||
|
|
8cf988f0a6 | ||
|
|
04a26f40a0 | ||
|
|
fc4eac390a | ||
|
|
64d559a8e6 | ||
|
|
03372836ab | ||
|
|
1af1e06ac4 | ||
|
|
e07a5de53f | ||
|
|
d4d81af583 | ||
|
|
6cd2c647ec | ||
|
|
37c8604105 | ||
|
|
a9814e789c | ||
|
|
ab10e37acf | ||
|
|
f7bc6b89c1 | ||
|
|
fabc5e6fcb | ||
|
|
a016d678db | ||
|
|
b6e409badd | ||
|
|
2734504cfc | ||
|
|
eee7967b81 | ||
|
|
1df8f91691 | ||
|
|
4654e7eccd |
268
CHANGELOG.md
268
CHANGELOG.md
@@ -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
|
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).
|
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
|
## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
|
||||||
|
|
||||||
* SECURITY
|
* SECURITY
|
||||||
|
|||||||
@@ -3008,6 +3008,10 @@ LEVEL = Info
|
|||||||
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
|
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
|
||||||
;; Maximum number of attempts a single workflow run can have. Default value is 50.
|
;; Maximum number of attempts a single workflow run can have. Default value is 50.
|
||||||
;MAX_RERUN_ATTEMPTS = 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
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ type StatusInfo struct {
|
|||||||
|
|
||||||
// GetStatusInfoList returns a slice of StatusInfo
|
// GetStatusInfoList returns a slice of StatusInfo
|
||||||
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
|
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
|
||||||
// same as those in aggregateJobStatus
|
// same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
|
||||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
|
allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
|
||||||
statusInfoList := make([]StatusInfo, 0, len(allStatus))
|
statusInfoList := make([]StatusInfo, 0, len(allStatus))
|
||||||
for _, s := range allStatus {
|
for _, s := range allStatus {
|
||||||
statusInfoList = append(statusInfoList, StatusInfo{
|
statusInfoList = append(statusInfoList, StatusInfo{
|
||||||
|
|||||||
@@ -73,8 +73,11 @@ func TestGetStatusInfoList(t *testing.T) {
|
|||||||
assert.Equal(t, []StatusInfo{
|
assert.Equal(t, []StatusInfo{
|
||||||
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
|
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
|
||||||
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
|
{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(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
|
||||||
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
|
{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"},
|
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
|
||||||
}, statusInfoList)
|
}, statusInfoList)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,28 @@ type ActionRunner struct {
|
|||||||
const (
|
const (
|
||||||
RunnerOfflineTime = time.Minute
|
RunnerOfflineTime = time.Minute
|
||||||
RunnerIdleTime = 10 * time.Second
|
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
|
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
|
||||||
func (r *ActionRunner) BelongsToOwnerName() string {
|
func (r *ActionRunner) BelongsToOwnerName() string {
|
||||||
if r.RepoID != 0 {
|
if r.RepoID != 0 {
|
||||||
@@ -251,21 +271,24 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (opts FindRunnerOptions) ToOrders() string {
|
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 {
|
switch opts.Sort {
|
||||||
case "online":
|
case "online":
|
||||||
return "last_online DESC"
|
return "last_online DESC, id ASC"
|
||||||
case "offline":
|
case "offline":
|
||||||
return "last_online ASC"
|
return "last_online ASC, id ASC"
|
||||||
case "alphabetically":
|
case "alphabetically":
|
||||||
return "name ASC"
|
return "name ASC, id ASC"
|
||||||
case "reversealphabetically":
|
case "reversealphabetically":
|
||||||
return "name DESC"
|
return "name DESC, id ASC"
|
||||||
case "newest":
|
case "newest":
|
||||||
return "id DESC"
|
return "id DESC"
|
||||||
case "oldest":
|
case "oldest":
|
||||||
return "id ASC"
|
return "id ASC"
|
||||||
}
|
}
|
||||||
return "last_online DESC"
|
return "last_online DESC, id ASC"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRunnerByUUID returns a runner via uuid
|
// GetRunnerByUUID returns a runner via uuid
|
||||||
|
|||||||
149
models/actions/runner_test.go
Normal file
149
models/actions/runner_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// another runner won the optimistic-lock race; it is never returned to callers.
|
||||||
var errJobAlreadyClaimed = errors.New("job already claimed by another runner")
|
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
|
// CreateTaskForRunner finds a waiting job that matches the runner's labels and
|
||||||
// atomically claims it. It iterates through all matching jobs so that a
|
// atomically claims it. It iterates through all matching jobs so that a
|
||||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
// 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").
|
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
|
||||||
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
||||||
}
|
}
|
||||||
if jobCond.IsValid() {
|
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: a more efficient way to filter labels
|
// TODO: a more efficient way to filter labels
|
||||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||||
for _, v := range jobs {
|
|
||||||
if !runner.CanMatchLabels(v.RunsOn) {
|
// Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
|
||||||
continue
|
// 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
|
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
|
// 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, &ActionTask{ID: task.ID})
|
||||||
unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,29 +55,34 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
|
|||||||
return parsed, nil
|
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(
|
func MatchScopedWorkflows(
|
||||||
parsed []*ParsedScopedWorkflow,
|
parsed []*ParsedScopedWorkflow,
|
||||||
consumerGitRepo *git.Repository,
|
consumerGitRepo *git.Repository,
|
||||||
consumerCommit *git.Commit,
|
consumerCommit *git.Commit,
|
||||||
triggedEvent webhook_module.HookEventType,
|
triggedEvent webhook_module.HookEventType,
|
||||||
payload api.Payloader,
|
payload api.Payloader,
|
||||||
) []*DetectedWorkflow {
|
) (matched, filtered []*DetectedWorkflow) {
|
||||||
workflows := make([]*DetectedWorkflow, 0, len(parsed))
|
|
||||||
for _, p := range parsed {
|
for _, p := range parsed {
|
||||||
for _, evt := range p.Events {
|
for _, evt := range p.Events {
|
||||||
if evt.IsSchedule() {
|
if evt.IsSchedule() {
|
||||||
// schedule is a non-target for scoped workflows
|
// schedule is a non-target for scoped workflows
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if detectMatched(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
dwf := &DetectedWorkflow{
|
||||||
workflows = append(workflows, &DetectedWorkflow{
|
EntryName: p.EntryName,
|
||||||
EntryName: p.EntryName,
|
TriggerEvent: evt,
|
||||||
TriggerEvent: evt,
|
Content: p.Content,
|
||||||
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
|
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() {
|
func init() {
|
||||||
model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
||||||
// Log the error instead of panic or fatal.
|
// Log the error instead of panic or fatal.
|
||||||
@@ -172,18 +180,16 @@ func DetectWorkflows(
|
|||||||
triggedEvent webhook_module.HookEventType,
|
triggedEvent webhook_module.HookEventType,
|
||||||
payload api.Payloader,
|
payload api.Payloader,
|
||||||
detectSchedule bool,
|
detectSchedule bool,
|
||||||
) ([]*DetectedWorkflow, []*DetectedWorkflow, error) {
|
) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
|
||||||
_, entries, err := ListWorkflows(commit)
|
_, entries, err := ListWorkflows(commit)
|
||||||
if err != nil {
|
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 {
|
for _, entry := range entries {
|
||||||
content, err := GetContentFromEntry(entry)
|
content, err := GetContentFromEntry(entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// one workflow may have multiple events
|
// one workflow may have multiple events
|
||||||
@@ -203,18 +209,24 @@ func DetectWorkflows(
|
|||||||
}
|
}
|
||||||
schedules = append(schedules, dwf)
|
schedules = append(schedules, dwf)
|
||||||
}
|
}
|
||||||
} else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) {
|
} else {
|
||||||
dwf := &DetectedWorkflow{
|
dwf := &DetectedWorkflow{
|
||||||
EntryName: entry.Name(),
|
EntryName: entry.Name(),
|
||||||
TriggerEvent: evt,
|
TriggerEvent: evt,
|
||||||
Content: content,
|
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) {
|
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
|
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) {
|
if !canGithubEventMatch(evt.Name, triggedEvent) {
|
||||||
return false
|
return detectNotApplicable
|
||||||
}
|
}
|
||||||
|
|
||||||
switch triggedEvent {
|
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())
|
log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
|
||||||
}
|
}
|
||||||
// no special filter parameters for these events, just return true if name matched
|
// no special filter parameters for these events, just return true if name matched
|
||||||
return true
|
return detectMatched
|
||||||
|
|
||||||
case // push
|
case // push
|
||||||
webhook_module.HookEventPush:
|
webhook_module.HookEventPush:
|
||||||
@@ -279,14 +291,20 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
|||||||
webhook_module.HookEventIssueAssign,
|
webhook_module.HookEventIssueAssign,
|
||||||
webhook_module.HookEventIssueLabel,
|
webhook_module.HookEventIssueLabel,
|
||||||
webhook_module.HookEventIssueMilestone:
|
webhook_module.HookEventIssueMilestone:
|
||||||
return matchIssuesEvent(payload.(*api.IssuePayload), evt)
|
if matchIssuesEvent(payload.(*api.IssuePayload), evt) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectNotApplicable
|
||||||
|
|
||||||
case // issue_comment
|
case // issue_comment
|
||||||
webhook_module.HookEventIssueComment,
|
webhook_module.HookEventIssueComment,
|
||||||
// `pull_request_comment` is same as `issue_comment`
|
// `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
|
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
|
||||||
webhook_module.HookEventPullRequestComment:
|
webhook_module.HookEventPullRequestComment:
|
||||||
return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt)
|
if matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectNotApplicable
|
||||||
|
|
||||||
case // pull_request
|
case // pull_request
|
||||||
webhook_module.HookEventPullRequest,
|
webhook_module.HookEventPullRequest,
|
||||||
@@ -300,34 +318,49 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
|||||||
case // pull_request_review
|
case // pull_request_review
|
||||||
webhook_module.HookEventPullRequestReviewApproved,
|
webhook_module.HookEventPullRequestReviewApproved,
|
||||||
webhook_module.HookEventPullRequestReviewRejected:
|
webhook_module.HookEventPullRequestReviewRejected:
|
||||||
return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt)
|
if matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectNotApplicable
|
||||||
|
|
||||||
case // pull_request_review_comment
|
case // pull_request_review_comment
|
||||||
webhook_module.HookEventPullRequestReviewComment:
|
webhook_module.HookEventPullRequestReviewComment:
|
||||||
return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt)
|
if matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectNotApplicable
|
||||||
|
|
||||||
case // release
|
case // release
|
||||||
webhook_module.HookEventRelease:
|
webhook_module.HookEventRelease:
|
||||||
return matchReleaseEvent(payload.(*api.ReleasePayload), evt)
|
if matchReleaseEvent(payload.(*api.ReleasePayload), evt) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectNotApplicable
|
||||||
|
|
||||||
case // registry_package
|
case // registry_package
|
||||||
webhook_module.HookEventPackage:
|
webhook_module.HookEventPackage:
|
||||||
return matchPackageEvent(payload.(*api.PackagePayload), evt)
|
if matchPackageEvent(payload.(*api.PackagePayload), evt) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectNotApplicable
|
||||||
|
|
||||||
case // workflow_run
|
case // workflow_run
|
||||||
webhook_module.HookEventWorkflowRun:
|
webhook_module.HookEventWorkflowRun:
|
||||||
return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
|
if matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectNotApplicable
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log.Warn("unsupported event %q", triggedEvent)
|
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
|
// with no special filter parameters
|
||||||
if len(evt.Acts()) == 0 {
|
if len(evt.Acts()) == 0 {
|
||||||
return true
|
return detectMatched
|
||||||
}
|
}
|
||||||
|
|
||||||
matchTimes := 0
|
matchTimes := 0
|
||||||
@@ -393,14 +426,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
|||||||
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
||||||
} else {
|
return detectNotApplicable
|
||||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
}
|
||||||
if err != nil {
|
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||||
break
|
if err != nil {
|
||||||
}
|
break
|
||||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
}
|
||||||
matchTimes++
|
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||||
}
|
matchTimes++
|
||||||
}
|
}
|
||||||
case "paths-ignore":
|
case "paths-ignore":
|
||||||
if refName.IsTag() {
|
if refName.IsTag() {
|
||||||
@@ -410,14 +443,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
|||||||
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
||||||
} else {
|
return detectNotApplicable
|
||||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
}
|
||||||
if err != nil {
|
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||||
break
|
if err != nil {
|
||||||
}
|
break
|
||||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
}
|
||||||
matchTimes++
|
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||||
}
|
matchTimes++
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
log.Warn("push event unsupported condition %q", cond)
|
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 {
|
if hasBranchFilter && hasTagFilter {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
}
|
}
|
||||||
return matchTimes == len(evt.Acts())
|
if matchTimes == len(evt.Acts()) {
|
||||||
|
return detectMatched
|
||||||
|
}
|
||||||
|
return detectFilteredOut
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
|
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())
|
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()
|
acts := evt.Acts()
|
||||||
activityTypeMatched := false
|
activityTypeMatched := false
|
||||||
matchTimes := 0
|
matchTimes := 0
|
||||||
@@ -525,7 +561,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
|
|||||||
headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
|
headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err)
|
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)
|
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
||||||
} else {
|
return detectNotApplicable
|
||||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
}
|
||||||
if err != nil {
|
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||||
break
|
if err != nil {
|
||||||
}
|
break
|
||||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
}
|
||||||
matchTimes++
|
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||||
}
|
matchTimes++
|
||||||
}
|
}
|
||||||
case "paths-ignore":
|
case "paths-ignore":
|
||||||
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
|
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
||||||
} else {
|
return detectNotApplicable
|
||||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
}
|
||||||
if err != nil {
|
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||||
break
|
if err != nil {
|
||||||
}
|
break
|
||||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
}
|
||||||
matchTimes++
|
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||||
}
|
matchTimes++
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
log.Warn("pull request event unsupported condition %q", cond)
|
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 {
|
func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {
|
||||||
|
|||||||
@@ -101,49 +101,49 @@ func TestDetectMatched(t *testing.T) {
|
|||||||
triggedEvent webhook_module.HookEventType
|
triggedEvent webhook_module.HookEventType
|
||||||
payload api.Payloader
|
payload api.Payloader
|
||||||
yamlOn string
|
yamlOn string
|
||||||
expected bool
|
expected detectResult
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
desc: "HookEventCreate(create) matches GithubEventCreate(create)",
|
desc: "HookEventCreate(create) matches GithubEventCreate(create)",
|
||||||
triggedEvent: webhook_module.HookEventCreate,
|
triggedEvent: webhook_module.HookEventCreate,
|
||||||
payload: nil,
|
payload: nil,
|
||||||
yamlOn: "on: create",
|
yamlOn: "on: create",
|
||||||
expected: true,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
|
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
|
||||||
triggedEvent: webhook_module.HookEventIssues,
|
triggedEvent: webhook_module.HookEventIssues,
|
||||||
payload: &api.IssuePayload{Action: api.HookIssueOpened},
|
payload: &api.IssuePayload{Action: api.HookIssueOpened},
|
||||||
yamlOn: "on: issues",
|
yamlOn: "on: issues",
|
||||||
expected: true,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
|
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
|
||||||
triggedEvent: webhook_module.HookEventIssues,
|
triggedEvent: webhook_module.HookEventIssues,
|
||||||
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
|
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
|
||||||
yamlOn: "on: issues",
|
yamlOn: "on: issues",
|
||||||
expected: true,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
|
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
|
||||||
triggedEvent: webhook_module.HookEventPullRequestSync,
|
triggedEvent: webhook_module.HookEventPullRequestSync,
|
||||||
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
|
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
|
||||||
yamlOn: "on: pull_request",
|
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",
|
desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
||||||
triggedEvent: webhook_module.HookEventPullRequest,
|
triggedEvent: webhook_module.HookEventPullRequest,
|
||||||
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
||||||
yamlOn: "on: pull_request",
|
yamlOn: "on: pull_request",
|
||||||
expected: false,
|
expected: detectNotApplicable,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
||||||
triggedEvent: webhook_module.HookEventPullRequest,
|
triggedEvent: webhook_module.HookEventPullRequest,
|
||||||
payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
|
payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
|
||||||
yamlOn: "on: pull_request",
|
yamlOn: "on: pull_request",
|
||||||
expected: false,
|
expected: detectNotApplicable,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches",
|
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]",
|
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",
|
desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
|
||||||
triggedEvent: webhook_module.HookEventPullRequest,
|
triggedEvent: webhook_module.HookEventPullRequest,
|
||||||
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
||||||
yamlOn: "on:\n pull_request:\n types: [labeled]",
|
yamlOn: "on:\n pull_request:\n types: [labeled]",
|
||||||
expected: true,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
|
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
|
||||||
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
|
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
|
||||||
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
||||||
yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
|
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)",
|
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,
|
triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
|
||||||
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
||||||
yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
|
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",
|
desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type",
|
||||||
triggedEvent: webhook_module.HookEventRelease,
|
triggedEvent: webhook_module.HookEventRelease,
|
||||||
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
|
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
|
||||||
yamlOn: "on:\n release:\n types: [published]",
|
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",
|
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
||||||
triggedEvent: webhook_module.HookEventPackage,
|
triggedEvent: webhook_module.HookEventPackage,
|
||||||
payload: &api.PackagePayload{Action: api.HookPackageCreated},
|
payload: &api.PackagePayload{Action: api.HookPackageCreated},
|
||||||
yamlOn: "on:\n registry_package:\n types: [updated]",
|
yamlOn: "on:\n registry_package:\n types: [updated]",
|
||||||
expected: false,
|
expected: detectNotApplicable,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
|
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
|
||||||
triggedEvent: webhook_module.HookEventWiki,
|
triggedEvent: webhook_module.HookEventWiki,
|
||||||
payload: nil,
|
payload: nil,
|
||||||
yamlOn: "on: gollum",
|
yamlOn: "on: gollum",
|
||||||
expected: true,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
|
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
|
||||||
triggedEvent: webhook_module.HookEventSchedule,
|
triggedEvent: webhook_module.HookEventSchedule,
|
||||||
payload: nil,
|
payload: nil,
|
||||||
yamlOn: "on: schedule",
|
yamlOn: "on: schedule",
|
||||||
expected: true,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "push to tag matches workflow with paths condition (should skip paths check)",
|
desc: "push to tag matches workflow with paths condition (should skip paths check)",
|
||||||
@@ -222,7 +222,19 @@ func TestDetectMatched(t *testing.T) {
|
|||||||
},
|
},
|
||||||
commit: nil,
|
commit: nil,
|
||||||
yamlOn: "on:\n push:\n paths:\n - src/**",
|
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))
|
evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, evts, 1)
|
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]))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,3 +131,17 @@ func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
|||||||
}
|
}
|
||||||
return c.allParticipants
|
return c.allParticipants
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoAuthorIdentities returns the commit's co-authors: the participants without the
|
||||||
|
// author and the committer, which are already displayed separately. This avoids
|
||||||
|
// surfacing the committer as a "co-author" (see issue #38384).
|
||||||
|
func (c *Commit) CoAuthorIdentities() []*CommitIdentity {
|
||||||
|
var ret []*CommitIdentity
|
||||||
|
for _, p := range c.AllParticipantIdentities()[1:] { // index 0 is always the author
|
||||||
|
if c.Committer.Email != "" && strings.EqualFold(p.Email, c.Committer.Email) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ret = append(ret, p)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,42 @@ func TestCommitMessageAllParticipantIdentities(t *testing.T) {
|
|||||||
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
|
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommitMessageCoAuthorIdentities(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
|
||||||
|
coAuthors []*CommitIdentity
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// a genuine co-author (neither author nor committer) is reported
|
||||||
|
&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")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// the committer is shown separately as "committed by", so it must
|
||||||
|
// not appear as a co-author even though it is a participant
|
||||||
|
&Commit{
|
||||||
|
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||||
|
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// regression for #38384: a Co-authored-by trailer naming the author
|
||||||
|
// must not cause the committer to be surfaced as the co-author
|
||||||
|
&Commit{
|
||||||
|
Author: sig("silverwind", "me@silverwind.io"), Committer: sig("bircni", "bircni@icloud.com"),
|
||||||
|
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: silverwind <me@silverwind.io>"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
assert.Equal(t, c.coAuthors, c.commit.CoAuthorIdentities())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,19 +21,6 @@ func TestCommitsCount(t *testing.T) {
|
|||||||
assert.Equal(t, int64(3), commitsCount)
|
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) {
|
func TestCommitsCountWithSinceUntil(t *testing.T) {
|
||||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||||
revision := []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}
|
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) {
|
func TestGetLatestCommitTime(t *testing.T) {
|
||||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||||
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
|
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
|
|
||||||
const defaultMaxRerunAttempts = 50
|
const defaultMaxRerunAttempts = 50
|
||||||
|
|
||||||
|
const defaultMaxConcurrentTaskPicks = 16
|
||||||
|
|
||||||
// Actions settings
|
// Actions settings
|
||||||
var (
|
var (
|
||||||
Actions = struct {
|
Actions = struct {
|
||||||
@@ -31,13 +33,18 @@ var (
|
|||||||
WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
|
WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
|
||||||
ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"`
|
ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"`
|
||||||
MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"`
|
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,
|
Enabled: true,
|
||||||
DefaultActionsURL: defaultActionsURLGitHub,
|
DefaultActionsURL: defaultActionsURLGitHub,
|
||||||
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
|
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
|
||||||
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
|
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
|
||||||
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
|
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
|
||||||
MaxRerunAttempts: defaultMaxRerunAttempts,
|
MaxRerunAttempts: defaultMaxRerunAttempts,
|
||||||
|
MaxConcurrentTaskPicks: defaultMaxConcurrentTaskPicks,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +135,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
|
|||||||
Actions.MaxRerunAttempts = defaultMaxRerunAttempts
|
Actions.MaxRerunAttempts = defaultMaxRerunAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Actions.MaxConcurrentTaskPicks <= 0 {
|
||||||
|
Actions.MaxConcurrentTaskPicks = defaultMaxConcurrentTaskPicks
|
||||||
|
}
|
||||||
|
|
||||||
if !Actions.LogCompression.IsValid() {
|
if !Actions.LogCompression.IsValid() {
|
||||||
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
|
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,17 +73,18 @@ func TestInitKeys(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, keyFiles, len(keyTypes))
|
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 {
|
for _, keyType := range keyTypes {
|
||||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||||
info, err := os.Stat(privKeyPath)
|
data, err := os.ReadFile(privKeyPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
metadata[privKeyPath] = info
|
content[privKeyPath] = data
|
||||||
|
|
||||||
info, err = os.Stat(pubKeyPath)
|
data, err = os.ReadFile(pubKeyPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
metadata[pubKeyPath] = info
|
content[pubKeyPath] = data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test recreation on missing private key and noop for missing pub key
|
// 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)
|
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||||
|
|
||||||
infoPriv, err := os.Stat(privKeyPath)
|
dataPriv, err := os.ReadFile(privKeyPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
switch keyType {
|
switch keyType {
|
||||||
case "rsa":
|
case "rsa":
|
||||||
// No modification to RSA key
|
// No modification to RSA key
|
||||||
infoPub, err := os.Stat(pubKeyPath)
|
dataPub, err := os.ReadFile(pubKeyPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
assert.Equal(t, content[privKeyPath], dataPriv)
|
||||||
assert.Equal(t, metadata[pubKeyPath], infoPub)
|
assert.Equal(t, content[pubKeyPath], dataPub)
|
||||||
case "ecdsa":
|
case "ecdsa":
|
||||||
// ECDSA public key should be missing, private unchanged
|
// ECDSA public key should be missing, private unchanged
|
||||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
assert.Equal(t, content[privKeyPath], dataPriv)
|
||||||
assert.NoFileExists(t, pubKeyPath)
|
assert.NoFileExists(t, pubKeyPath)
|
||||||
case "ed25519":
|
case "ed25519":
|
||||||
// ed25519 private key was removed, so both keys regenerated
|
// ed25519 private key was removed, so both keys regenerated
|
||||||
infoPub, err := os.Stat(pubKeyPath)
|
dataPub, err := os.ReadFile(pubKeyPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEqual(t, metadata[privKeyPath], infoPriv)
|
assert.NotEqual(t, content[privKeyPath], dataPriv)
|
||||||
assert.NotEqual(t, metadata[pubKeyPath], infoPub)
|
assert.NotEqual(t, content[pubKeyPath], dataPub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -47,40 +48,42 @@ type MinioStorage struct {
|
|||||||
basePath string
|
basePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertMinioErr(err error) error {
|
func convertMinioErr(err error, optMsg ...string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 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 {
|
if !ok {
|
||||||
return err
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert two responses to standard analogues
|
// Convert two responses to standard analogues
|
||||||
switch errResp.Code {
|
switch errResp.Code {
|
||||||
case "NoSuchKey":
|
case "NoSuchKey":
|
||||||
return os.ErrNotExist
|
return wrapErr(os.ErrNotExist)
|
||||||
case "AccessDenied":
|
case "AccessDenied":
|
||||||
return os.ErrPermission
|
return wrapErr(os.ErrPermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return wrapErr(err)
|
||||||
}
|
|
||||||
|
|
||||||
var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
|
|
||||||
_, err := minioClient.GetBucketVersioning(ctx, bucket)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMinioStorage returns a minio storage
|
// NewMinioStorage returns a minio storage
|
||||||
func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
|
func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
|
||||||
config := cfg.MinioConfig
|
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" {
|
if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
|
||||||
return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
|
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
|
var lookup minio.BucketLookupType
|
||||||
switch config.BucketLookUpType {
|
switch config.BucketLookUpType {
|
||||||
case "auto", "":
|
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)
|
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{
|
minioClient, err := minio.New(config.Endpoint, &minio.Options{
|
||||||
Creds: buildMinioCredentials(config),
|
Creds: buildMinioCredentials(config),
|
||||||
Secure: config.UseSSL,
|
Secure: config.UseSSL,
|
||||||
@@ -101,37 +111,21 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
|
|||||||
BucketLookup: lookup,
|
BucketLookup: lookup,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, convertMinioErr(err)
|
return nil, convertMinioErr(err, makeErrMsg("NewClient"))
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check to see if we already own this bucket
|
// Check to see if we already own this bucket
|
||||||
exists, err := minioClient.BucketExists(ctx, config.Bucket)
|
exists, err := minioClient.BucketExists(ctx, config.Bucket)
|
||||||
if err != nil {
|
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 !exists {
|
||||||
if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
|
if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
|
||||||
Region: config.Location,
|
Region: config.Location,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, convertMinioErr(err)
|
return nil, convertMinioErr(err, makeErrMsg("MakeBucket"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,13 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
"github.com/minio/minio-go/v7"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,31 +81,18 @@ func TestMinioStoragePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestS3StorageBadRequest(t *testing.T) {
|
func TestS3StorageBadRequest(t *testing.T) {
|
||||||
if os.Getenv("CI") == "" {
|
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
|
||||||
t.Skip("S3Storage not present outside of CI")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := &setting.Storage{
|
cfg := &setting.Storage{
|
||||||
MinioConfig: setting.MinioStorageConfig{
|
MinioConfig: setting.MinioStorageConfig{
|
||||||
Endpoint: "minio:9000",
|
Endpoint: endpoint,
|
||||||
AccessKeyID: "123456",
|
AccessKeyID: "123456",
|
||||||
SecretAccessKey: "12345678",
|
SecretAccessKey: "invalid-secret",
|
||||||
Bucket: "bucket",
|
Bucket: "bucket",
|
||||||
Location: "us-east-1",
|
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)
|
_, err := NewStorage(setting.MinioStorageType, cfg)
|
||||||
assert.ErrorContains(t, err, message)
|
assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinioCredentials(t *testing.T) {
|
func TestMinioCredentials(t *testing.T) {
|
||||||
|
|||||||
@@ -92,9 +92,12 @@ func TestTemplateEscape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("Golang URL Escape", func(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>`)
|
actual := execTmpl(`<a href="?a={{"%"}}"></a>`)
|
||||||
assert.Equal(t, `<a href="?a=%25"></a>`, actual)
|
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>`)
|
actual = execTmpl(`<a data-xxx-url="?a={{"%"}}"></a>`)
|
||||||
assert.Equal(t, `<a data-xxx-url="?a=%25"></a>`, actual)
|
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
|
// non-URL content isn't auto-escaped
|
||||||
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
|
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
|
||||||
assert.Equal(t, `<a data-link="?a=%"></a>`, actual)
|
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) {
|
t.Run("QueryBuild", func(t *testing.T) {
|
||||||
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)
|
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
auth_model "gitea.dev/models/auth"
|
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")
|
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
|
||||||
}
|
}
|
||||||
|
|
||||||
cols := []string{"last_online"}
|
now := time.Now()
|
||||||
runner.LastOnline = timeutil.TimeStampNow()
|
cols := make([]string, 0, 2)
|
||||||
if methodName == "UpdateTask" || methodName == "UpdateLog" {
|
// Debounce last_active too: while a runner streams logs, UpdateLog fires
|
||||||
runner.LastActive = timeutil.TimeStampNow()
|
// 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")
|
cols = append(cols, "last_active")
|
||||||
}
|
}
|
||||||
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
|
// Debounce last_online: writing on every poll is a major source of DB load
|
||||||
log.Error("can't update runner status: %v", err)
|
// 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)
|
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,
|
// 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.
|
// 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.
|
// 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)
|
log.Error("pick task failed: %v", err)
|
||||||
return nil, status.Errorf(codes.Internal, "pick task: %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 {
|
} else if ok {
|
||||||
task = t
|
task = t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,8 +258,27 @@ func GetAllCommits(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
} else if commitsCountTotal == 0 {
|
} else if commitsCountTotal == 0 {
|
||||||
ctx.APIErrorNotFound()
|
// when date filters are active, a zero count may just mean no
|
||||||
return
|
// 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(
|
commits, _, err = ctx.Repo.GitRepo.CommitsByFileAndRange(
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ func MustInitSessioner() func(next http.Handler) http.Handler {
|
|||||||
Secure: setting.SessionConfig.Secure,
|
Secure: setting.SessionConfig.Secure,
|
||||||
SameSite: setting.SessionConfig.SameSite,
|
SameSite: setting.SessionConfig.SameSite,
|
||||||
Domain: setting.SessionConfig.Domain,
|
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 {
|
if err != nil {
|
||||||
log.Fatal("common.Sessioner failed: %v", err)
|
log.Fatal("common.Sessioner failed: %v", err)
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ import (
|
|||||||
type preReceiveContext struct {
|
type preReceiveContext struct {
|
||||||
*gitea_context.PrivateContext
|
*gitea_context.PrivateContext
|
||||||
|
|
||||||
// loadedPusher indicates that where the following information are loaded
|
user *user_model.User // the "pusher", it's the org user if a DeployKey is used
|
||||||
loadedPusher bool
|
|
||||||
user *user_model.User // it's the org user if a DeployKey is used
|
|
||||||
userPerm access_model.Permission
|
userPerm access_model.Permission
|
||||||
deployKeyAccessMode perm_model.AccessMode
|
deployKeyAccessMode perm_model.AccessMode
|
||||||
|
|
||||||
@@ -53,10 +51,7 @@ type preReceiveContext struct {
|
|||||||
|
|
||||||
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||||
if ctx.canWriteCodeUnitCached == nil {
|
if ctx.canWriteCodeUnitCached == nil {
|
||||||
var canWrite bool
|
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||||
if ctx.loadPusherAndPermission() {
|
|
||||||
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
|
||||||
}
|
|
||||||
ctx.canWriteCodeUnitCached = &canWrite
|
ctx.canWriteCodeUnitCached = &canWrite
|
||||||
}
|
}
|
||||||
return *ctx.canWriteCodeUnitCached
|
return *ctx.canWriteCodeUnitCached
|
||||||
@@ -91,9 +86,6 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
|||||||
// CanCreatePullRequest returns true if pusher can create pull requests
|
// CanCreatePullRequest returns true if pusher can create pull requests
|
||||||
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
|
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
|
||||||
if !ctx.checkedCanCreatePullRequest {
|
if !ctx.checkedCanCreatePullRequest {
|
||||||
if !ctx.loadPusherAndPermission() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
|
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
|
||||||
ctx.checkedCanCreatePullRequest = true
|
ctx.checkedCanCreatePullRequest = true
|
||||||
}
|
}
|
||||||
@@ -124,6 +116,10 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
opts: opts,
|
opts: opts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !ourCtx.loadPusherAndPermission() {
|
||||||
|
return // if error occurs, loadPusherAndPermission had written the error response
|
||||||
|
}
|
||||||
|
|
||||||
// Iterate across the provided old commit IDs
|
// Iterate across the provided old commit IDs
|
||||||
for i := range opts.OldCommitIDs {
|
for i := range opts.OldCommitIDs {
|
||||||
oldCommitID := opts.OldCommitIDs[i]
|
oldCommitID := opts.OldCommitIDs[i]
|
||||||
@@ -281,18 +277,10 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
|
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if isForcePush {
|
||||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, user)
|
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
|
||||||
} else {
|
} 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
|
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
|
// 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
|
// 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)
|
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
|
// loadPusherAndPermission returns false if an error occurs, and it writes the error response
|
||||||
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
||||||
if ctx.loadedPusher {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.opts.UserID == user_model.ActionsUserID {
|
if ctx.opts.UserID == user_model.ActionsUserID {
|
||||||
taskID := ctx.opts.ActionsTaskID
|
taskID := ctx.opts.ActionsTaskID
|
||||||
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
|
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
|
||||||
@@ -555,7 +533,5 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
|||||||
}
|
}
|
||||||
ctx.deployKeyAccessMode = deployKey.Mode
|
ctx.deployKeyAccessMode = deployKey.Mode
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.loadedPusher = true
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
|||||||
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||||
ctx := &preReceiveContext{
|
ctx := &preReceiveContext{
|
||||||
PrivateContext: mockCtx,
|
PrivateContext: mockCtx,
|
||||||
loadedPusher: true,
|
|
||||||
user: maintainer,
|
user: maintainer,
|
||||||
userPerm: headPerm,
|
userPerm: headPerm,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
|||||||
|
|
||||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
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.KeyUID: u.ID,
|
||||||
session.KeyUname: u.Name,
|
session.KeyUname: u.Name,
|
||||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||||
@@ -357,7 +357,7 @@ func SignInPost(ctx *context.Context) {
|
|||||||
// User will need to use WebAuthn, save data
|
// User will need to use WebAuthn, save data
|
||||||
updates["totpEnrolled"] = u.ID
|
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)
|
ctx.ServerError("UserSignIn: Unable to update session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -398,7 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := updateSession(ctx, []string{
|
if err := regenerateSession(ctx, []string{
|
||||||
// Delete the openid, 2fa and link_account data
|
// Delete the openid, 2fa and link_account data
|
||||||
"openid_verified_uri",
|
"openid_verified_uri",
|
||||||
"openid_signin_remember",
|
"openid_signin_remember",
|
||||||
@@ -884,7 +884,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
|
|||||||
|
|
||||||
log.Trace("User activated: %s", user.Name)
|
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,
|
"uid": user.ID,
|
||||||
"uname": user.Name,
|
"uname": user.Name,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -936,7 +936,7 @@ func ActivateEmail(ctx *context.Context) {
|
|||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
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 {
|
if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil {
|
||||||
return fmt.Errorf("regenerate session: %w", err)
|
return fmt.Errorf("regenerate session: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,12 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
|||||||
return
|
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.
|
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||||
"twofaUid": u.ID,
|
"twofaUid": u.ID,
|
||||||
"twofaRemember": remember,
|
"twofaRemember": remember,
|
||||||
|
|||||||
@@ -285,9 +285,7 @@ func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
|
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
|
||||||
return updateSession(ctx, nil, map[string]any{
|
return ctx.Session.Set("linkAccountData", linkAccountData)
|
||||||
"linkAccountData": linkAccountData,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := updateSession(ctx, nil, map[string]any{
|
if err := regenerateSession(ctx, nil, map[string]any{
|
||||||
session.KeyUID: u.ID,
|
session.KeyUID: u.ID,
|
||||||
session.KeyUname: u.Name,
|
session.KeyUname: u.Name,
|
||||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
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.
|
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||||
"twofaUid": u.ID,
|
"twofaUid": u.ID,
|
||||||
"twofaRemember": false,
|
"twofaRemember": false,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) {
|
|||||||
if u != nil {
|
if u != nil {
|
||||||
nickname = u.LowerName
|
nickname = u.LowerName
|
||||||
}
|
}
|
||||||
if err := updateSession(ctx, nil, map[string]any{
|
if err := regenerateSession(ctx, nil, map[string]any{
|
||||||
"openid_verified_uri": id,
|
"openid_verified_uri": id,
|
||||||
"openid_determined_email": email,
|
"openid_determined_email": email,
|
||||||
"openid_determined_username": nickname,
|
"openid_determined_username": nickname,
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ func ViewProject(ctx *context.Context) {
|
|||||||
ctx.Data["MilestoneID"] = milestoneID
|
ctx.Data["MilestoneID"] = milestoneID
|
||||||
|
|
||||||
// Get assignees.
|
// Get assignees.
|
||||||
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap)
|
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, project.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("LoadIssuesAssigneesForProject", err)
|
ctx.ServerError("LoadIssuesAssigneesForProject", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ func ServeAttachment(ctx *context.Context, uuid string) {
|
|||||||
return
|
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 {
|
if requiredScope, ok := attachmentReadScope(unitType); ok {
|
||||||
context.CheckTokenScopes(ctx, repo, requiredScope)
|
context.CheckTokenScopes(ctx, repo, requiredScope)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ func Diff(ctx *context.Context) {
|
|||||||
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
||||||
ctx.Data["Verification"] = verification
|
ctx.Data["Verification"] = verification
|
||||||
ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit)
|
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["Parents"] = parents
|
||||||
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
|
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,6 @@ import (
|
|||||||
"gitea.dev/services/context"
|
"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) {
|
func IssuePullPosters(ctx *context.Context) {
|
||||||
isPullList := ctx.PathParam("type") == "pulls"
|
isPullList := ctx.PathParam("type") == "pulls"
|
||||||
issuePosters(ctx, isPullList)
|
issuePosters(ctx, isPullList)
|
||||||
@@ -46,14 +35,5 @@ func issuePosters(ctx *context.Context, isPullList bool) {
|
|||||||
posters = append(posters, ctx.Doer)
|
posters = append(posters, ctx.Doer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ctx.JSON(http.StatusOK, shared_user.ToSearchUserResponse(ctx, ctx.Doer, posters))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/web/feed"
|
"gitea.dev/routers/web/feed"
|
||||||
shared_user "gitea.dev/routers/web/shared/user"
|
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/context/upload"
|
"gitea.dev/services/context/upload"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
@@ -338,7 +337,6 @@ func LatestRelease(ctx *context.Context) {
|
|||||||
|
|
||||||
func newReleaseCommon(ctx *context.Context) {
|
func newReleaseCommon(ctx *context.Context) {
|
||||||
ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
|
ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
|
||||||
ctx.Data["PageIsReleaseList"] = true
|
|
||||||
|
|
||||||
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
|
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -346,17 +344,8 @@ func newReleaseCommon(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Data["Tags"] = tags
|
ctx.Data["Tags"] = tags
|
||||||
|
|
||||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
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")
|
upload.AddUploadContext(ctx, "release")
|
||||||
|
|
||||||
PrepareBranchList(ctx) // for New Release page
|
PrepareBranchList(ctx) // for New Release page
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,8 +414,8 @@ func GenerateReleaseNotes(ctx *context.Context) {
|
|||||||
|
|
||||||
// NewReleasePost response for creating a release
|
// NewReleasePost response for creating a release
|
||||||
func NewReleasePost(ctx *context.Context) {
|
func NewReleasePost(ctx *context.Context) {
|
||||||
newReleaseCommon(ctx)
|
if ctx.HasError() {
|
||||||
if ctx.Written() {
|
ctx.JSONError(ctx.GetErrMsg())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,35 +439,28 @@ func NewReleasePost(ctx *context.Context) {
|
|||||||
// Or another choice is "always show the tag-only button" if error occurs.
|
// Or another choice is "always show the tag-only button" if error occurs.
|
||||||
ctx.Data["ShowCreateTagOnlyButton"] = form.TagOnly || rel == nil
|
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)
|
form.Target = util.IfZero(form.Target, ctx.Repo.Repository.DefaultBranch)
|
||||||
if exist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, form.Target); !exist {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !form.TagOnly && form.Title == "" {
|
if !form.TagOnly && form.Title == "" {
|
||||||
// if not "tag only", then the title of the release cannot be empty
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTagReleaseError := func(err error) {
|
handleTagReleaseError := func(err error) {
|
||||||
ctx.Data["Err_TagName"] = true
|
|
||||||
switch {
|
switch {
|
||||||
case release_service.IsErrTagAlreadyExists(err):
|
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):
|
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):
|
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):
|
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:
|
default:
|
||||||
ctx.ServerError("handleTagReleaseError", err)
|
ctx.ServerError("handleTagReleaseError", err)
|
||||||
}
|
}
|
||||||
@@ -497,7 +479,7 @@ func NewReleasePost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.TagName))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +504,7 @@ func NewReleasePost(ctx *context.Context) {
|
|||||||
handleTagReleaseError(err)
|
handleTagReleaseError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
|
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
|
||||||
return
|
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
|
// 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
|
// add new logic: if tag-only, do not convert the tag to a release
|
||||||
if form.TagOnly || !rel.IsTag {
|
if form.TagOnly || !rel.IsTag {
|
||||||
ctx.Data["Err_TagName"] = true
|
ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +528,7 @@ func NewReleasePost(ctx *context.Context) {
|
|||||||
handleTagReleaseError(err)
|
handleTagReleaseError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
|
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditRelease render release edit page
|
// EditRelease render release edit page
|
||||||
@@ -589,55 +570,39 @@ func EditRelease(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Data["attachments"] = rel.Attachments
|
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)
|
ctx.HTML(http.StatusOK, tplReleaseNew)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditReleasePost response for edit release
|
// EditReleasePost response for edit release
|
||||||
func EditReleasePost(ctx *context.Context) {
|
func EditReleasePost(ctx *context.Context) {
|
||||||
form := web.GetForm(ctx).(*forms.EditReleaseForm)
|
if ctx.HasError() {
|
||||||
|
ctx.JSONError(ctx.GetErrMsg())
|
||||||
newReleaseCommon(ctx)
|
|
||||||
if ctx.Written() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
|
form := web.GetForm(ctx).(*forms.EditReleaseForm)
|
||||||
ctx.Data["PageIsEditRelease"] = true
|
|
||||||
|
|
||||||
tagName := ctx.PathParam("*")
|
tagName := ctx.PathParam("*")
|
||||||
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
|
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if repo_model.IsErrReleaseNotExist(err) {
|
if repo_model.IsErrReleaseNotExist(err) {
|
||||||
ctx.NotFound(err)
|
ctx.JSONErrorNotFound(err.Error())
|
||||||
} else {
|
} else {
|
||||||
ctx.ServerError("GetRelease", err)
|
ctx.ServerError("GetRelease", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rel.IsTag {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Data["tag_name"] = rel.TagName
|
ctx.Data["tag_name"] = rel.TagName
|
||||||
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
|
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
|
||||||
ctx.Data["title"] = rel.Title
|
ctx.Data["title"] = rel.Title
|
||||||
ctx.Data["content"] = rel.Note
|
ctx.Data["content"] = rel.Note
|
||||||
ctx.Data["prerelease"] = rel.IsPrerelease
|
ctx.Data["prerelease"] = rel.IsPrerelease
|
||||||
|
|
||||||
if ctx.HasError() {
|
|
||||||
ctx.HTML(http.StatusOK, tplReleaseNew)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const delPrefix = "attachment-del-"
|
const delPrefix = "attachment-del-"
|
||||||
const editPrefix = "attachment-edit-"
|
const editPrefix = "attachment-edit-"
|
||||||
var addAttachmentUUIDs, delAttachmentUUIDs []string
|
var addAttachmentUUIDs, delAttachmentUUIDs []string
|
||||||
@@ -659,10 +624,14 @@ func EditReleasePost(ctx *context.Context) {
|
|||||||
rel.IsPrerelease = form.Prerelease
|
rel.IsPrerelease = form.Prerelease
|
||||||
if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo,
|
if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo,
|
||||||
rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil {
|
rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil {
|
||||||
ctx.ServerError("UpdateRelease", err)
|
if upload.IsErrFileTypeForbidden(err) {
|
||||||
|
ctx.JSONError(err.Error())
|
||||||
|
} else {
|
||||||
|
ctx.ServerError("UpdateRelease", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
|
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteRelease deletes a release
|
// DeleteRelease deletes a release
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/contexttest"
|
"gitea.dev/services/contexttest"
|
||||||
@@ -39,15 +41,15 @@ func TestNewReleasePost(t *testing.T) {
|
|||||||
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"])
|
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"])
|
||||||
})
|
})
|
||||||
|
|
||||||
post := func(t *testing.T, form forms.NewReleaseForm) *context.Context {
|
post := func(t *testing.T, form forms.NewReleaseForm) (*context.Context, *httptest.ResponseRecorder) {
|
||||||
ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new")
|
ctx, resp := contexttest.MockContext(t, "user2/repo1/releases/new")
|
||||||
contexttest.LoadUser(t, ctx, 2)
|
contexttest.LoadUser(t, ctx, 2)
|
||||||
contexttest.LoadRepo(t, ctx, 1)
|
contexttest.LoadRepo(t, ctx, 1)
|
||||||
contexttest.LoadGitRepo(t, ctx)
|
contexttest.LoadGitRepo(t, ctx)
|
||||||
defer ctx.Repo.GitRepo.Close()
|
defer ctx.Repo.GitRepo.Close()
|
||||||
web.SetForm(ctx, &form)
|
web.SetForm(ctx, &form)
|
||||||
NewReleasePost(ctx)
|
NewReleasePost(ctx)
|
||||||
return ctx
|
return ctx, resp
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRelease := func(t *testing.T, tagName string) *repo_model.Release {
|
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) {
|
t.Run("ReleaseExistsDoUpdate(non-tag)", func(t *testing.T) {
|
||||||
ctx := post(t, forms.NewReleaseForm{
|
_, resp := post(t, forms.NewReleaseForm{
|
||||||
TagName: "v1.1",
|
TagName: "v1.1",
|
||||||
Target: "master",
|
Target: "master",
|
||||||
Title: "updated-title",
|
Title: "updated-title",
|
||||||
@@ -80,11 +82,11 @@ func TestNewReleasePost(t *testing.T) {
|
|||||||
require.NotNil(t, rel)
|
require.NotNil(t, rel)
|
||||||
assert.False(t, rel.IsTag)
|
assert.False(t, rel.IsTag)
|
||||||
assert.Equal(t, "testing-release", rel.Title)
|
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) {
|
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
|
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
|
||||||
Target: "master",
|
Target: "master",
|
||||||
Title: "updated-title",
|
Title: "updated-title",
|
||||||
@@ -95,12 +97,12 @@ func TestNewReleasePost(t *testing.T) {
|
|||||||
require.NotNil(t, rel)
|
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.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.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
|
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"]) // still show the "tag-only" button
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ReleaseExistsDoUpdate(tag-release)", func(t *testing.T) {
|
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
|
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
|
||||||
Target: "master",
|
Target: "master",
|
||||||
Title: "updated-title",
|
Title: "updated-title",
|
||||||
@@ -114,7 +116,7 @@ func TestNewReleasePost(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("TagOnly", func(t *testing.T) {
|
t.Run("TagOnly", func(t *testing.T) {
|
||||||
ctx := post(t, forms.NewReleaseForm{
|
ctx, _ := post(t, forms.NewReleaseForm{
|
||||||
TagName: "new-tag-only",
|
TagName: "new-tag-only",
|
||||||
Target: "master",
|
Target: "master",
|
||||||
Title: "title",
|
Title: "title",
|
||||||
@@ -128,7 +130,7 @@ func TestNewReleasePost(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("TagOnlyConflict", func(t *testing.T) {
|
t.Run("TagOnlyConflict", func(t *testing.T) {
|
||||||
ctx := post(t, forms.NewReleaseForm{
|
_, resp := post(t, forms.NewReleaseForm{
|
||||||
TagName: "v1.1",
|
TagName: "v1.1",
|
||||||
Target: "master",
|
Target: "master",
|
||||||
Title: "title",
|
Title: "title",
|
||||||
@@ -138,7 +140,7 @@ func TestNewReleasePost(t *testing.T) {
|
|||||||
rel := loadRelease(t, "v1.1")
|
rel := loadRelease(t, "v1.1")
|
||||||
require.NotNil(t, rel)
|
require.NotNil(t, rel)
|
||||||
assert.False(t, rel.IsTag)
|
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"
|
"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 {
|
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
|
||||||
if doer != nil {
|
if doer == nil {
|
||||||
idx := slices.IndexFunc(users, func(u *user.User) bool {
|
return users
|
||||||
return u.ID == doer.ID
|
}
|
||||||
})
|
idx := slices.IndexFunc(users, func(u *user.User) bool {
|
||||||
if idx > 0 {
|
return u.ID == doer.ID
|
||||||
newUsers := make([]*user.User, len(users))
|
})
|
||||||
newUsers[0] = users[idx]
|
if idx > 0 {
|
||||||
copy(newUsers[1:], users[:idx])
|
newUsers := make([]*user.User, len(users))
|
||||||
copy(newUsers[idx+1:], users[idx+1:])
|
newUsers[0] = users[idx]
|
||||||
return newUsers
|
copy(newUsers[1:], users[:idx])
|
||||||
}
|
copy(newUsers[idx+1:], users[idx+1:])
|
||||||
|
return newUsers
|
||||||
}
|
}
|
||||||
return users
|
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.
|
// 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.
|
// 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.
|
// So it's better to make it work like GitHub: users could input username directly.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
@@ -14,10 +15,14 @@ import (
|
|||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
actions_module "gitea.dev/modules/actions"
|
actions_module "gitea.dev/modules/actions"
|
||||||
|
"gitea.dev/modules/actions/jobparser"
|
||||||
"gitea.dev/modules/commitstatus"
|
"gitea.dev/modules/commitstatus"
|
||||||
|
"gitea.dev/modules/glob"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
webhook_module "gitea.dev/modules/webhook"
|
webhook_module "gitea.dev/modules/webhook"
|
||||||
|
pull_service "gitea.dev/services/pull"
|
||||||
commitstatus_service "gitea.dev/services/repository/commitstatus"
|
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.
|
// 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)
|
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
|
// Mix the workflow file path into the hash so two workflow files that
|
||||||
// share the same `name:` and job name produce distinct commit statuses
|
// share the same `name:` and job name produce distinct commit statuses
|
||||||
// even though they render identically — matching GitHub's behavior
|
// even though they render identically — matching GitHub's behavior
|
||||||
// (issue #35699).
|
// (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
|
// 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 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
|
// the new row supersedes it; otherwise the old pending status would stay
|
||||||
// stuck forever (it lives in its own dedupe group). Only relevant for
|
// stuck forever (it lives in its own dedupe group). Only relevant for
|
||||||
// in-flight workflows at upgrade time.
|
// in-flight workflows at upgrade time.
|
||||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
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)
|
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -183,8 +183,9 @@ func notify(ctx context.Context, input *notifyInput) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var detectedWorkflows []*actions_module.DetectedWorkflow
|
var detectedWorkflows []*actions_module.DetectedWorkflow
|
||||||
|
var filteredWorkflows []*actions_module.DetectedWorkflow
|
||||||
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
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.Event,
|
||||||
input.Payload,
|
input.Payload,
|
||||||
shouldDetectSchedules,
|
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 {
|
if input.PullRequest != nil {
|
||||||
// detect pull_request_target workflows
|
// detect pull_request_target workflows
|
||||||
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
|
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
|
||||||
@@ -219,7 +231,7 @@ func notify(ctx context.Context, input *notifyInput) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("gitRepo.GetCommit: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("DetectWorkflows: %w", err)
|
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)
|
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RelativePath(), baseCommit.ID)
|
||||||
} else {
|
} else {
|
||||||
for _, wf := range baseWorkflows {
|
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 {
|
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
|
||||||
detectedWorkflows = append(detectedWorkflows, wf)
|
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 {
|
if shouldDetectSchedules {
|
||||||
@@ -244,6 +269,8 @@ func notify(ctx context.Context, input *notifyInput) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleFilteredWorkflows(ctx, input, filteredWorkflows)
|
||||||
|
|
||||||
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
|
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +396,28 @@ func buildApproveAndInsertRun(
|
|||||||
return nil
|
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 {
|
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
|
||||||
return newNotifyInput(issue.Repo, issue.Poster, event)
|
return newNotifyInput(issue.Repo, issue.Poster, event)
|
||||||
}
|
}
|
||||||
@@ -616,6 +665,12 @@ func detectAndHandleScopedWorkflows(
|
|||||||
isForkPullRequest := isForkPullRequestInput(input)
|
isForkPullRequest := isForkPullRequestInput(input)
|
||||||
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
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 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.
|
// the IDs and batch-load them in one query instead of one round-trip per source.
|
||||||
seen := make(container.Set[int64], len(sources))
|
seen := make(container.Set[int64], len(sources))
|
||||||
@@ -640,7 +695,7 @@ func detectAndHandleScopedWorkflows(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceCommitSHA, detected, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
|
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
|
||||||
continue
|
continue
|
||||||
@@ -658,23 +713,40 @@ func detectAndHandleScopedWorkflows(
|
|||||||
continue
|
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
|
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(
|
func detectScopedWorkflowsForSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input *notifyInput,
|
input *notifyInput,
|
||||||
consumerGitRepo *git.Repository,
|
consumerGitRepo *git.Repository,
|
||||||
consumerCommit *git.Commit,
|
consumerCommit *git.Commit,
|
||||||
sourceRepo *repo_model.Repository,
|
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
|
// 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)
|
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
|
||||||
if err != nil {
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
secret_model "gitea.dev/models/secret"
|
secret_model "gitea.dev/models/secret"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
"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) {
|
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
|
||||||
var (
|
var (
|
||||||
task *runnerv1.Task
|
task *runnerv1.Task
|
||||||
@@ -76,6 +107,15 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
|
|||||||
NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID)
|
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
|
return task, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
services/actions/task_test.go
Normal file
34
services/actions/task_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -254,6 +254,17 @@ func BatchHandler(ctx *context.Context) {
|
|||||||
|
|
||||||
var responseObject *lfs_module.ObjectResponse
|
var responseObject *lfs_module.ObjectResponse
|
||||||
if isUpload {
|
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
|
var err *lfs_module.ObjectError
|
||||||
if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize {
|
if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize {
|
||||||
err = &lfs_module.ObjectError{
|
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)
|
responseObject = buildObjectResponse(rc, p, false, !exists, err)
|
||||||
} else {
|
} else {
|
||||||
var err *lfs_module.ObjectError
|
var err *lfs_module.ObjectError
|
||||||
@@ -337,13 +329,17 @@ func UploadHandler(ctx *context.Context) {
|
|||||||
|
|
||||||
uploadOrVerify := func() error {
|
uploadOrVerify := func() error {
|
||||||
if exists {
|
if exists {
|
||||||
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
|
// The bytes already exist in the content store. Only skip proof of
|
||||||
if err != nil {
|
// possession when the object is already linked to *this* repo; never
|
||||||
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
if !accessible {
|
if meta == nil {
|
||||||
// The file exists but the user has no access to it.
|
// The file exists but is not linked to this repo.
|
||||||
// The upload gets verified by hashing and size comparison to prove access to it.
|
// The upload gets verified by hashing and size comparison to prove access to it.
|
||||||
hash := sha256.New()
|
hash := sha256.New()
|
||||||
written, err := io.Copy(hash, ctx.Req.Body)
|
written, err := io.Copy(hash, ctx.Req.Body)
|
||||||
|
|||||||
@@ -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 {
|
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)
|
stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd)
|
||||||
if pruneErr != nil {
|
if pruneErr != nil {
|
||||||
// sanitize the output, since it may contain the remote address, which may contain a password
|
// 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
|
// use fetch but not remote update because git fetch support --tags but remote update doesn't
|
||||||
cmdFetch := func() *gitcmd.Command {
|
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 {
|
if m.EnablePrune {
|
||||||
cmd.AddArguments("--prune")
|
cmd.AddArguments("--prune")
|
||||||
}
|
}
|
||||||
@@ -200,7 +203,8 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmdRemoteUpdatePrune := func() *gitcmd.Command {
|
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)
|
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import (
|
|||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
project_model "gitea.dev/models/project"
|
project_model "gitea.dev/models/project"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/container"
|
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
|
// 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) {
|
func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
|
||||||
var issueList issues_model.IssueList
|
sub := builder.Select("distinct issue_assignees.assignee_id").
|
||||||
for _, colIssues := range issuesMap {
|
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
|
||||||
issueList = append(issueList, colIssues...)
|
Where(builder.Eq{"project_issue.project_id": projectID})
|
||||||
}
|
err := db.GetEngine(ctx).Table("`user`").Where(builder.In("id", sub)).Find(&users)
|
||||||
err := issueList.LoadAssignees(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
users := make([]*user_model.User, 0, len(issueList))
|
slices.SortFunc(users, func(a, b *user_model.User) int { return strings.Compare(a.Name, b.Name) })
|
||||||
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)
|
|
||||||
})
|
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_Projects(t *testing.T) {
|
func Test_Projects(t *testing.T) {
|
||||||
@@ -198,18 +197,4 @@ func Test_Projects(t *testing.T) {
|
|||||||
assert.Len(t, columnIssues[3], 1)
|
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
|
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)
|
// 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
|
// 2. each given protected branch rule's own configured contexts, only when that rule's status check is enabled
|
||||||
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pb *git_model.ProtectedBranch) ([]string, error) {
|
//
|
||||||
if pb == nil {
|
// 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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,36 +174,28 @@ func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository,
|
|||||||
return nil, fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
|
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:
|
// 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.
|
// 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 _, source := range sources {
|
||||||
for _, cfg := range source.WorkflowConfigs {
|
for _, cfg := range source.WorkflowConfigs {
|
||||||
if !cfg.Required {
|
if !cfg.Required {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, p := range cfg.Patterns {
|
required.AddMultiple(cfg.Patterns...)
|
||||||
if seen.Add(p) {
|
|
||||||
scoped = append(scoped, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.Sort(scoped) // sort for stable output
|
// Union the configured contexts of every rule whose own status check is enabled (a disabled rule contributes none)
|
||||||
|
for _, pb := range pbs {
|
||||||
// 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 == nil || !pb.EnableStatusCheck {
|
||||||
if !pb.EnableStatusCheck {
|
continue
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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)",
|
"org/src: ci.yaml / lint (pull_request)",
|
||||||
}, got)
|
}, 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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import (
|
|||||||
"gitea.dev/modules/graceful"
|
"gitea.dev/modules/graceful"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/repository"
|
"gitea.dev/modules/repository"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/storage"
|
"gitea.dev/modules/storage"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
"gitea.dev/services/context/upload"
|
||||||
notify_service "gitea.dev/services/notify"
|
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 {
|
for uuid, newName := range editAttachments {
|
||||||
if !deletedUUIDs.Contains(uuid) {
|
if deletedUUIDs.Contains(uuid) {
|
||||||
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
|
continue
|
||||||
UUID: uuid,
|
}
|
||||||
Name: newName,
|
if err = upload.Verify(nil, newName, setting.Repository.Release.AllowedTypes); err != nil {
|
||||||
}, "name"); err != nil {
|
return err
|
||||||
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"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/gitrepo"
|
"gitea.dev/modules/gitrepo"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/services/attachment"
|
"gitea.dev/services/attachment"
|
||||||
|
"gitea.dev/services/context/upload"
|
||||||
|
|
||||||
_ "gitea.dev/models/actions"
|
_ "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, release.ID, release.Attachments[0].ReleaseID)
|
||||||
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
|
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
|
// delete the attachment
|
||||||
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, nil))
|
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, nil))
|
||||||
release.Attachments = 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"
|
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
|
// NewRequest creates an archival request, based on the URI. The
|
||||||
// resulting ArchiveRequest is suitable for being passed to Await()
|
// resulting ArchiveRequest is suitable for being passed to Await()
|
||||||
// if it's determined that the request still needs to be satisfied.
|
// 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
|
return archiver, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var archiverQueue *queue.WorkerPoolQueue[*ArchiveRequest]
|
var archiverQueue *queue.WorkerPoolQueue[*archiveQueueItem]
|
||||||
|
|
||||||
// Init initializes archiver
|
// Init initializes archiver
|
||||||
func Init(ctx context.Context) error {
|
func Init(ctx context.Context) error {
|
||||||
handler := func(items ...*ArchiveRequest) []*ArchiveRequest {
|
handler := func(items ...*archiveQueueItem) []*archiveQueueItem {
|
||||||
for _, archiveReq := range items {
|
for _, item := range items {
|
||||||
log.Trace("ArchiverData Process: %#v", archiveReq)
|
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 {
|
if archiver, err := doArchive(ctx, archiveReq); err != nil {
|
||||||
log.Error("Archive %v failed: %v", archiveReq, err)
|
log.Error("Archive %v failed: %v", archiveReq, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -254,14 +291,15 @@ func Init(ctx context.Context) error {
|
|||||||
|
|
||||||
// StartArchive push the archive request to the queue
|
// StartArchive push the archive request to the queue
|
||||||
func StartArchive(request *ArchiveRequest) error {
|
func StartArchive(request *ArchiveRequest) error {
|
||||||
has, err := archiverQueue.Has(request)
|
item := request.toQueueItem()
|
||||||
|
has, err := archiverQueue.Has(item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if has {
|
if has {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return archiverQueue.Push(request)
|
return archiverQueue.Push(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error {
|
func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/services/contexttest"
|
"gitea.dev/services/contexttest"
|
||||||
|
|
||||||
@@ -21,6 +23,22 @@ func TestMain(m *testing.M) {
|
|||||||
unittest.MainTest(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) {
|
func TestArchive_Basic(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
|
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="tw-text-red show-modal" href data-modal="#admin-package-delete-modal"
|
<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}}"
|
data-modal-package-name="{{.Package.Name}}" data-modal-package-version="{{.Version.Version}}"
|
||||||
>{{svg "octicon-trash"}}</a>
|
>{{svg "octicon-trash"}}</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
|
<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="tw-text-red show-modal" href data-modal="#admin-repo-delete-modal"
|
<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}}"
|
data-modal-repo-name="{{.Name}}"
|
||||||
>{{svg "octicon-trash"}}</a>
|
>{{svg "octicon-trash"}}</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<div class="item-trailing">
|
<div class="item-trailing">
|
||||||
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
|
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
|
||||||
<button class="ui red button show-modal" data-modal="#remove-team-member"
|
<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-name="{{.DisplayName}}"
|
||||||
data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
|
data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{if .Team.IsMember ctx $.SignedUser.ID}}
|
{{if .Team.IsMember ctx $.SignedUser.ID}}
|
||||||
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
|
<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}}"
|
data-modal-to-leave-team-name="{{$.Team.Name}}"
|
||||||
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
|
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
|
||||||
{{else if .IsOrganizationOwner}}
|
{{else if .IsOrganizationOwner}}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a>
|
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a>
|
||||||
{{if .IsMember ctx $.SignedUser.ID}}
|
{{if .IsMember ctx $.SignedUser.ID}}
|
||||||
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
|
<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}}"
|
data-modal-to-leave-team-name="{{.Name}}"
|
||||||
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
|
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{{else}}
|
{{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"}}
|
{{svg "octicon-trash"}}
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<table class="ui very basic table unstackable" id="commits-table">
|
<table class="ui very basic table unstackable" id="commits-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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="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="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th>
|
||||||
<th class="one wide"></th>
|
<th class="one wide"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -34,10 +34,10 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if $canUserBlock}}
|
{{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}}
|
{{end}}
|
||||||
{{if $canOrgBlock}}
|
{{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}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
{{template "base/alert" .}}
|
{{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-existing-tags="{{JsonUtils.EncodeToString .Tags}}"
|
||||||
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
|
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
|
||||||
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"
|
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="ui right">
|
<div class="ui right">
|
||||||
<button class="ui primary tiny button show-modal"
|
<button class="ui primary tiny button show-modal"
|
||||||
data-modal="#add-secret-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-header="{{ctx.Locale.Tr "secrets.add_secret"}}"
|
||||||
data-modal-secret-name.value=""
|
data-modal-secret-name.value=""
|
||||||
data-modal-secret-name.read-only="false"
|
data-modal-secret-name.read-only="false"
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<button class="btn interact-bg show-modal tw-p-2"
|
<button class="btn interact-bg show-modal tw-p-2"
|
||||||
data-modal="#add-secret-modal"
|
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-modal-header="{{ctx.Locale.Tr "secrets.edit_secret"}}"
|
||||||
data-tooltip-content="{{ctx.Locale.Tr "secrets.edit_secret"}}"
|
data-tooltip-content="{{ctx.Locale.Tr "secrets.edit_secret"}}"
|
||||||
data-modal-secret-name.value="{{.Name}}"
|
data-modal-secret-name.value="{{.Name}}"
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<li>
|
<li>
|
||||||
{{if not .UserBlocking}}
|
{{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}}
|
{{else}}
|
||||||
<a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
|
<a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="ui right">
|
<div class="ui right">
|
||||||
<button class="ui primary tiny button show-modal"
|
<button class="ui primary tiny button show-modal"
|
||||||
data-modal="#edit-variable-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-header="{{ctx.Locale.Tr "actions.variables.creation"}}"
|
||||||
data-modal-dialog-variable-name=""
|
data-modal-dialog-variable-name=""
|
||||||
data-modal-dialog-variable-data=""
|
data-modal-dialog-variable-data=""
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<button class="btn interact-bg tw-p-2 show-modal"
|
<button class="btn interact-bg tw-p-2 show-modal"
|
||||||
data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}"
|
data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}"
|
||||||
data-modal="#edit-variable-modal"
|
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-header="{{ctx.Locale.Tr "actions.variables.edit"}}"
|
||||||
data-modal-dialog-variable-name="{{.Name}}"
|
data-modal-dialog-variable-name="{{.Name}}"
|
||||||
data-modal-dialog-variable-data="{{.Data}}"
|
data-modal-dialog-variable-data="{{.Data}}"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-trailing">
|
<div class="item-trailing">
|
||||||
<button class="ui red button show-modal" data-modal="#leave-organization"
|
<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"}}
|
data-modal-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test('pdf file', async ({page, request}) => {
|
|||||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
|
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
|
||||||
const container = page.locator('.file-view-render-container');
|
const container = page.locator('.file-view-render-container');
|
||||||
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
|
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'));
|
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) {
|
t.Run("Settings page required patterns", func(t *testing.T) {
|
||||||
source := createTestRepo(t, "sw-settings-source", false)
|
source := createTestRepo(t, "sw-settings-source", false)
|
||||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
|
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, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
|
||||||
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
|
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
|
// add another file whose name cannot match the specified path
|
||||||
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
|
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
|
||||||
Files: []*files_service.ChangeRepoFile{
|
Files: []*files_service.ChangeRepoFile{
|
||||||
@@ -215,8 +223,68 @@ jobs:
|
|||||||
err = pull_service.NewPullRequest(t.Context(), prOpts)
|
err = pull_service.NewPullRequest(t.Context(), prOpts)
|
||||||
assert.NoError(t, err)
|
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}))
|
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.NoError(t, err)
|
||||||
assert.NotEmpty(t, addFileToBranchResp)
|
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")
|
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)
|
url := test.RedirectURL(resp)
|
||||||
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
|
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}))
|
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1879,3 +1951,29 @@ jobs:
|
|||||||
runner.fetchNoTask(t)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,3 +241,27 @@ func TestGetFileHistoryNotOnMaster(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "1", resp.Header().Get("X-Total"))
|
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)
|
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)()
|
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}
|
p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
|
||||||
|
|
||||||
contentStore := lfs.NewContentStore()
|
contentStore := lfs.NewContentStore()
|
||||||
@@ -250,6 +255,7 @@ func TestAPILFSBatch(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, exist)
|
assert.True(t, exist)
|
||||||
|
|
||||||
|
// The object is linked to another repo owned by the same user.
|
||||||
repo2 := createLFSTestRepository(t, "lfs-batch2-repo")
|
repo2 := createLFSTestRepository(t, "lfs-batch2-repo")
|
||||||
storeObjectInRepo(t, repo2.ID, "dummy0")
|
storeObjectInRepo(t, repo2.ID, "dummy0")
|
||||||
|
|
||||||
@@ -266,11 +272,13 @@ func TestAPILFSBatch(t *testing.T) {
|
|||||||
br := decodeResponse(t, resp.Body)
|
br := decodeResponse(t, resp.Body)
|
||||||
assert.Len(t, br.Objects, 1)
|
assert.Len(t, br.Objects, 1)
|
||||||
assert.Nil(t, br.Objects[0].Error)
|
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)
|
meta, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
|
||||||
assert.NoError(t, err)
|
assert.Nil(t, meta)
|
||||||
assert.NotNil(t, meta)
|
assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
err = contentStore.Delete(p.RelativePath())
|
err = contentStore.Delete(p.RelativePath())
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ func testGetAttachment(t *testing.T) {
|
|||||||
{"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
|
{"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
|
||||||
{"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound},
|
{"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound},
|
||||||
{"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", 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 {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"gitea.dev/services/auth/source/oauth2"
|
"gitea.dev/services/auth/source/oauth2"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"xorm.io/builder"
|
"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) {
|
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,
|
assert.Contains(t,
|
||||||
htmlDoc.doc.Find(".ui.positive.message").Text(),
|
htmlDoc.doc.Find(".ui.positive.message").Text(),
|
||||||
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),
|
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/models/db"
|
||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
project_model "gitea.dev/models/project"
|
project_model "gitea.dev/models/project"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
project "gitea.dev/services/projects"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
@@ -365,3 +367,28 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
|
|||||||
assert.NotContains(t, issueIDs, issue17.ID)
|
assert.NotContains(t, issueIDs, issue17.ID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjects(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
|
||||||
|
_ = db.TruncateBeans(t.Context(), "project_issue", "issue_assignees")
|
||||||
|
_ = db.Insert(t.Context(),
|
||||||
|
&project_model.ProjectIssue{ProjectID: 1, IssueID: 1},
|
||||||
|
&project_model.ProjectIssue{ProjectID: 1, IssueID: 6},
|
||||||
|
)
|
||||||
|
_ = db.Insert(t.Context(),
|
||||||
|
&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 1},
|
||||||
|
&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 10},
|
||||||
|
&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 2},
|
||||||
|
&issues_model.IssueAssignees{IssueID: 6, AssigneeID: 2},
|
||||||
|
&issues_model.IssueAssignees{IssueID: 6, AssigneeID: 4},
|
||||||
|
)
|
||||||
|
assignees, err := project.LoadIssuesAssigneesForProject(t.Context(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, assignees, 4)
|
||||||
|
require.Equal(t, "user1", assignees[0].Name)
|
||||||
|
require.Equal(t, "user10", assignees[1].Name)
|
||||||
|
require.Equal(t, "user2", assignees[2].Name)
|
||||||
|
require.Equal(t, "user4", assignees[3].Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/modules/translation"
|
"gitea.dev/modules/translation"
|
||||||
@@ -39,11 +40,10 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st
|
|||||||
if draft {
|
if draft {
|
||||||
postData["draft"] = "1"
|
postData["draft"] = "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
req = NewRequestWithValues(t, "POST", link, postData)
|
req = NewRequestWithValues(t, "POST", link, postData)
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
resp = session.MakeRequest(t, req, http.StatusSeeOther)
|
assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes()))
|
||||||
|
|
||||||
test.RedirectURL(resp) // check that redirect URL exists
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) {
|
func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) {
|
||||||
@@ -253,3 +253,27 @@ func TestDownloadReleaseAttachment(t *testing.T) {
|
|||||||
session := loginUser(t, "user2")
|
session := loginUser(t, "user2")
|
||||||
session.MakeRequest(t, req, http.StatusOK)
|
session.MakeRequest(t, req, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEditReleaseAttachmentRejectsForbiddenRename(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.Release.AllowedTypes, ".zip")()
|
||||||
|
|
||||||
|
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9})
|
||||||
|
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: attachment.ReleaseID})
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
|
||||||
|
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
|
||||||
|
session := loginUser(t, repoOwner.Name)
|
||||||
|
req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/releases/edit/%s", repo.Link(), release.TagName), map[string]string{
|
||||||
|
"title": release.Title,
|
||||||
|
"content": release.Note,
|
||||||
|
"attachment-edit-" + attachment.UUID: "evil.exe",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
errMsg := test.ParseJSONError(resp.Body.Bytes()).ErrorMessage
|
||||||
|
assert.Equal(t, "This file cannot be uploaded or modified due to a forbidden file extension or type.", errMsg)
|
||||||
|
|
||||||
|
attachment = unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: attachment.ID})
|
||||||
|
assert.NotEqual(t, "evil.exe", attachment.Name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
import {addDelegatedEventListener, createElementFromAttrs} from '../utils/dom.ts';
|
||||||
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
||||||
import {POST} from '../modules/fetch.ts';
|
import {POST} from '../modules/fetch.ts';
|
||||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||||
@@ -247,9 +247,6 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
|
|||||||
`${seconds}s`, // for "Show seconds"
|
`${seconds}s`, // for "Show seconds"
|
||||||
);
|
);
|
||||||
|
|
||||||
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
|
|
||||||
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
|
|
||||||
|
|
||||||
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
|
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
|
||||||
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
|
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
|
||||||
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
||||||
@@ -391,9 +388,6 @@ function elStepsContainer(): HTMLElement {
|
|||||||
|
|
||||||
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
||||||
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
|
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
|
||||||
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
|
|
||||||
toggleElem(el, timeVisible.value[`log-time-${type}`]);
|
|
||||||
}
|
|
||||||
saveLocaleStorageOptions();
|
saveLocaleStorageOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +467,15 @@ async function hashChangeListener() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
||||||
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
|
<div
|
||||||
|
class="job-step-container"
|
||||||
|
ref="stepsContainer"
|
||||||
|
v-show="!isCallerJob && currentJob.steps.length"
|
||||||
|
:class="{
|
||||||
|
'log-line-show-timestamps': timeVisible['log-time-stamp'],
|
||||||
|
'log-line-show-seconds': timeVisible['log-time-seconds']
|
||||||
|
}"
|
||||||
|
>
|
||||||
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||||
<div
|
<div
|
||||||
class="job-step-summary"
|
class="job-step-summary"
|
||||||
@@ -681,8 +683,22 @@ async function hashChangeListener() {
|
|||||||
scroll-margin-top: 95px;
|
scroll-margin-top: 95px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-log-line .log-time-stamp,
|
||||||
|
.job-log-line .log-time-seconds {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line-show-timestamps .job-log-line .log-time-stamp {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line-show-seconds .job-log-line .log-time-seconds {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
||||||
.job-log-line .line-num, .log-time-seconds {
|
.job-log-line .line-num,
|
||||||
|
.job-log-line .log-time-seconds {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
color: var(--color-text-light-3);
|
color: var(--color-text-light-3);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -699,16 +715,16 @@ async function hashChangeListener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.job-log-line .log-time,
|
.job-log-line .log-time,
|
||||||
.log-time-stamp {
|
.job-log-line .log-time-stamp {
|
||||||
color: var(--color-text-light-3);
|
color: var(--color-text-light-3);
|
||||||
margin-left: 10px;
|
margin-left: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-step-logs .job-log-line .log-msg {
|
.job-step-logs .job-log-line .log-msg {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: break-spaces;
|
white-space: break-spaces;
|
||||||
margin-left: 10px;
|
margin-left: 12px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,30 +791,28 @@ async function hashChangeListener() {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-log-group .job-log-list .job-log-line .log-msg {
|
|
||||||
margin-left: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-log-group-summary {
|
.job-log-group-summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
list-style: none; /* hide the standard disclosure marker (Chrome, Edge, Firefox) */
|
||||||
display: list-item;
|
|
||||||
list-style: disclosure-closed inside;
|
|
||||||
padding-left: 58px; /* line-num gutter (48px) + log-msg margin (10px), so the marker sits in the content column */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-log-group[open] > .job-log-group-summary {
|
.job-log-group-summary::-webkit-details-marker { /* hide the disclosure marker on Safari */
|
||||||
list-style-type: disclosure-open;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-log-group-summary > .job-log-line {
|
.log-line-group .log-msg::before {
|
||||||
position: absolute;
|
content: "";
|
||||||
inset: 0;
|
display: inline-block;
|
||||||
z-index: -1; /* sit behind the disclosure marker */
|
vertical-align: middle;
|
||||||
overflow: hidden;
|
margin-top: -2.5px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
border-left: 6px solid var(--color-text-light-3);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-log-group-summary > .job-log-line .log-msg {
|
.job-log-group[open] .log-line-group .log-msg::before {
|
||||||
margin-left: 21px;
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import {assignElementProperty, type ElementWithAssignableProperties} from './com
|
|||||||
|
|
||||||
test('assignElementProperty', () => {
|
test('assignElementProperty', () => {
|
||||||
const elForm = document.createElement('form');
|
const elForm = document.createElement('form');
|
||||||
assignElementProperty(elForm, 'action', '/test-link');
|
expect(() => assignElementProperty(elForm, 'action', '/test-link')).toThrow();
|
||||||
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
|
assignElementProperty(elForm, 'url', '/test-url');
|
||||||
expect(elForm.getAttribute('action')).eq('/test-link');
|
expect(elForm.action).contains('/test-url'); // the DOM always returns absolute URL
|
||||||
|
expect(elForm.getAttribute('action')).eq('/test-url');
|
||||||
assignElementProperty(elForm, 'text-content', 'dummy');
|
assignElementProperty(elForm, 'text-content', 'dummy');
|
||||||
expect(elForm.textContent).toBe('dummy');
|
expect(elForm.textContent).toBe('dummy');
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ test('assignElementProperty', () => {
|
|||||||
_attrs: Record<string, string> = {};
|
_attrs: Record<string, string> = {};
|
||||||
setAttribute(name: string, value: string) { this._attrs[name] = value }
|
setAttribute(name: string, value: string) { this._attrs[name] = value }
|
||||||
getAttribute(name: string): string | null { return this._attrs[name] }
|
getAttribute(name: string): string | null { return this._attrs[name] }
|
||||||
|
nodeName = 'FORM';
|
||||||
}();
|
}();
|
||||||
assignElementProperty(elFormWithAction, 'action', '/bar');
|
assignElementProperty(elFormWithAction, 'url', '/bar');
|
||||||
expect(elFormWithAction.getAttribute('action')).eq('/bar');
|
expect(elFormWithAction.getAttribute('action')).eq('/bar');
|
||||||
|
|
||||||
const elInput = document.createElement('input');
|
const elInput = document.createElement('input');
|
||||||
|
|||||||
@@ -42,11 +42,20 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ElementWithAssignableProperties = {
|
export type ElementWithAssignableProperties = {
|
||||||
|
nodeName: string;
|
||||||
getAttribute: (name: string) => string | null;
|
getAttribute: (name: string) => string | null;
|
||||||
setAttribute: (name: string, value: string) => void;
|
setAttribute: (name: string, value: string) => void;
|
||||||
} & Record<string, any>;
|
} & Record<string, any>;
|
||||||
|
|
||||||
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
|
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
|
||||||
|
if (el.nodeName === 'FORM') {
|
||||||
|
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: a special case for Golang HTML template escaping.
|
||||||
|
// Golang HTML template only handles some "known" attribute names as URL (e.g.: when the name is "action" or contains "url")
|
||||||
|
// To prevent template developers from making mistakes like `data-modal-form.action="?k={{ValueWithSpecialChars}}" (no escaping),
|
||||||
|
// here we use `data-modal-form.url="?k={{ValueWithSpecialChars}}", then the value gets correctly escaped by Golang HTML template.
|
||||||
|
if (kebabName === 'action') throw new Error(`don't assign element property "action" by value, use "data-modal-form.url" instead`);
|
||||||
|
if (kebabName === 'url') kebabName = 'action';
|
||||||
|
}
|
||||||
const camelizedName = camelize(kebabName);
|
const camelizedName = camelize(kebabName);
|
||||||
const old = el[camelizedName];
|
const old = el[camelizedName];
|
||||||
if (typeof old === 'boolean') {
|
if (typeof old === 'boolean') {
|
||||||
@@ -59,7 +68,7 @@ export function assignElementProperty(el: ElementWithAssignableProperties, kebab
|
|||||||
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
|
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
|
||||||
el.setAttribute(kebabName, val);
|
el.setAttribute(kebabName, val);
|
||||||
} else {
|
} else {
|
||||||
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
|
// in the future, maybe we could introduce a better typing system if it is really needed
|
||||||
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
|
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +80,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
|||||||
// * Then, try to query '[name=target]'
|
// * Then, try to query '[name=target]'
|
||||||
// * Then, try to query '.target'
|
// * Then, try to query '.target'
|
||||||
// * Then, try to query 'target' as HTML tag
|
// * Then, try to query 'target' as HTML tag
|
||||||
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
|
// If there's a ".{prop-name}" part like "data-modal-input.value", the "input" element's "value" property will be set,
|
||||||
|
// the "prop-name" will be camel-cased to "propName" (e.g.: "data-modal-input.read-only" for "readOnly" property).
|
||||||
|
//
|
||||||
|
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: Form element's "action" property must be set by "data-modal-form.url"
|
||||||
|
// to make the template variables get correctly escaped in the URL.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const modalSelector = el.getAttribute('data-modal')!;
|
const modalSelector = el.getAttribute('data-modal')!;
|
||||||
const elModal = document.querySelector(modalSelector);
|
const elModal = document.querySelector(modalSelector);
|
||||||
|
|||||||
Reference in New Issue
Block a user