Compare commits

...

101 Commits

Author SHA1 Message Date
Giteabot
0b5dfdeeea fix(repo): do not check out existing repo content during auto-init (#40376) (#40383)
Backport #40376 by @bircni

Plain repository creation marked a new repository as ready before its
git data, hooks and initial commit existed, and auto-init cloned the new
repository to build its initial commit on top of whatever it already
contained.

- Auto-init builds the initial commit in a freshly initialized temp
repository instead of a clone
- New repositories are inserted as being created and marked ready once
initialized, as fork, template generation, adoption and migrations
already do

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-15 18:39:36 +00:00
bircni
6b5f406f81 fix(auth): limit failed TOTP attempts per account (#39993) (#40374)
Backport https://github.com/go-gitea/gitea/pull/39993

Gitea accepted unlimited wrong TOTP passcodes, so anyone holding an
account's password could enumerate the 6-digit code in minutes. This
allows 5 failed attempts per account, then refuses TOTP until 15 minutes
after the last one.

- The count lives in the cache, and the global lock spans the check,
validation and counting, so new sessions and concurrent requests cannot
widen it. With Redis, both are shared across instances
- Applies to every TOTP check: the web 2FA page, account recovery and
Basic auth with `X-Gitea-OTP` (API, git, packages)
- Only wrong or replayed passcodes count, server errors do not
- Scratch codes and WebAuthn stay usable as the recovery path while
locked
- Failures log `Failed authentication attempt for <user> from <ip>`,
which the fail2ban filter already matches

Co-authored-by: Sandiyo Christan <55909152+sandiyochristan@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-11-15 17:26:33 +00:00
bircni
bf2eb11ec1 fix(audit): record missing repository and content events (#40322) (#40390)
Backport https://github.com/go-gitea/gitea/pull/40322

Several API and background paths change repositories without writing the
audit event the web UI writes for the same change. This records the
existing actions on those paths, and webhook, secret and OAuth2
application events now also keep the impersonator.

Conflicts resolved for release/v28:
- The web project close/reopen handler uses
`NotFoundOrServerError`/`ServerError`, as `ServerErrorAuto` does not
exist on this branch.
- The repository webhook delete API keeps its documented 404 response.
- The notifier test keeps the release branch layout with the new cases
appended.

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: delvh <dev.lh@web.de>
2026-11-15 16:32:23 +00:00
bircni
b0088f060b fix(repo): limit the commit status fields returned by repo search (#40377) (#40384)
Backport https://github.com/go-gitea/gitea/pull/40377

The web repo search response embedded the whole commit status model
although the dashboard only uses its state and target URL. It now
returns just those two fields.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-15 16:28:28 +00:00
bircni
df961a3962 fix(api): do not allow renaming the Owners team (#40381) (#40387)
Backport https://github.com/go-gitea/gitea/pull/40381

The API accepted a new name for an organization's Owners team. The owner
team is looked up by its name, so the organization had no owner team
afterwards and its owners lost access to it.

The API now rejects the rename, as the web UI already does.

Fixes #40380
2026-11-15 13:41:13 +00:00
bircni
58df887de6 fix(issues): hide unreadable issue types in search and milestone views (#40192) (#40375)
Backport https://github.com/go-gitea/gitea/pull/40192

The repository issue search JSON route sits behind the issues reader
check only, and the milestone page is open to anyone who can read either
issues or pull requests, since the issue routes have to tolerate pull
request permissions. Neither handler narrows the issue type to what the
viewer may read: the search route takes `type=pulls` straight from the
query or lists both types when it is absent, and the milestone page
always lists both. A team member granted only the Issues unit on a
private repository can therefore fetch every pull request's title and
body as JSON and see pull requests on milestone pages, while a
pull-requests-only reader sees issues there. I noticed it while
comparing these routes with the API `ListIssues` handler and the web
issue suggestions endpoint, which both already narrow the type. The
narrowing now lives in one small helper used by all three handlers, with
the search route answering 404 for a requested type the viewer cannot
read, like the API does. A unit test covers both handlers with an
issues-only permission.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-15 10:05:10 +00:00
bircni
fff18237d5 fix(repo): do not reuse pull mirror credentials for a new host (#39991) (#40373)
Backport https://github.com/go-gitea/gitea/pull/39991

Changing a pull mirror address with an empty password field carried the
stored credentials over to the new address. The password is write-only,
so a repo admin who did not set up the mirror could send the owner's
token to a host they control, or pull another repository on the same
host that the token can read.

Stored credentials are now reused only when the address is unchanged,
like a changed username already drops the stored password. The API
cannot change the address and push mirrors never reuse credentials, so
both are unaffected.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-15 05:04:55 +00:00
Lunny Xiao
06a2286930 fix(git): list affected files NUL-separated so special names are not quoted (#40362)
Backport of the `GetAffectedFiles` fix from main (#40326) to v28.

`git diff --name-only` C-quotes paths containing characters such as `"`
or non-ASCII bytes, so they never matched protected file patterns. Use
`diff-tree -r -z` and split on NUL. Renames now report both old and new
paths.

Assisted-by: Codet:claude
2026-11-14 12:19:53 +00:00
silverwind
0c6645958b fix(api): hide org roles from non-members (#40042) (#40365)
Backport https://github.com/go-gitea/gitea/pull/40042

`GET /users/{username}/orgs/{org}/permissions` returned a user's team
roles to anyone who could see the org, revealing concealed members and
their roles. Now only the user, unrestricted org members and site admins
get the real answer, everyone else gets the non-member response:

- Outsiders get no roles, for public members too.
- Restricted members only get their own roles.
- Public-only tokens get none, and site admins need an `all`-scoped
token.

v28 lacks the doer token helpers from
https://github.com/go-gitea/gitea/pull/39690, so the check reads
`ctx.PublicOnly` and the request's token scope instead.

Assisted-by: Claude Code:claude-opus-5-5

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
2026-11-14 12:19:34 +00:00
Giteabot
660c4b1de6 fix(packages): accept OCI 1.1 container image names (#40363) (#40369)
Backport #40363 by @lazerg

The container registry rejected image names with `__` or runs of `-`
(like `a__b` or `a--b`) with `NAME_INVALID`, because the name check
still used the distribution-spec v1.0 grammar. This updates the pattern
to the v1.1 grammar, which docker and podman already accept.

Fixes: https://github.com/go-gitea/gitea/issues/40360

Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lazizbek Ergashev <lazerg2@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-14 12:18:43 +00:00
Giteabot
7210b4cc52 fix: correctly apply team admin permission to doer (#40353) (#40356)
Backport #40353 by @wxiaoguang

fix #40352

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-13 22:47:38 +00:00
Giteabot
87c509a405 fix(web): fix memory leak in dropdown ARIA patch (#40349) (#40364)
Backport #40349 by @silverwind

Dropdowns initialized without an options object shared the default
Fomantic templates, so the ARIA patch kept wrapping `templates.menu` on
the defaults and retained every dropdown ever created. This grows memory
on the Actions run list, which re-renders its dropdowns on each refresh.

Each dropdown now gets its own templates copy, which the ARIA patch
modifies in place.

Fixes: https://github.com/go-gitea/gitea/issues/40347

Assisted-by: Claude Code:claude-opus-5-5

Co-authored-by: silverwind <me@silverwind.io>
2026-11-13 22:45:39 +00:00
silverwind
95b69a4a2d fix(queue): bump levelqueue to v0.5.1 and remove workarounds (#40219) (#40350)
Backport https://github.com/go-gitea/gitea/pull/40219

levelqueue v0.5.1 fixes this upstream
(https://gitea.com/lunny/levelqueue/pulls/8), so this now only bumps the
dependency.

A push wrote "high" before the payload, so an interrupted push left an
id counted with no key. `LPop` then returned `ErrNotFound` without
advancing "low" and the wrapper retried forever: nothing popped, no
worker started, later items unreachable. All were only fixable by
clearing the queue, losing the backlog:
- https://github.com/go-gitea/gitea/issues/32006 — mail, 0 workers
- https://github.com/go-gitea/gitea/issues/28919 — mirror, "already in
queue"
- https://github.com/go-gitea/gitea/issues/33862 — archives hanging
- https://github.com/go-gitea/gitea/issues/33882 — self-healing request

v0.5.1 writes counter and payload in one batch, moves the counters past
missing keys, and repairs leftover unique-queue set entries, so affected
instances recover on their next pop.

No test: the behaviour lives in the dependency, which covers it in its
own `recover_test.go`.

Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: William Desportes <williamdes@wdes.fr>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-11-13 17:12:25 +00:00
silverwind
b60da6d876 enhance(websocket): improve client synchronization (#38847) (#40340)
Backport of https://github.com/go-gitea/gitea/pull/38847, which fixes
the stopwatch push flake in
https://github.com/go-gitea/gitea/actions/runs/43200377380/job/131122930118.

The stopwatch e2e test keeps its v28 flow, since main reworked it in a
separate change.

Assisted-by: Claude Code:claude-opus-5-5
2026-11-12 14:00:15 +00:00
Giteabot
78698abee0 fix: handle task list items checked with uppercase X (#40334) (#40339)
Backport #40334 by @SudhanshuMatrix

Markdown renders `[X]` as a checked task like `[x]`, but parts of the
frontend only recognize lowercase `x`.

- Clicking a `[X]` checkbox now unchecks it instead of failing.
- Pressing Enter on a `- [X]` line in the editor continues the list with
`- [ ] ` instead of dropping the checkbox.

Assisted-by: Claude Code:claude-opus-5-5

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-12 11:56:50 +00:00
silverwind
b24cd7839b fix: add missing checks to more handlers and services (#40131) (#40281)
Backport https://github.com/go-gitea/gitea/pull/40131

Several code paths skipped checks that their siblings already perform.

- Code comment replies are rejected when they target a review of another
pull request or another user's pending review
- OAuth2 refuses tokens for users who can't sign in, and introspection
reports their tokens and already used refresh tokens as inactive
- AGit proc-receive runs the same pull request permission and wiki
checks as pre-receive
- Repository transfer drops the repository's owner-level scoped workflow
source registrations, and registrations whose source the owner no longer
owns are ignored
- The web editor's base branch copy into a fork and the fork's upstream
divergence info require read access to the base repository
- LFS downloads require sign-in under strict `REQUIRE_SIGNIN_VIEW`, like
git smart HTTP
- Unknown AGit target branches are reported as an unexpected ref again

Assisted-by: Claude:claude-opus-5-5
2026-11-11 17:05:39 +00:00
silverwind
5c69d5710a fix(ui): consistent spacing between labels (#40261) (#40330)
Backport https://github.com/go-gitea/gitea/pull/40261

The issue reference popup showed its labels without any spacing, and the
other label lists each used their own gap, with wrapped rows spaced
differently from columns.

`.labels-list` is now a wrapping flex list with `--gap-inline` by
default, giving the popup, issue sidebar and project cards equal gaps in
both directions. Lists inline with text, in issue list titles and
timeline events, stay inline so their labels still wrap one by one.

<img width="600" alt="Issue reference popup, issue sidebar and issue
list labels, before and after"
src="https://github.com/user-attachments/assets/fc4f0b9e-2b9d-4060-a2fb-c1bd3b7391db"
/>

Assisted-by: Claude Code:claude-opus-5-5
2026-11-11 14:39:45 +00:00
Giteabot
7b466a8a66 chore(deps): update go toolchain directive to v1.27.4 (#40315) (#40318)
Backport #40315 by @GiteaBot

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go](https://go.dev/)
([source](https://redirect.github.com/golang/go)) | toolchain | patch |
`1.27.3` → `1.27.4` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend Renovate
CLI](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yMTAuMCIsInVwZGF0ZWRJblZlciI6IjQ0LjIxMC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->
2026-11-10 18:51:06 +00:00
Giteabot
f9d31d2f11 fix(api): lock and unlock issues as the signed-in user, not the repository owner (#40291) (#40298)
Backport #40291 by @lunny

`PUT/DELETE /repos/{owner}/{repo}/issues/{index}/lock` passed
`ctx.ContextUser` as the doer of `LockIssue` / `UnlockIssue`. In the
repo routes that is the owner of the repository, so on a repository of
an organization the lock and unlock timeline comments were created by
the organization instead of the user who did it. The web handler already
uses the signed-in user; this uses `ctx.Doer` as well.

Adds a subtest `DoerOnOrganizationRepo` to the existing
`TestAPILockIssue`: a member of an organization locks and unlocks an
issue of an organization repository, and the comments must be posted by
that member.

Found while testing audit logging of the issue lock events downstream.

_Authored by Codet (Claude)._

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-11-10 15:55:38 +00:00
Hyeonmin Park
24bceb9f6e fix(markup): correct character escaping at read buffer boundaries (#40209)
When a file, README or wiki page is rendered as markup, the HTML is read
through a 32 KiB buffer to escape invisible and ambiguous characters. A
multi-byte character cut by that buffer right after a tag was shown as
broken characters, e.g. `<p>보기</p>` became `<p>���기</p>`. Such a part is
now kept for the next read. Two related cases are fixed as well:

- Invalid bytes right before a tag are marked as broken instead of
passing through raw as part of the tag.
- A BOM split across reads is kept instead of being escaped as
invisible.

To reproduce, view a Markdown file with 32734 `A` characters, a blank
line and `보기`, which puts `보` across the first 32 KiB of the rendered
HTML:
[split-rune-after-tag.md](https://github.com/user-attachments/files/34361104/split-rune-after-tag.md)

https://github.com/go-gitea/gitea/pull/39937 replaces this reader with a
single in-memory pass. This small fix can be backported to the release
branches.

Assisted-by: Claude Code:claude-opus-5-5

----

| Before (2ed6a6f61b) | After
(2a1d09755f) |
| --- | --- |
|
![image](https://github.com/user-attachments/assets/d4f79a8a-17bd-4b7f-b853-1d1a7241dc9b)
|
![image](https://github.com/user-attachments/assets/d4b8b3dd-e28e-44dd-99de-d250ee4c282c)
|

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-09 16:36:34 +00:00
Giteabot
df01835a83 fix: don't log network error in console when loading issue popup (#40288) (#40294)
Backport #40288

* fix #40286

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-09 06:13:17 +00:00
silverwind
8e5585c5b9 fix(deps): update clippie to 4.3.1 (#40293)
Update clippie to 4.3.1, which fixes copy buttons in browsers that have
`navigator.clipboard.write` but no `ClipboardItem`, such as Pale Moon.
Text is now copied through `writeText`, with a copy event as fallback.
The same update is part of https://github.com/go-gitea/gitea/pull/40292
on main.

Fixes: https://github.com/go-gitea/gitea/issues/40287

Assisted-by: Claude Code:claude-opus-5-5
2026-11-09 05:59:36 +00:00
Giteabot
7d73ce020e fix: paste copied code as text, not as a markdown table (#40053) (#40280)
Backport #40053 by @silverwind

Copying lines from a diff, file view, blame, code search or code preview
and pasting them into a comment on Gitea or GitHub produced a markdown
table with escaped pipes and stripped indentation. Both use
`@github/paste-markdown`, which converts copied HTML tables unless they
carry `data-paste-markdown-skip`, as GitHub's own code tables do.
Gitea's code tables now set it too.

The test is a bit more complicated because of a upstream bug which
causes `paste-markdown` to insert pasted content twice, once
https://github.com/github/paste-markdown/pull/128 is merged and
released, the negative assertion could be removed.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-08 10:02:48 +00:00
Giteabot
f357a03049 fix(pull): don't add a timeline entry for pending API reviews (#40260) (#40272)
Backport #40260 by @lazerg

Pending reviews created through the API went through `SubmitReview`,
which added a timeline entry and sent notifications before the review
was submitted, so submitting it later showed the review twice.

- Pending reviews skip the timeline entry and notifications until
submitted, like on GitHub
- `CreatePullReview` rejects comments without a `path` or a positive
line with 422, like GitHub
- Already duplicated reviews stay as they are

Fixes https://github.com/go-gitea/gitea/issues/40259

Assisted-by: Claude Code:claude-opus-5-5

Co-authored-by: Lazizbek Ergashev <lazerg2@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-07 15:36:19 +00:00
Giteabot
53cf53cbbc fix: add missing checks to several handlers and services (#40120) (#40268)
Backport #40120 by @silverwind

Several code paths skipped checks their siblings perform, or cleaned up
more than they validated.

- Official team review requests now count, and removing one no longer
marks an unrelated review official
- Release edits only delete files of validated attachments
- Rejected container uploads no longer delete an existing blob
- Attachment removal only removes the uploader's own pending uploads
- OpenID sign-in checks the allow and block lists against the normalized
identifier
- HTTP git access is rejected while a repository is being created, like
SSH

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-07 10:51:18 +00:00
Giteabot
ba591d2d5a fix(repo): mark repository as non-empty before git push returns (#40102) (#40266)
Backport #40102 by @Zettat123

The post-receive hook syncs pushed branches to the database, but
`is_empty` and the default branch of an empty repository were only
updated later by the `push_update` queue. Until then the API treated the
repository as empty (branch list `null`, `GET /branches/{branch}` 500),
and the queue overwrote the default branch chosen by the hook or set via
the API.

`SyncBranchesToDB` now initializes an empty repository and repairs a
default branch missing in git in its locked transaction, so the state is
final when `git push` returns. The push queue and web file edits retry
it while the repository is still empty.

- The default branch is the configured one if it exists, else `main`,
else `master`, else the first branch by name, like GitLab. HEAD follows
it.
- A first push of several branches no longer depends on push order. The
push that initializes a repository still prints no pull request hints,
like GitHub.

---------

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-07 04:26:04 +00:00
silverwind
317db14701 fix: don't roll back branches on out-of-order post-receive hooks (#40110) (#40255)
Backport https://github.com/go-gitea/gitea/pull/40110

The post-receive hook wrote each push's new commit into the branch
table. Each push runs its own hook after the ref is updated, so with
pushes in close succession, like CI pushing back or a quick force-push,
a slower first hook can finish last. The table then keeps the older
commit, or marks a re-created branch as deleted.

- The sync reads each pushed branch's state from git under a repo-scoped
lock
- Deletions go through the same sync, so a re-created branch stays
active
- A branch that fails to sync is logged and skipped instead of aborting
the batch

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-11-07 02:59:15 +00:00
silverwind
2f40e966f7 ci: update giteabot to v1.5.1, label conflict-free backports (#40247) (#40248)
Backport https://github.com/go-gitea/gitea/pull/40247

Release branch pushes run this branch's workflow.

_Written by Claude (Opus 5.5)._
2026-11-06 08:00:23 +00:00
Giteabot
88e1e4ccd7 fix: upgrade Alpine base packages in Docker images (#40252) (#40253)
Backport #40252 by @silverwind

The `alpine:3.24` base image lags behind fixes published to the Alpine
repository.

Both images now run `apk upgrade` before `apk add`. Packages from `apk
add` are already current, so this only affects the preinstalled base
packages.

_Written by Claude (Opus 5.5)._

Co-authored-by: silverwind <me@silverwind.io>
2026-11-06 07:31:05 +00:00
Giteabot
e847d6a9c6 fix(deps): update module github.com/andybalholm/brotli to v1.2.7 [security] (#40246) (#40249)
Backport #40246 by @GiteaBot

This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[github.com/andybalholm/brotli](https://redirect.github.com/andybalholm/brotli)
| `v1.2.1` → `v1.2.7` |
![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fandybalholm%2fbrotli/v1.2.7?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fandybalholm%2fbrotli/v1.2.1/v1.2.7?slim=true)
|

---

### Excessive memory allocation when decompressing crafted streams in
github.com/andybalholm/brotli
BIT-brotli-2026-111497 / BIT-dotnet-2026-111497 /
BIT-dotnet-sdk-2026-111497 / BIT-node-2026-111497 /
BIT-node-min-2026-111497 / BIT-powershell-2026-111497 /
[CVE-2026-111497](https://nvd.nist.gov/vuln/detail/CVE-2026-111497) /
[GHSA-9v8h-qqgm-4m35](https://redirect.github.com/advisories/GHSA-9v8h-qqgm-4m35)
/
[GHSA-c9gr-gmg4-2vqv](https://redirect.github.com/advisories/GHSA-c9gr-gmg4-2vqv)
/ [GO-2026-6888](https://pkg.go.dev/vuln/GO-2026-6888) / PYSEC-2026-4322
/ PYSEC-2026-4323

<details>
<summary>More information</summary>

#### Details
In github.com/andybalholm/brotli before v1.2.7, the decompressor
allocates memory for Huffman tables and block-type trees on every
meta-block header without bounding the cumulative allocations relative
to the input size. A small crafted brotli stream containing many empty
meta-blocks with new Huffman trees can trigger hundreds of megabytes of
allocations per kilobyte of input, leading to denial of service.

#### Severity
Unknown

#### References
-
[https://github.com/andybalholm/brotli/security/advisories/GHSA-c9gr-gmg4-2vqv](https://redirect.github.com/andybalholm/brotli/security/advisories/GHSA-c9gr-gmg4-2vqv)
-
[33c2a98842)
-
[https://github.com/andybalholm/brotli/releases/tag/v1.2.7](https://redirect.github.com/andybalholm/brotli/releases/tag/v1.2.7)
-
[0ed4268a44)

This data is provided by
[OSV](https://osv.dev/vulnerability/GO-2026-6888) and the [Go
Vulnerability Database](https://redirect.github.com/golang/vulndb)
([CC-BY 4.0](https://redirect.github.com/golang/vulndb#license)).
</details>

---

### Release Notes

<details>
<summary>andybalholm/brotli (github.com/andybalholm/brotli)</summary>

###
[`v1.2.7`](https://redirect.github.com/andybalholm/brotli/compare/v1.2.6...v1.2.7)

[Compare
Source](https://redirect.github.com/andybalholm/brotli/compare/v1.2.6...v1.2.7)

###
[`v1.2.6`](https://redirect.github.com/andybalholm/brotli/compare/v1.2.5...v1.2.6)

[Compare
Source](https://redirect.github.com/andybalholm/brotli/compare/v1.2.5...v1.2.6)

###
[`v1.2.5`](https://redirect.github.com/andybalholm/brotli/compare/v1.2.4...v1.2.5)

[Compare
Source](https://redirect.github.com/andybalholm/brotli/compare/v1.2.4...v1.2.5)

###
[`v1.2.4`](https://redirect.github.com/andybalholm/brotli/compare/v1.2.3...v1.2.4)

[Compare
Source](https://redirect.github.com/andybalholm/brotli/compare/v1.2.3...v1.2.4)

###
[`v1.2.3`](https://redirect.github.com/andybalholm/brotli/compare/v1.2.2...v1.2.3)

[Compare
Source](https://redirect.github.com/andybalholm/brotli/compare/v1.2.2...v1.2.3)

###
[`v1.2.2`](https://redirect.github.com/andybalholm/brotli/compare/v1.2.1...v1.2.2)

[Compare
Source](https://redirect.github.com/andybalholm/brotli/compare/v1.2.1...v1.2.2)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend Renovate
CLI](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xOTAuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE5MC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->
2026-11-06 03:05:25 +00:00
Giteabot
d0e8294f46 fix(packages): return empty Chef dependencies instead of null (#40237) (#40242)
Backport #40237 by @Shadow0Bit

Fixes #40230

Cookbooks without dependencies are served with `"dependencies": null`
from the universe and version metadata endpoints. This happens because
`omitempty` drops the empty map when the metadata is stored. Chef
clients iterate over this value and Policyfile resolution crashes.

Supermarket returns `{}` for these cookbooks, Gitea now does too. The
fix checks whether dependencies are `nil` while building response,
returns an empty map if true. This way cookbooks that are already stored
are covered without a re-upload.

Tested with the Chef integration test and `chef-cli install`.

Assisted by Claude Code (claude-opus-5-5).

Co-authored-by: rafał przypek <58443512+Shadow0Bit@users.noreply.github.com>
2026-11-05 19:03:52 +00:00
wxiaoguang
b9a99f0f3e fix: allow clearing the full name in profile settings (#40186) (#40191)
backport #40186

Co-authored-by: silverwind <me@silverwind.io>
2026-11-04 20:33:38 +00:00
Giteabot
4f536f1513 fix(web): build issue popup request from parsed link (#40207) (#40225)
Backport #40207 by @silverwind

The issue reference popup built its `/info` request from the raw link
path, so a link ending in `/owner/repo/issues/N` queried an unrelated
location.

It now builds the request from the owner, repo, type and index parsed
from the link, with `parseIssueHref` anchored to the pathname under
`appSubUrl`.

Co-authored-by: silverwind <me@silverwind.io>
2026-11-04 21:02:45 +01:00
Giteabot
db5df0494a fix(actions): evaluate job if: against needs finished in the same pass (#40126) (#40221)
Backport #40126 by @silverwind

The job emitter resolves a dependency chain in several rounds in memory,
but a job's `if:` reads its needs' results from the database, where a
need skipped or failed earlier in the same pass was still blocked. A job
with `if: always() && needs.build.result == 'skipped'` was therefore
skipped instead of started.

Finished statuses are now persisted at the end of each round, which also
replaces the separate handling of skipped and failed reusable workflow
callers.

Co-authored-by: silverwind <me@silverwind.io>
2026-11-04 19:17:17 +00:00
silverwind
5aea433c71 fix(api): don't panic on null milestone due_on (#40171)
On release/v28, `PATCH /repos/{owner}/{repo}/milestones/{id}` with
`"due_on": null`, the documented way to remove a deadline, returns HTTP
500. The backport https://github.com/go-gitea/gitea/pull/39921 changed
`EditMilestoneOption.Deadline` to `optional.Option[*string]`, which
decodes `null` as a slice holding a nil pointer, and the go-chi/binding
validator panics on that element (`reflect: call of
reflect.Value.Interface on zero Value`).

The field has no binding rules, so tag it `form:"-"` to skip validation.
main is unaffected since https://github.com/go-gitea/gitea/pull/39528
moved the binding into Gitea.
2026-11-04 06:08:08 +00:00
Giteabot
a67b72e57b fix(notifications): avoid deadlock when creating issue notifications concurrently (#40166) (#40203)
Backport #40166 by @perfectra1n

Concurrent `notification-service` workers can deadlock in the
transaction for-loop.

The transaction is useless, so it is removed to avoid deadlock.

Co-authored-by: Jon Fuller <jonfuller2012@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-04 00:20:19 +00:00
Giteabot
a4cad57174 fix(repo): avoid deadlock when updating language stats concurrently (#40167) (#40202)
Backport #40167 by @perfectra1n

The `repo_stats_update` queue only deduplicates waiting items, so
several workers can index the same repository at once, then
`UpdateLanguageStats` can lead to transaction deadlock.

Use a global lock to avoid concurrent updates, and also helps
https://github.com/go-gitea/gitea/pull/39342 to avoid duplicate updates.

Co-authored-by: Jon Fuller <jonfuller2012@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-03 17:02:11 +00:00
Giteabot
ad8fa1770a fix(actions): make Actions API work with repo admin permission (#40174) (#40180)
Backport #40174

* Fix #40173

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-03 00:24:55 +00:00
Giteabot
8fb1547a4c fix(web): read the wiki blob excerpt from the wiki repository (#40165) (#40178)
Backport #40165 by @hktitof

The "load more lines" button in wiki diffs returned 404. `ExcerptBlob`
loads the commit from the wiki repo but read the blob from
`ctx.Repo.GitRepo`, a regression from
https://github.com/go-gitea/gitea/pull/38464.

Read the blob from the repo the commit was loaded from.

Co-authored-by: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com>
Co-authored-by: Abdel <hktitof@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-02 10:35:23 -08:00
silverwind
fb79898c28 fix(api): return language of starred and watched repositories (#40127) (#40157)
Backport https://github.com/go-gitea/gitea/pull/40127

The starred and watched repository API lists converted repositories
without loading their attributes, so `language` was always empty. They
are now loaded in one batch, owners included.

Three of these handlers also continued after an internal error and wrote
a second response body, they now return.
2026-11-01 09:05:28 -08:00
Giteabot
96b2997c06 ci: use GitHub merge queue (#40130) (#40162)
Backport #40130 by @bircni

Requiring branches to be up to date reruns the full CI matrix on every
waiting PR after each merge, which saturates our 20 concurrent jobs. A
merge queue still tests against the latest `main`, but once per batch.

This runs the PR workflows on `merge_group` and reports `giteabot/lgtm`
on queue commits, since GiteaBot only sets it on PR heads. Mergers queue
PRs with "Merge when ready" instead of `reviewed/wait-merge`, so
giteabot no longer updates branches. Nothing changes until the
`merge-queue` ruleset is enabled and "Require branches to be up to date"
is turned off. Backport to `release/v28` before the ruleset covers it.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-01 10:40:02 +00:00
Giteabot
4f5a3e13e5 fix(repo): don't get stuck generating contributor stats (#40125) (#40156)
Backport #40125 by @silverwind

A failed contributor stats generation never released its lock, so after
the cached error expired, the contributors and code frequency pages kept
saying stats are being generated until a restart. A generation
outlasting the 5 second wait also leaked its goroutine on an unread
channel.

The lock is now claimed atomically and released together with the
completion signal when generation ends, and a cache miss after the wait
counts as still generating.

Co-authored-by: silverwind <me@silverwind.io>
2026-11-01 01:20:24 -07:00
bircni
c50d5fc20d fix: list bot users in gitea admin user list and admin users API (#40060) (#40147)
Backport #40060 by @soujasK

Fixes https://github.com/go-gitea/gitea/issues/40056

The admin web UI lists every account type except organizations, but
`gitea admin user list` and `GET /api/v1/admin/users` only returned
individual users. That hid bots created for CI, even though the CLI and
the admin API can both create and manage them.

- The admin web UI, the admin API and the CLI share one type list:
individual, reserved, bot and remote accounts.
- `gitea admin user list` (and `--admin`) gains a `Type` column showing
`User`, `Bot`, `Reserved` or `Remote`.

Signed-off-by: Soujas K <kudchadkarsoujas@gmail.com>
Co-authored-by: soujasK <121026960+soujasK@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-01 07:27:30 +00:00
silverwind
dfdb0438ad fix(auth): only auto-link the account the external identity points to (#40121) (#40137)
Backport https://github.com/go-gitea/gitea/pull/40121

With `[oauth2_client].ACCOUNT_LINKING = auto`, the link account sign-up
form linked the external identity to an existing account matching the
submitted user name or email and signed in as it. Automatic linking now
only happens when the existing user matches the external identity,
otherwise the linking login page asks for the account's password.

`release/v28` lacks `TestOAuth2AvatarFromPicture`, so only the new test
is added.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-31 22:33:10 +00:00
Giteabot
7868ade8f7 perf(pull): load the repository once for all commit statuses (#39930) (#40135)
Backport #39930 by @lunny

Pages showing commit statuses loaded the repository of every status
separately, so a PR with ~75 statuses ran ~75 identical repository
queries per merge box render (about 3.4s in a production trace).
`statusRepoCanReadActions` only loaded the repository inside the
permission cache loader, which never runs once `RepoAssignment` has
published the permission.

- `CommitStatus.loadRepository` resolves through the request context
cache
- `statusRepoCanReadActions` loads the repository before the permission
lookup
- `RepoAssignment` publishes its repository to the context cache next to
the permission
- `GetCommitActionsStatusMap` drops its own repository map

Queries per request with 50 statuses (45 Actions, 5 external) on the
head commit, SQLite:

| Page | Repository queries | Total queries |
|---|---|---|
| PR view | 61 → 11 | 157 → 107 |
| Commit | 6 → 5 | 24 → 23 |
| Branches | 6 → 5 | 33 → 32 |
| Commits | 6 → 5 | 23 → 22 |
| Repo home | 8 → 7 | 35 → 34 |
| `/pulls` | 15 → 15 | 37 → 37 |

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 19:12:36 +00:00
Giteabot
67904da6a5 perf: speed up repository issue and pull request lists (#40114) (#40132)
Backport #40114 by @silverwind

The repository issue and pull request lists filter `access` by
`repo_id`, which no index covers, so every load scans the whole table.
Issue counts on larger repositories merge three single-column indices,
and the API pull request list loads the latest reviews with `id IN
(SELECT max(id) ... GROUP BY ...)`, which MySQL runs as a scan of every
review, about 1.1s on
https://gitea.com/api/v1/repos/gitea/gitea-mcp/pulls?limit=1.

- Index `access.repo_id`
- Index `team_repo.repo_id`
- Replace the single `repo_id`, `is_closed` and `is_pull` indices with
one `issue` index on `(repo_id, is_closed, is_pull)`
- Join the latest review ids instead of `id IN (subquery)`

Median times in ms on MySQL 8.4 with tables sized like gitea.com (1M
access rows, 500k issues, 200k reviews), for a copy of
https://gitea.com/gitea/gitea-mcp and a repository with 9.7k issues:

| Page | Before | After |
|---|---|---|
| `/{owner}/{repo}/issues` | 107 | 40 |
| `/{owner}/{repo}/pulls` | 106 | 32 |
| `/{owner}/{repo}/pulls?state=closed` | 153 | 35 |
| `/{owner}/{repo}/issues/{index}` | 141 | 60 |
| `/{owner}/{repo}/issues`, 9.7k issues | 303 | 53 |
| `/api/v1/repos/{owner}/{repo}/pulls?limit=50` | 180 | 46 |
| `/api/v1/repos/{owner}/{repo}/pulls?limit=1` | 137 | 16 |
| `/api/v1/repos/{owner}/{repo}/issues`, 9.7k issues | 248 | 43 |

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-31 18:24:15 +01:00
Giteabot
6f5d66f6dd fix(pull): keep review body in sync when its comment is edited (#40058) (#40133)
Backport #40058 by @silverwind

A review's text is stored on both the review and its timeline comment,
but editing it in the web UI or through the issue comment API only
updated the comment. The reviews API kept returning the old body
afterwards.

Extracted out of https://github.com/go-gitea/gitea/pull/39865.

*Written by Claude.*

Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 18:21:03 +01:00
Giteabot
a1c56dd96a fix(projects): hide linked pull requests the viewer cannot read (#40016) (#40134)
Backport #40016 by @basavaraj-sm05

Project board cards list the pull requests that reference their issue,
but loaded them without checking the viewer's access. A private pull
request mentioning a public issue showed its title, number and state on
that issue's board to anyone who can view the board, anonymous visitors
included.

Repository and organization boards now skip linked pull requests in
repositories where the viewer cannot read pull requests, matching how
the issue page already hides such references.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-31 18:14:26 +01:00
silverwind
5a3f06430d fix: harden context user access scope (#39945) (#40115)
Backport https://github.com/go-gitea/gitea/pull/39945

API tokens and Actions task tokens could see data beyond their scope
through user-scoped API routes and web profile pages.

- Actions task tokens authenticate as a shared system user with a
negative ID, which org, project and SSH/GPG key filters treated as "no
owner filter". These filters now check `!= 0`, so system users own
nothing. The `/api/v1/user` routes except `GET /api/v1/user` only accept
individual and bot accounts, and repo search no longer skips access
checks for the owner.
- `/{username}` only accepts tokens for RSS/Atom feeds. HTML profile and
org pages render as anonymous for token requests.
- User and org feeds require `read:user` or `read:organization` from
tokens, the same way repo feeds require `read:repository`.

`release/v28` lacks https://github.com/go-gitea/gitea/pull/39967, so
`/{username}` enables basic auth only for feed requests through a
middleware. The profile page token tests from
https://github.com/go-gitea/gitea/pull/39853 are folded into
`TestFeedUser`, because profile pages no longer accept tokens.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-31 17:40:49 +01:00
bircni
6c8eaaf9e7 fix(actions): don't pick up jobs of repos with Actions disabled (#40106) (#40122)
Backport https://github.com/go-gitea/gitea/pull/40106

Repository-level and instance-level runners still picked up waiting jobs
of repositories with Actions disabled.
https://github.com/go-gitea/gitea/pull/30331 only covered owner-level
runners, by filtering them out in SQL.

Pickup now checks the Actions unit of each job it is about to claim, for
every runner scope, and cancels the job instead when Actions is
disabled, also honoring `DISABLED_REPO_UNITS`. Disabling Actions
therefore cancels its queued jobs once a matching runner polls, instead
of leaving them waiting until `ABANDONED_JOB_TIMEOUT`.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 15:56:26 +00:00
Giteabot
c9e1805cc9 fix(pull): respect max approvers in default merge message (#40118) (#40119)
Backport #40118 by @lazerg

The approver limit was checked before writing each `Reviewed-by:` line
but compared with `>`, so the default merge message got one more trailer
than `DEFAULT_MERGE_MESSAGE_MAX_APPROVERS` allows (2 with a limit of 1,
11 with the default 10). It now stops once the limit is reached.

Fixes https://github.com/go-gitea/gitea/issues/40116

Assisted-by: Claude Code:claude-opus-5-5

Co-authored-by: Lazizbek Ergashev <lazerg2@gmail.com>
2026-10-31 15:07:42 +00:00
Giteabot
5ba69f9af4 fix(actions): run review-triggered workflows on the pull request head (#40099) (#40117)
Backport #40099 by @silverwind

Runs triggered by pull request reviews took their ref and commit from
the review's `commit_id` instead of the pull request. They now use the
pull request head like other pull request events, and their commit
status is set on the commit that ran.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 13:13:39 +00:00
Giteabot
e395b4d9dc fix(mail): preserve outer spacing in notification emails (#40104) (#40111)
Backport #40104 by @0xJacky

Remove the `body {margin: 0; padding: 0}` reset from
https://github.com/go-gitea/gitea/pull/38396, which left HTML mails
flush against the edge in Apple Mail and Thunderbird. The devtest
preview stops injecting its own padding, so it matches delivered mails.

These clients render the mail as a document and apply the default 8px
body margin again, as with GitHub and GitLab notifications. Webmail
clients replace `<body>` and keep their own inset.

Fixes: https://github.com/go-gitea/gitea/issues/40103
Assisted-by: Codex:GPT-6.1, Claude:Opus 5.5
Co-authored-by: Jacky <jacky-943572677@qq.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 09:29:48 +00:00
Giteabot
308dd93484 perf: speed up the issue and pull request dashboards and fix table indices (#40001) (#40096)
Backport #40001 by @silverwind

The dashboard counts ran queries whose cost grew with the whole instance
instead of the viewing user. On https://gitea.com/pulls the two review
counts alone take about 1.4s per page load.

- Check the owner of the already joined repository with `EXISTS` instead
of listing every public repository and public user
- Hide superseded review requests with `NOT EXISTS` instead of grouping
every review on the instance
- Skip the review counts on `/issues`, which never shows them
- Index `team_user.uid` for permission and team lookups by user
- Fix indices for table `watch`, `package_property` and `action_run`,
fix #40090

Median page times in ms, before → after, on a synthetic dataset sized
like gitea.com (175k users, 40k repos, 120k issues, 1M comments):

| Page | SQLite | PostgreSQL 17 | MySQL 8.4 | MariaDB 13 |
|---|---|---|---|---|
| `/pulls` | 901 → 56 | 330 → 156 | 189 → 124 | 192 → 218 |
| `/pulls?type=review_requested` | 1797 → 66 | 557 → 254 | 307 → 245 |
604 → 499 |
| `/pulls?type=reviewed_by` | 1646 → 118 | 537 → 316 | 515 → 265 | 528 →
682 |
| `/pulls?type=mentioned` | 1403 → 46 | 426 → 119 | 165 → 116 | 179 →
179 |
| `/issues` | 923 → 33 | 293 → 39 | 178 → 65 | 140 → 41 |
| `/api/v1/repos/issues/search` (anonymous) | 870 → 110 | 144 → 104 |
123 → 87 | 170 → 193 |

MariaDB checks the owner per row on long scans across all public
repositories, which makes a few pages slower there.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-31 07:38:45 +00:00
Giteabot
3162dc4cbf fix(api): answer 409 when repository files already exist on disk (#40095) (#40098)
Backport #40095 by @hktitof

when a repository directory exists on disk without a database row, for
example after a failed delete, POST /user/repos answered 500 with an
empty message

the migrate endpoint already answers 409 for this error and the fork API
got the same treatment in https://github.com/go-gitea/gitea/pull/39909,
so the create handler now maps ErrRepoFilesAlreadyExist to 409 too. this
covers POST /user/repos, POST /orgs/{org}/repos and the admin user route
since they all go through CreateUserRepo

the integration test creates the leftover directory and asserts 409, on
master it gets 500. runs on sqlite as usual

this also touches routers/api/v1/repo/repo.go, which several open PRs
modify, but none of them changes this error mapping

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Signed-off-by: hktitof <hktitof@users.noreply.github.com>
Co-authored-by: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com>
Co-authored-by: hktitof <hktitof@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 22:26:30 +01:00
silverwind
51736a9706 fix(pull): refuse review comments on locked pull requests (#39960) (#40084)
Backport https://github.com/go-gitea/gitea/pull/39960

Locking a pull request stops users without write access from commenting,
but the review paths never checked the lock, so any signed-in user could
still post reviews, code comments and replies on a locked pull request.

The cherry-pick conflicted on the `repo-diff.ts` imports, because
`release/v28` has no `DiffFileTreeLocale`.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 19:36:51 +00:00
silverwind
40079c7c09 fix(ui): ignore code line anchors below 1, show JS errors in vite dev mode (#39432) (#40087)
Backport https://github.com/go-gitea/gitea/pull/39432

- A `#L0` URL hash made `selectRange` index the line list at -1 and
throw inside `initRepoCodeView`, which aborted every init function after
it on the page. Line numbers below 1 now select nothing.
- The JS error filter only accepted stacks under the asset prefix, but
the vite dev server serves the sources from `/web_src/`, so dev mode
dropped every error without showing a banner. Outside production, the
filter now also accepts stacks from `/web_src/`.
2026-10-30 19:07:17 +00:00
Giteabot
2b11ba861f fix(repo): honor ALLOW_FORK_INTO_SAME_OWNER for organizations (#40025) (#40094)
Backport #40025 by @somaz94

With `[repository] ALLOW_FORK_INTO_SAME_OWNER = true` a user can fork
their own repository, but an organization still cannot fork a repository
it owns. The fork button check and the owner list on the fork page
compared the organization with the repository owner directly instead of
asking `CanUserForkBetweenOwners`, and the walk over parent repositories
dropped the owning organization whenever the setting was on.
Organizations now follow the setting the same way users already do, and
nothing changes when it is off.

Fixes https://github.com/go-gitea/gitea/issues/40021
Assisted-by: Claude Code:claude-opus-5-5
Co-authored-by: somaz <112675579+somaz94@users.noreply.github.com>
2026-10-30 18:06:59 +00:00
silverwind
9ad9f99060 fix(user): answer the follow of a bot user with the profile card (#39980) (#40086)
Backport https://github.com/go-gitea/gitea/pull/39980

Following a bot user from its profile succeeded but answered with an
error, because the follow handler only rendered a response for
individuals and organizations.

The cherry-pick conflicted in `OwnerProfile`, which still serves the
user feeds on `release/v28`.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 07:30:13 +00:00
silverwind
0964106988 fix: add missing access checks and size limits (#39942) (#40085)
Backport https://github.com/go-gitea/gitea/pull/39942

Several handlers and parsers skipped checks that their siblings already
perform.

- Reject issue, comment, label, milestone and release mutations in
archived repositories via the API with one guard per route group,
matching the web UI
- Hide timeline references and dependencies pointing at issues the doer
cannot read, in the web UI and the API, including pull requests of the
same repository without pull request access
- Reject workflow runs with more than `MaxJobNumPerRun` jobs, including
inline reusable workflow jobs, and show rejected pushes as invalid
workflow runs
- Bound how much of a Helm chart archive is inflated while looking for
`Chart.yaml`

`release/v28` lacks two helpers this needs, so they are included:
`insertInvalidWorkflowRun`, split out of `handleInvalidWorkflows` by
https://github.com/go-gitea/gitea/pull/39480, and
`packages.NewLimitedReader` from
https://github.com/go-gitea/gitea/pull/39366.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 08:08:32 +01:00
silverwind
8cf0f9798f fix(feed): require code read access for the repo home branch feed (#39964) (#40067)
Backport https://github.com/go-gitea/gitea/pull/39964

The repo home URL served the branch or file commit feed for RSS or Atom
`Accept` headers without the code unit check of the explicit
`/rss/branch/*` routes, exposing commit messages and authors to users
without code access.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
2026-10-29 10:04:51 +00:00
Giteabot
5d737d660b perf(actions): is_reusable_caller index makes SQLite read nearly every job row (#40055) (#40064)
Backport #40055 by @myers

On SQLite, the `is_reusable_caller = false` filters (e.g. the admin job
queue page) are planned from `IDX_action_run_job_is_reusable_caller`,
which matches almost every row, so nearly the whole `action_run_job`
table is read. `ANALYZE` does not help: `sqlite_stat1` only keeps
average rows per key.

No query needs that index. `is_reusable_caller = true` is only used by
`expandedCallerCond`, always together with `repo_id` and
`concurrency_group`. Their plans use other indexes on SQLite, PostgreSQL
17 and MySQL 8.4 (PostgreSQL is slightly slower with it).

This removes the index tag. `SyncAllTables` drops the existing index on
the next start, so no migration is needed.

Job queue count on SQLite, before: a search on the `is_reusable_caller`
index; after: a `MULTI-INDEX OR` on the `status` and `pickup` indexes.
On an instance with about 7,000 jobs the admin job queue page went from
`Page: 784ms` to `39ms` (measured with an earlier revision of this PR
that gave the same plan).

Signed-off-by: Myers Carpenter <myers@maski.org>
Co-authored-by: Myers Carpenter <myers@maski.org>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-29 07:40:30 +01:00
silverwind
5be73fa93a fix: size mermaid iframe by the diagram's aspect ratio (#40046) (#40052)
Backport https://github.com/go-gitea/gitea/pull/40046

Mermaid scales diagrams wider than the markup column down to its width,
but the iframe kept the unscaled viewBox height, leaving a large blank
area below them. The iframe now uses a CSS `aspect-ratio` capped at the
viewBox height. Diagrams that don't scale (`useMaxWidth: false`) keep
the fixed height.

The cherry-pick conflicted only on the `srcdoc` line, which uses an
`html` template on `main` but a plain string on `release/v28`.

Fixes: https://github.com/go-gitea/gitea/issues/40045
2026-10-28 19:10:25 +00:00
silverwind
3ed5886066 fix(deps): update module golang.org/x/net to v0.62.0 [security] (#40050) (#40051)
Backport https://github.com/go-gitea/gitea/pull/40050

`release/v28` is still on x/net v0.60.0, so the cherry-pick conflicted.
x/net v0.62.0 raises the minimum x/crypto, x/sync, x/sys, x/text and
x/tools versions, which `go get` and `make tidy` bump to what `main`
already uses.

Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-28 09:57:35 +01:00
Giteabot
12b5af068f fix(repo): use the viewed ref's editorconfig in file view, blame and editor (#39973) (#40019)
Backport #39973 by @Hello-Liar

The file view, blame and web editor read `.editorconfig` from the
default branch, so branch-specific settings like tab width were ignored.
The file view's middleware ran before the ref was resolved, and blame
never loaded it.

- `GetEditorconfig` reads the current commit, falling back to the
default branch only where no ref is resolved
- `Home` and `RefBlame` load the editorconfig after ref resolution

Fixes: https://github.com/go-gitea/gitea/issues/27413
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Hello-Liar <80651389+Hello-Liar@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-26 22:39:01 +00:00
Giteabot
8e83bb0e61 fix(markup): keep commit title emoji inside links (#39986) (#40032)
Backport #39986 by @Hello-Liar

Render emoji inside the default commit-title link so the whole title
remains clickable, including emoji-only titles. Explicit links and
mentions keep their own destinations.

Fixes https://github.com/go-gitea/gitea/issues/31873

Verified failing-before/passing-after native, shortcode, custom, and
emoji-only cases; markup/template tests and Go lint pass.
Browser-checked actual renderer output; blue outlines show link
boundaries.


![Before](https://github.com/user-attachments/assets/f56a4607-3265-4536-9fa2-ecc297dada6f)

![After](https://github.com/user-attachments/assets/7e0ad96d-6732-4d41-b7c6-cc28e90b747a)

AI-assisted by Codex (GPT-6); prepared and tested by the assistant.

Co-authored-by: leo <80651389+Hello-Liar@users.noreply.github.com>
2026-10-26 20:07:13 +00:00
Giteabot
2042caf945 fix(issue): prevent redirect to external tracker when built-in tracker is enabled (#39997) (#40031)
Backport #39997 by petervavrinecz

Signed-off-by: Vavrinecz Péter <98645804+petervavrinecz@users.noreply.github.com>
Co-authored-by: petervavrinecz <98645804+petervavrinecz@users.noreply.github.com>
2026-10-26 19:38:30 +00:00
wxiaoguang
79189a959f fix: replace null char with space for issue/comment content (#40014) (#40034)
A simple workaround to fix
https://github.com/go-gitea/gitea/issues/40003

Backport of https://github.com/go-gitea/gitea/pull/40014
2026-10-27 03:14:59 +08:00
Giteabot
1b8a7c4d85 fix(git): default to the gc maintenance strategy (#40022) (#40029)
Backport #40022 by @silverwind

Git 2.54 made "geometric" the default auto-maintenance strategy. Pushes
wait for its foreground reflog expiry
(https://github.com/go-gitea/gitea/issues/39693), and its detached
repacks race with filesystem operations, as in
https://github.com/go-gitea/gitea/actions/runs/40236681252/job/121299686933.
Pin the pre-2.54 "gc" strategy, older git ignores it.

https://github.com/go-gitea/gitea/pull/39745 is the more complete fix
which disables the auto-maintenance.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-26 19:43:11 +01:00
Giteabot
dbe80ac05c fix: Fix OneDev migration with OneDev 16.8 and newer (#40017) (#40026)
Backport #40017 by @lunny

OneDev 16.8 moved the server version endpoint from
`/~api/version/server` to `/~api/server/version`. Both are tried, the
new one first, and both errors are reported when neither works.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-26 13:54:28 +00:00
silverwind
4b669ca620 fix(migrations): update gitea sdk to fix nil pointer panic (#39999)
`gitea.dev/sdk` v1.2.0 loses the error of a failed server version lookup
after the first call, so Gitea-to-Gitea migrations panic on the next
version check. v1.3.0 (https://gitea.com/gitea/go-sdk/pulls/855) keeps
returning the error. `main` already has v1.3.0 via
https://github.com/go-gitea/gitea/pull/39742.

Fixes: https://github.com/go-gitea/gitea/issues/39989
2026-10-24 23:57:14 +02:00
Giteabot
9d335e8783 fix(diff): load both blobs of renamed files for diff rendering (#39969) (#39992)
Backport #39969

Co-authored-by: schop-rob <49682405+schop-rob@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-24 14:05:09 +00:00
Giteabot
85a85878c7 fix: get valid UTF-8 for diff_hunk and composer readme (#39932) (#40000)
Backport #39932 by @fixfon

* Fixes #39929
* Fixes #39994

Co-authored-by: fixfon <12512757+fixfon@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-24 13:38:33 +00:00
Giteabot
ef2ceff6cd fix(actions): drop untrusted Content-Encoding from v1 artifact uploads (#39855) (#39996)
Backport #39855 by @basavaraj-sm05

Legacy (v1 to v3) artifact uploads stored the request's
`Content-Encoding` header in the column that v4 artifacts read as their
`Content-Type`.

- Store only the `gzip` marker on legacy uploads, the only encoding
their clients send
- Pick the PDF and audio/video serve headers only for content types that
`mime.ParseMediaType` accepts

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-24 15:06:46 +02:00
Giteabot
35a6f3d4e0 enhance(project): filter project milestones by linked issues (#39958) (#39984)
Backport #39958 by @SHIVANSHGARG07

The project milestone filter listed every milestone of the repo, or of
all accessible org repos for org projects, including ones with no issue
in the project. That made the dropdown noisy and unhelpful.

Both repo and org projects now list only milestones that have at least
one issue linked to the project, via a new `ProjectID` option on
`FindMilestoneOptions`.

Fixes: https://github.com/go-gitea/gitea/issues/39150
Co-authored-by: Shivansh Garg <shivanshgarg587@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-24 06:24:46 +00:00
Giteabot
cf3d325e45 ci(release): stop creating deployments for release tags (#39966) (#39971)
Backport #39966 by @silverwind

Each `release-create-tag` run created a deployment record, which makes
GitHub show a useless "This branch has not been deployed" box on every
PR:

<img width="819" height="113" alt="image"
src="https://github.com/user-attachments/assets/129c8348-1d70-46b9-8996-8872c84a11f2"
/>

With `deployment: false`, the `release-signing` approval and branch
policy still apply.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 12:12:33 -07:00
Lunny Xiao
b140311d54 perf(actions): cache successful runner token verification (#39934) (#39959)
Backport of https://github.com/go-gitea/gitea/pull/39934 to release/v28.

Runner token verification (PBKDF2) dominated CPU on instances with many
runners. Successful verifications are now cached in a dedicated LRU.
Conflict resolved by keeping v28's connect v1 interceptor and adding
only the cache.

_Backport prepared with Codet._

Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 02:36:43 -07:00
Giteabot
2b0aa6cfd2 docs: clarify that the team packages permission covers all org packages (#39953) (#39956)
The Packages row in the team settings read "Manage repository
packages.", but packages belong to the owner, so a team's Packages
permission applies to every package of the organization, regardless of
the team's repositories. Reword it to say so. The docs get the same
clarification in https://gitea.com/gitea/docs/pulls/605.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 00:13:33 +00:00
silverwind
9d1d5ceddb fix(actions): bound requested artifact retention (#39894) (#39946)
`actions/upload-artifact` v4 and later send an absolute `expires_at`
computed with the runner's clock, which Gitea stored unchecked. A runner
clock running behind made artifacts expire at the next cleanup, and
workflows could keep artifacts longer than `ARTIFACT_RETENTION_DAYS`.

- Requested expiries are bounded by Gitea's clock, at least 1 day and at
most `ARTIFACT_RETENTION_DAYS` when it is not `0`. Longer
`retention-days` requests now get the instance limit.
- `github.retention_days` now carries `ARTIFACT_RETENTION_DAYS`, so
upload actions cap `retention-days` themselves and log a warning, like
on GitHub.

Co-authored-by: bircni <bircni@icloud.com>
2026-10-22 23:21:37 +00:00
silverwind
9141a6386f ci: update giteabot to v1.3.1 (#39947) (#39948)
Backport https://github.com/go-gitea/gitea/pull/39947

PRs targeting `release/v28` run this branch's `giteabot.yml`, and
`v1.3.1` runs the merge queue when such a PR merges, so the branch needs
the same pin. `giteabot-backport.yml` gets it too to keep the branches
in sync.
2026-10-22 22:56:30 +00:00
Giteabot
a814cf7567 fix(api): adding issue labels by name attaches both the repo and the org label (#39873) (#39951)
Backport #39873 by @nazem-droubi

Fixes #39651

When an org-owned repo has a repo label and an org label with the same
name, `POST`/`PUT /repos/{owner}/{repo}/issues/{index}/labels` with
label names attached both of them.

With this change the repo label is used when the names collide, and org
labels are only looked up for the names that didn't match a repo label.
Names that only exist at the org level still resolve to the org label as
before. I documented the behavior on `IssueLabelsOption.Labels` and
regenerated the swagger files.

Picking the repo label seemed the least surprising choice since it's the
more specific one, but I'm happy to change it (for example to return a
422 on ambiguous names) if you'd prefer that.

Tests: added `TestAPIAddIssueLabelsWithSharedRepoAndOrgLabelName`, which
covers both POST and PUT. It fails on `main` (3 labels attached instead
of 2) and passes with this change. I ran the existing add/replace label
integration tests locally on sqlite, plus `go vet` and gofmt.

AI disclosure: the code and test in this PR were written primarily by an
AI coding agent working on my behalf. I'm happy to walk through it or
revise anything during review.

Signed-off-by: Nazem <214302554+nazem-droubi@users.noreply.github.com>
Co-authored-by: Nazem <nazem98@gmail.com>
Co-authored-by: Nazem <214302554+nazem-droubi@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 21:40:40 +00:00
Giteabot
2fd12d5f76 fix(feed): hide release notes in tags feed without releases access (#39944) (#39949)
Backport #39944 by @basavaraj-sm05

The tags feed (`tags.rss` / `tags.atom`) lives in the tags route group,
which only requires code read access, yet it lists every published
release next to the plain tags and includes each release's title and
rendered note. The releases page and `releases.rss` require releases
read access, and the tags page only shows tag names to users without it,
so the feed is the one place that leaks. A team member granted Code but
not Releases on a private repo, or anyone on a public repo whose
Releases unit is disabled, can read all release notes this way. I
noticed it while comparing the feed routes with the tags template. When
the doer cannot read releases, the feed now lists releases as plain
tags, linking to `/src/tag/` like the tags page does.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
2026-10-22 14:06:27 -07:00
Giteabot
640b528580 fix(migrations): use cursor pagination for GitHub issue migration too (#39888) (#39941)
Backport #39888 by @yuhongwei380

For repos with many issues, the GitHub API only allows cursor-based
pagination instead of page-based pagination after a certain page.
A full migration of such repos failed with a 422 response.
Now, we dynamically switch how we migrate based on the response from the
GitHub API.

Fixes #39882
Assisted-by: Codex:GPT-6

Signed-off-by: Phoenix <44333391+yuhongwei380@users.noreply.github.com>
Co-authored-by: Phoenix <44333391+yuhongwei380@users.noreply.github.com>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 13:33:39 +00:00
Giteabot
3e5256b6ca fix(api): check unit read permission in issue sub-resource endpoints (#39885) (#39938)
Backport #39885 by @basavaraj-sm05

Several `/repos/{owner}/{repo}/issues/{index}/...` API endpoints load
the issue by index without checking
`CanReadIssuesOrPulls(issue.IsPull)`, unlike `GetIssue`,
`ListIssueComments` and `GetIssueReactions`. The route group's
`mustEnableIssuesOrPulls` passes when the caller can read either unit,
so someone who can read issues but not pull requests (a team with only
the Issues unit, or per-unit anonymous access on a public repo) can read
a pull request's whole conversation through `/timeline`, plus its
labels, subscribers, attachments and tracked times, and can subscribe to
it. The reverse holds for issues.

These handlers now return 404 in that case, like their siblings.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-22 05:27:46 -07:00
Giteabot
3996c2c520 fix(issues): keep one row per issue when sorting by exclusive label order (#39364) (#39928)
Backport #39364 by @shoemoney

Sorting by an exclusive label scope joined `issue_label`, returning one
row per label. Issues with several labels were listed repeatedly and
pushed others off the page, and `IssueIDs` returned inflated totals.

The sort key now comes from a correlated subquery, so each issue yields
one row.

Fixes https://github.com/go-gitea/gitea/issues/35711

This base-branch update was prepared with AI assistance.

Co-authored-by: Jeremy Schoemaker <jeremy@shoemoney.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 00:22:16 -07:00
Giteabot
716295015b fix(oauth2): decode form-encoded client credentials in HTTP Basic auth (#39926) (#39931)
Backport #39926 by @fixfon

Fixes https://github.com/go-gitea/gitea/issues/39925

RFC 6749 §2.3.1 has clients form-urlencode the client id and secret
before building the HTTP Basic header. `openid-client` with
`client_secret_basic` does this and sends `-` as `%2D` and `_` as `%5F`.
The token and introspection endpoints used the raw header values, so
these clients failed with `invalid_client`. Both endpoints now decode
the values with `url.QueryUnescape` before the client lookup, the secret
check and the body consistency check, and reject a value that does not
decode. Plain HTTP Basic parsing in `httpauth` is unchanged, since the
encoding only applies to OAuth client authentication.

Clients that don't encode are unaffected, since no client id or secret
Gitea has ever issued contains `+` or `%`.

Co-authored-by: fixfon <12512757+fixfon@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 06:22:15 +00:00
Giteabot
f50a5d0dab test(e2e): enable async clipboard testing pref in Firefox (#39838) (#39927)
Backport #39838 by @silverwind

When `navigator.clipboard.readText()` runs before the page's copy has
landed, Firefox shows its "Paste" prompt, which cannot be interacted
with when headless. Avoid that and and use
`dom.events.testing.asyncClipboard` which is a pref meant for testing
that disabes this prompt.

Fixes: https://github.com/go-gitea/gitea/actions/runs/39113819632/job/117635434106
Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 22:14:01 -07:00
Shudhanshu Singh
bc3e228e78 fix(api): keep milestone due date when it is omitted in edit request (#39869) (#39921)
Backport https://github.com/go-gitea/gitea/pull/39869

`PATCH /repos/{owner}/{repo}/milestones/{id}` always overwrote the due
date with `due_on`, so a request that only changed other fields, such as
`{"state": "closed"}`, silently removed it.

The due date is now kept when `due_on` is omitted, and `"due_on": null`
or `"due_on": ""` remove it.

Only the milestone fix is backported. The issue and pull request
deadline changes of https://github.com/go-gitea/gitea/pull/39869 build
on https://github.com/go-gitea/gitea/pull/38677, which isn't on v28.
2026-10-21 17:59:21 +02:00
Giteabot
193c676946 fix(lfs): send the default branch as ref in batch download requests (#39891) (#39920)
Backport #39891

Fixes https://github.com/go-gitea/gitea/issues/38085

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-21 10:59:03 +00:00
Giteabot
977c9181ad fix(api): return 4xx for invalid fork repository names (#39909) (#39919)
Backport #39909

Fixes #39908

Signed-off-by: Ferdinand-MZ <ferdinandmaulanazafauzi@gmail.com>
Co-authored-by: Ferdinand Maulana Za Fauzi <101077118+Ferdinand-MZ@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-21 10:40:04 +00:00
Giteabot
197e761471 enhance(webhook): mention release assets in notifications (#39631) (#39912)
Backport #39631 by @stackedbyaradhya

Release webhook notifications never mention attached files, although the
payload carries them.

The summary shared by all webhook providers now appends an asset count
like `(assets: 2)` for created and updated releases.

Closes https://github.com/go-gitea/gitea/issues/39630

Co-authored-by: Aradhya Jain <aradhyaj736@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 07:05:55 +00:00
Giteabot
01db50f0bf fix: enforce public-only token scope on more routes (#39690) (#39853)
Partial backport of https://github.com/go-gitea/gitea/pull/39690 with
only its public-only token checks, without the refactor.

1. Admin, org owner and repo or org creation APIs reject public-only
tokens.
1. Profile and org pages hide private repos and limited owners from
public-only tokens.
1. Heatmap API, OIDC userinfo and OCI blob mounts respect public-only
tokens.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-21 05:58:07 +00:00
Giteabot
4b743d2e8c fix(packages): reject newline-bearing rubygems dependency metadata (#39782) (#39900)
Backport #39782 by @basavaraj-sm05

The RubyGems compact index (the shared `/versions` file and per-package
`info/<name>` files) is line-based.
https://github.com/go-gitea/gitea/pull/38061 rejected line breaks in the
gem name, but the platform, dependency names and version requirements
from the gemspec still accepted them, letting an uploader forge index
lines for bundler clients.

The parser now rejects a line break in any of those fields. The Marshal
based endpoints are length prefixed and unaffected.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 05:24:52 +00:00
silverwind
31653bdb34 ci: update giteabot to v1.3.0 (#39910) (#39911) 2026-10-21 04:56:59 +00:00
Giteabot
7365946851 fix: use clear error message when failed to parse git version (#39898) (#39903)
Backport #39898 by @wxiaoguang

fix  #39892

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-21 04:35:32 +00:00
Giteabot
254e18107e enhance: improve hover border of colored button groups (#39861) (#39899)
Backport #39861 by @silverwind

Hovering a button in a primary or red button group showed a grey frame,
because the generic group hover border applied to colored groups too.
Green groups had no colors at all.

Colored groups now let the hover background fill the border, and green
groups get the same colors as primary and red. The devtest page shows
all three.

<img width="569" height="304" alt="Hovered buttons in uncolored,
primary, red and green button groups, before and after"
src="https://github.com/user-attachments/assets/e0fb6b16-f1f1-459b-9848-9386cd9453c9"
/>

Extracted from https://github.com/go-gitea/gitea/pull/38404

Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 04:03:35 +00:00
Giteabot
290568a07b fix: off-by-one errors in code comment patches (#39887) (#39904)
Backport #39887 by @silverwind

Code comments next to a diff hunk could show one line too high in the
conversation view vs. in the code view that showed them on the correct
line.

- Treat the hunk end in `CutDiffAroundLine` as exclusive, so the line
after a hunk falls back to the unchanged-line context
- Don't count `\ No newline at end of file` markers as lines when
cutting the hunk
- Share the unchanged-line fallback with migrated review comments

Co-authored-by: silverwind <me@silverwind.io>
2026-10-20 20:37:19 -07:00
bircni
b758396619 chore(deps): update go toolchain directive to v1.27.3 (#39881) (#39896)
Backport of https://github.com/go-gitea/gitea/pull/39881.

Go 1.27.3 includes a crypto/x509 security fix that distrusts a set of
intermediate certificates (https://github.com/golang/go/issues/82345),
so release builds need the new toolchain.

Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-20 14:04:10 -07:00
Giteabot
9bc4ce88ee fix: prevent system users from watching a repository (#39886) (#39895)
Backport #39886 by @wxiaoguang

fix #39884

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-20 20:27:42 +00:00
Giteabot
9bb9a51623 fix(api): skip label lookup in ListIssues when no labels filter given (#39890) (#39893)
Backport #39890 by @SHIVANSHGARG07

strings.Split("", ",") returns []string{""} (length 1), not an empty
slice, so the previous len(splitted) > 0 guard was always true and never
skipped the label ID lookup, even when the labels query param was
absent. Check the raw trimmed param for emptiness before splitting,
mirroring the pattern already used elsewhere in this file.

Fixes: https://github.com/go-gitea/gitea/issues/39889

Co-authored-by: Shivansh Garg <shivanshgarg587@gmail.com>
2026-10-20 12:19:50 -07:00
wxiaoguang
5838cc8172 fix(pulls): tolerate invalid stored review commit_id (#39849) (#39857)
Simple backport for #39849 to fix #39848
2026-10-21 01:46:56 +08:00
371 changed files with 4268 additions and 2498 deletions

View File

@@ -4,13 +4,14 @@ on:
push:
branches:
- main
- release/v*
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
@@ -19,8 +20,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
- uses: go-gitea/giteabot@15b18fdedd98a80d0968d0bd9db3a9428814b808 # v1.5.1
with:
github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea
checks: backport
checks: backport,backport_conflict_free

View File

@@ -1,16 +1,11 @@
name: giteabot
on:
# When main advances, rerun merge queue maintenance so the oldest
# reviewed/wait-merge PR can be updated against the new base promptly.
push:
branches:
- main
# pull_request_target gives this workflow access to GITEABOT_TOKEN on PRs from
# forks, which the bot needs to write labels, statuses and comments. Safe here
# because the job only runs a pinned action and never checks out PR HEAD.
# These PR lifecycle events drive label maintenance, queue maintenance, and
# explicit bot actions triggered by relevant label changes.
# These PR lifecycle events drive label maintenance, lgtm, and explicit bot
# actions triggered by relevant label changes.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types:
- opened
@@ -26,8 +21,7 @@ on:
- giteabot-review
types:
- requested
# Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly.
# Daily housekeeping like locks, stale feedback issues, last calls and milestones
schedule:
- cron: "15 3 * * *"
# Allow maintainers to rerun selected checks manually when debugging bot
@@ -37,7 +31,7 @@ on:
checks:
description: Comma-separated list of non-backport checks to run
required: false
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
default: backport_branches,backport_conflict_free,labels,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
permissions: {}
@@ -56,7 +50,7 @@ jobs:
pull-requests: write
statuses: write
steps:
- uses: go-gitea/giteabot@8033358e118c0bd97f5e9902dc5d6633eadc6aeb # v1.2.1
- uses: go-gitea/giteabot@15b18fdedd98a80d0968d0bd9db3a9428814b808 # v1.5.1
with:
github_token: ${{ secrets.GITEABOT_TOKEN }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
checks: ${{ github.event.inputs.checks || 'backport_branches,backport_conflict_free,labels,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}

16
.github/workflows/merge-group.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: merge-group
# Reports the required giteabot/lgtm check on merge queue commits. giteabot only sets it
# on PR heads, which already passed it before entering the queue.
on:
merge_group:
permissions: {}
jobs:
lgtm:
name: giteabot/lgtm
runs-on: ubuntu-latest
steps:
- run: "true"

View File

@@ -2,6 +2,7 @@ name: compliance
on:
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}

View File

@@ -2,6 +2,7 @@ name: db-tests
on:
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}

View File

@@ -2,6 +2,7 @@ name: e2e-tests
on:
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}

View File

@@ -13,7 +13,9 @@ permissions: {}
jobs:
tag:
runs-on: ubuntu-latest
environment: release-signing
environment:
name: release-signing
deployment: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:

View File

@@ -48,7 +48,7 @@ FROM docker.io/library/alpine:3.24 AS gitea
EXPOSE 22 3000
RUN apk --no-cache add \
RUN apk --no-cache upgrade && apk --no-cache add \
bash \
ca-certificates \
curl \

View File

@@ -43,7 +43,7 @@ FROM docker.io/library/alpine:3.24 AS gitea-rootless
EXPOSE 2222 3000
RUN apk --no-cache add \
RUN apk --no-cache upgrade && apk --no-cache add \
bash \
ca-certificates \
dumb-init \

View File

@@ -119,11 +119,6 @@
"path": "github.com/andybalholm/brotli/LICENSE",
"licenseText": "Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
},
{
"name": "github.com/andybalholm/brotli/flate",
"path": "github.com/andybalholm/brotli/flate/LICENSE",
"licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
},
{
"name": "github.com/andybalholm/cascadia",
"path": "github.com/andybalholm/cascadia/LICENSE",

View File

@@ -33,7 +33,7 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
return err
}
users, err := user_model.GetAllUsers(ctx)
users, err := user_model.GetAllUsersForAdminListing(ctx)
if err != nil {
return err
}
@@ -41,17 +41,17 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
if c.IsSet("admin") {
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n")
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tType\n")
for _, u := range users {
if u.IsAdmin {
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive)
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.Type.DisplayName())
}
}
} else {
twofa := user_model.UserList(users).GetTwoFaStatus(ctx)
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n")
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\tType\n")
for _, u := range users {
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID])
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID], u.Type.DisplayName())
}
}

View File

@@ -811,6 +811,7 @@ LEVEL = Info
;[git.config]
;diff.algorithm = histogram
;core.logAllRefUpdates = true
;maintenance.strategy = gc
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -3083,7 +3084,7 @@ LEVEL = Info
;LOG_RETENTION_DAYS = 365
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
;; Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step.
;; Workflows can request a shorter retention period, down to 1 day.
;ARTIFACT_RETENTION_DAYS = 90
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760

View File

@@ -74,15 +74,13 @@ Changes to Gitea must be reviewed before they are accepted, including changes fr
We require two maintainer approvals for every PR. When that is satisfied, your PR gets the `lgtm/done` label. After that, you mainly fix merge conflicts and respond to or implement maintainer requests; maintainers drive getting the PR merged.
If a PR has `lgtm/done`, no open discussions, and no merge conflicts, any maintainer may add `reviewed/wait-merge`. That puts the PR in the merge queue. PRs are merged from the queue in the order of this list:
If a PR has `lgtm/done`, no open discussions, and no merge conflicts, a merger rewrites its title and description into the final [commit message](#commit-messages) and clicks **Merge when ready**. That adds the PR to the [merge queue](https://github.com/go-gitea/gitea/queue/main), which tests it on top of the latest `main` and the PRs ahead of it, and merges it once CI passes. Edits to the title and description no longer reach the commit once the queue starts building the PR, so make them before queueing.
<https://github.com/go-gitea/gitea/pulls?q=is%3Apr+label%3Areviewed%2Fwait-merge+sort%3Acreated-asc+is%3Aopen>
Gitea uses its own tool, <https://github.com/go-gitea/gitea/blob/main/.github/workflows/giteabot.yml>, to automate parts of the review process. The backporter:
Gitea uses its own tool, <https://github.com/go-gitea/gitea/blob/main/.github/workflows/giteabot.yml>, to automate parts of the review process. It:
- Counts maintainer approvals and sets the `lgtm/*` labels and the `giteabot/lgtm` status.
- Takes a PR out of the merge queue when it loses its approval.
- Creates a backport PR when needed after the initial PR merges.
- Removes the PR from the merge queue after it merges.
- Keeps the oldest branch in the merge queue up to date with merges.
### Final call
@@ -164,7 +162,7 @@ Any account with write access (including bots and TOC members) **must** use [2FA
Mergers are the maintainers who carry out the final merge of approved PRs. Their responsibilities, described throughout this guide, are:
- Merging PRs from the [merge queue](#getting-prs-merged) in order, once a PR has `lgtm/done`, no open discussions, and no merge conflicts.
- Adding PRs to the [merge queue](#getting-prs-merged) once a PR has `lgtm/done`, no open discussions, and no merge conflicts.
- Rewriting the PR title and description prior to the merge, making the [commit message](#commit-messages) clear.\
In particular, mergers should edit the PR description.\
Mergers should **not** edit the actual commit message except to remove unnecessary information. Because of that, even if users are looking at the PR, they can understand what changed.

View File

@@ -24,6 +24,8 @@ Now, that's not a requirement anymore as our [backport bot](https://github.com/G
The `backport/manual` label signifies either that you want to backport the change yourself, or that there were conflicts when backporting, thus you **must** do it yourself.
The bot labels open PRs targeting `main` that have a `backport/<version>` label with `backport/conflict-free` when they would cherry-pick onto the latest release branch without conflicts.
### Format of backport PRs
The title of backport PRs should be

20
go.mod
View File

@@ -2,7 +2,7 @@ module gitea.dev
go 1.27
toolchain go1.27.2
toolchain go1.27.4
require (
connectrpc.com/connect v1.21.0
@@ -11,9 +11,9 @@ require (
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
gitea.com/lunny/levelqueue v0.5.1
gitea.dev/actionslib v1.2.1
gitea.dev/sdk v1.2.0
gitea.dev/sdk v1.3.0
github.com/42wim/httpsig v1.2.4
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
github.com/Azure/go-ntlmssp v0.1.1
@@ -95,14 +95,14 @@ require (
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
golang.org/x/crypto v0.57.0
golang.org/x/crypto v0.58.0
golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0
golang.org/x/net v0.60.0
golang.org/x/net v0.62.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/text v0.42.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/text v0.43.0
google.golang.org/grpc v1.84.0
google.golang.org/protobuf v1.36.12
gopkg.in/ini.v1 v1.67.3
@@ -120,7 +120,7 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.7 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -252,7 +252,7 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect
golang.org/x/tools v0.51.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.75.7 // indirect

40
go.sum
View File

@@ -18,14 +18,14 @@ gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6 h1:YWzVGeC/8SZThrJS4
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0=
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw=
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96/go.mod h1:VyMQP6ue6MKHM8UsOXfNfuMKD0oSAWZdXVcpHIN2yaY=
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHqdhS7keYWioqfmxdnfblFDTGoOwcZ+o=
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
gitea.com/lunny/levelqueue v0.5.1 h1:15q1oBWaO2Aw9t3rDthQSTX+vBNgjYNkCgRJgES9gA8=
gitea.com/lunny/levelqueue v0.5.1/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I=
gitea.dev/sdk v1.3.0/go.mod h1:85EBqSAqUn1KlFCk40y3sTjWjBsB/KouL3slGXMA/2o=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y=
@@ -71,8 +71,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -686,8 +686,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
@@ -714,8 +714,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -728,8 +728,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -756,8 +756,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -767,8 +767,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -779,8 +779,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -790,8 +790,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -93,7 +93,12 @@ const artifactKeepForever timeutil.TimeStamp = 0
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
if requested.Has() {
return max(requested.Value(), artifactKeepForever+1)
now := timeutil.TimeStampNow()
expiry := max(requested.Value(), now.Add(timeutil.Day)) // v4 clients use the runner's clock
if setting.Actions.ArtifactRetentionDays > 0 {
expiry = min(expiry, now.Add(timeutil.Day*setting.Actions.ArtifactRetentionDays))
}
return expiry
}
if setting.Actions.ArtifactRetentionDays <= 0 {
return artifactKeepForever

View File

@@ -5,6 +5,12 @@ package actions
import (
"testing"
"time"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
@@ -25,3 +31,21 @@ func TestKeepLatestAttemptArtifacts(t *testing.T) {
}
assert.Equal(t, []int64{1, 4, 5}, ids)
}
func TestArtifactExpiry(t *testing.T) {
defer timeutil.MockSet(time.Unix(1_000_000_000, 0))()
now := timeutil.TimeStampNow()
days := func(n int64) timeutil.TimeStamp { return now.Add(n * timeutil.Day) }
requested := optional.Some[timeutil.TimeStamp]
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 90)()
assert.Equal(t, days(90), artifactExpiry(optional.None[timeutil.TimeStamp]()))
assert.Equal(t, days(5), artifactExpiry(requested(days(5))))
assert.Equal(t, days(1), artifactExpiry(requested(days(-2))), "runner clock behind must not expire the artifact at once")
assert.Equal(t, days(90), artifactExpiry(requested(days(1000))), "runner must not exceed the instance retention")
setting.Actions.ArtifactRetentionDays = 0
assert.Equal(t, artifactKeepForever, artifactExpiry(optional.None[timeutil.TimeStamp]()))
assert.Equal(t, days(1000), artifactExpiry(requested(days(1000))))
assert.Equal(t, days(1), artifactExpiry(requested(now)))
}

View File

@@ -34,8 +34,8 @@ type ActionRun struct {
RepoID int64 `xorm:"unique(repo_index) index(repo_status)"`
Repo *repo_model.Repository `xorm:"-"`
OwnerID int64 `xorm:"index"`
WorkflowID string `xorm:"index"` // the name of workflow file
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
WorkflowID string `xorm:"index"` // the name of workflow file
Index int64 `xorm:"unique(repo_index)"` // a unique number for each run of a repository
TriggerUserID int64 `xorm:"index"`
TriggerUser *user_model.User `xorm:"-"`
ScheduleID int64

View File

@@ -25,7 +25,6 @@ import (
// MaxJobNumPerRun is the maximum number of jobs in a single run.
// https://docs.github.com/en/actions/reference/limits#existing-system-limits
// TODO: check this limit when creating jobs
const MaxJobNumPerRun = 256
// ActionRunJob represents a job of a run
@@ -100,7 +99,7 @@ type ActionRunJob struct {
// IsReusableCaller marks this job as a reusable workflow caller.
// Caller jobs do not run on a runner; their status is derived from their child jobs.
IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"`
IsReusableCaller bool `xorm:"NOT NULL DEFAULT FALSE"`
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
@@ -440,14 +439,6 @@ func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*
return out
}
// hasWaitingJobsToPick reports whether any waiting, unclaimed, non-reusable job
// remains in the repo, i.e. work that an idle runner could still pick up.
func hasWaitingJobsToPick(ctx context.Context, repoID int64) (bool, error) {
return db.GetEngine(ctx).
Where("repo_id = ? AND task_id = ? AND status = ? AND is_reusable_caller = ?", repoID, 0, StatusWaiting, false).
Exist(&ActionRunJob{})
}
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
e := db.GetEngine(ctx)
@@ -498,7 +489,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
// Only bump when waiting work actually remains for this repo, otherwise
// every job completion would needlessly bump the global version and wake
// every idle runner instance-wide for nothing.
hasWaiting, err := hasWaitingJobsToPick(ctx, job.RepoID)
hasWaiting, err := JobQueueOptions{RepoID: job.RepoID, Status: StatusWaiting}.session(ctx).Exist(new(ActionRunJob))
if err != nil {
return 0, err
}

View File

@@ -27,13 +27,12 @@ func (opts JobQueueOptions) session(ctx context.Context) *xorm.Session {
sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
}
if opts.OwnerID > 0 {
sess = sess.Join("INNER", "repository", "repository.id = `action_run_job`.repo_id AND repository.owner_id = ?", opts.OwnerID)
sess = sess.And(builder.In("`action_run_job`.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"owner_id": opts.OwnerID})))
}
return sess.And(opts.statusCond())
}
var (
// keep in sync with CreateTaskForRunner
queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0}
// a cancelling job still occupies its runner
runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling)

View File

@@ -65,26 +65,22 @@ func (opts FindScopedWorkflowSourceOpts) ToConds() builder.Cond {
if opts.SourceRepoID != 0 {
cond = cond.And(builder.Eq{"source_repo_id": opts.SourceRepoID})
}
return cond
return cond.And(scopedWorkflowSourceOwnedCond())
}
func scopedWorkflowSourceOwnedCond() builder.Cond {
return builder.Eq{"owner_id": 0}.Or(builder.Expr("EXISTS (SELECT 1 FROM repository WHERE repository.id = action_scoped_workflow_source.source_repo_id AND repository.owner_id = action_scoped_workflow_source.owner_id)"))
}
// GetEffectiveScopedWorkflowSources returns the scoped-workflow sources effective for a repo owned by repoOwnerID:
// the owner's own sources plus instance-level (owner_id=0) sources.
func GetEffectiveScopedWorkflowSources(ctx context.Context, repoOwnerID int64) ([]*ActionScopedWorkflowSource, error) {
owners := []int64{0}
if repoOwnerID != 0 {
owners = append(owners, repoOwnerID)
}
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners})
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{0, repoOwnerID}})
}
// IsScopedWorkflowSourceEffective reports whether sourceRepoID is a scoped-workflow source effective for a repo owned by repoOwnerID.
func IsScopedWorkflowSourceEffective(ctx context.Context, repoOwnerID, sourceRepoID int64) (bool, error) {
owners := []int64{0}
if repoOwnerID != 0 {
owners = append(owners, repoOwnerID)
}
return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners, SourceRepoID: sourceRepoID}.ToConds())
return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{0, repoOwnerID}, SourceRepoID: sourceRepoID}.ToConds())
}
// IsWorkflowRequiredInSources reports whether workflowID from sourceRepoID is required by any of the given sources.
@@ -138,12 +134,10 @@ func GetScopedWorkflowSourcesByOwner(ctx context.Context, ownerID int64) ([]*Act
// GetScopedWorkflowSource returns the (owner, repo) source registration or a NotExist error.
func GetScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) (*ActionScopedWorkflowSource, error) {
src := &ActionScopedWorkflowSource{}
has, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Get(src)
src, has, err := db.Get[ActionScopedWorkflowSource](ctx, builder.Eq{"owner_id": ownerID, "source_repo_id": repoID}.And(scopedWorkflowSourceOwnedCond()))
if err != nil {
return nil, err
}
if !has {
} else if !has {
return nil, util.NewNotExistErrorf("scoped workflow source (owner %d, repo %d) does not exist", ownerID, repoID)
}
return src, nil
@@ -181,3 +175,9 @@ func RemoveScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) erro
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Delete(new(ActionScopedWorkflowSource))
return err
}
// RemoveOwnerScopedWorkflowSources removes every owner-level registration of repoID, as only its current owner may hold one.
func RemoveOwnerScopedWorkflowSources(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Where("owner_id <> 0 AND source_repo_id = ?", repoID).Delete(new(ActionScopedWorkflowSource))
return err
}

View File

@@ -48,26 +48,28 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
rows := []*ActionScopedWorkflowSource{
{OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil}, // org 2 registers repo 100 (optional)
{OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}}, // instance also registers repo 100 (required)
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}}, // instance source 200
{OwnerID: 3, SourceRepoID: 300, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true, Patterns: []string{"p"}}}}, // a different owner's source
}
for _, r := range rows {
require.NoError(t, db.Insert(ctx, r))
for _, source := range []*ActionScopedWorkflowSource{
{OwnerID: 2, SourceRepoID: 1},
{OwnerID: 0, SourceRepoID: 1, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true}}},
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true}}},
{OwnerID: 3, SourceRepoID: 3, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true}}},
{OwnerID: 2, SourceRepoID: 3, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true}}},
} {
require.NoError(t, db.Insert(ctx, source))
}
// owner 2 sees its own sources plus instance-level ones, but not owner 3's.
owner2, err := GetEffectiveScopedWorkflowSources(ctx, 2)
require.NoError(t, err)
assert.Len(t, owner2, 3)
_, err = GetScopedWorkflowSource(ctx, 2, 3)
assert.ErrorIs(t, err, util.ErrNotExist)
required, err := IsScopedWorkflowRequired(ctx, 2, 100, "a.yml")
required, err := IsScopedWorkflowRequired(ctx, 2, 1, "a.yml")
require.NoError(t, err)
assert.True(t, required, "instance marks a.yml required → required for owner 2 even though org left it optional")
required, err = IsScopedWorkflowRequired(ctx, 2, 100, "x.yml")
required, err = IsScopedWorkflowRequired(ctx, 2, 1, "x.yml")
require.NoError(t, err)
assert.False(t, required)
@@ -76,12 +78,12 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
assert.True(t, required)
// owner 3's source must not be effective for owner 2.
required, err = IsScopedWorkflowRequired(ctx, 2, 300, "c.yml")
required, err = IsScopedWorkflowRequired(ctx, 2, 3, "c.yml")
require.NoError(t, err)
assert.False(t, required)
// IsScopedWorkflowSourceEffective: owner-level and instance-level sources are effective; another owner's is not.
effective, err := IsScopedWorkflowSourceEffective(ctx, 2, 100)
effective, err := IsScopedWorkflowSourceEffective(ctx, 2, 1)
require.NoError(t, err)
assert.True(t, effective, "owner 2's own source")
@@ -89,7 +91,7 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
require.NoError(t, err)
assert.True(t, effective, "instance-level source is effective for any owner")
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 300)
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 3)
require.NoError(t, err)
assert.False(t, effective, "owner 3's source is not effective for owner 2")
@@ -97,7 +99,7 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
require.NoError(t, err)
assert.False(t, effective, "unknown source repo")
effective, err = IsScopedWorkflowSourceEffective(ctx, 3, 300)
effective, err = IsScopedWorkflowSourceEffective(ctx, 3, 3)
require.NoError(t, err)
assert.True(t, effective, "owner 3's own source is effective for owner 3")
}
@@ -107,8 +109,8 @@ func TestScopedWorkflowSourceCRUD(t *testing.T) {
ctx := t.Context()
// add is idempotent
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 4))
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 4))
sources, err := GetScopedWorkflowSourcesByOwner(ctx, 5)
require.NoError(t, err)
assert.Len(t, sources, 1)
@@ -118,20 +120,20 @@ func TestScopedWorkflowSourceCRUD(t *testing.T) {
"a.yml": {Required: true, Patterns: []string{"src: a.yml / *"}},
"b.yml": {Required: false, Patterns: []string{"src: b.yml / build (push)"}},
}
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, configs))
src, err := GetScopedWorkflowSource(ctx, 5, 10)
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 4, configs))
src, err := GetScopedWorkflowSource(ctx, 5, 4)
require.NoError(t, err)
assert.Equal(t, configs, src.WorkflowConfigs)
// clearing the configs works
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, nil))
src, err = GetScopedWorkflowSource(ctx, 5, 10)
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 4, nil))
src, err = GetScopedWorkflowSource(ctx, 5, 4)
require.NoError(t, err)
assert.Empty(t, src.WorkflowConfigs)
// remove
require.NoError(t, RemoveScopedWorkflowSource(ctx, 5, 10))
_, err = GetScopedWorkflowSource(ctx, 5, 10)
require.NoError(t, RemoveScopedWorkflowSource(ctx, 5, 4))
_, err = GetScopedWorkflowSource(ctx, 5, 4)
assert.ErrorIs(t, err, util.ErrNotExist)
sources, err = GetScopedWorkflowSourcesByOwner(ctx, 5)
require.NoError(t, err)

View File

@@ -261,21 +261,10 @@ var pickTaskBatchSize = 100
// atomically claims it. It iterates through all matching jobs so that a
// concurrent claim by another runner (which would lose the optimistic lock on
// job #1) does not leave the remaining jobs permanently unassigned.
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (task *ActionTask, cancelledJobs []*ActionRunJob, err error) {
if db.InTransaction(ctx) {
return nil, false, errors.New("CreateTaskForRunner must not be called within a database transaction")
return nil, nil, errors.New("CreateTaskForRunner must not be called within a database transaction")
}
e := db.GetEngine(ctx)
jobCond := builder.NewCond()
if runner.RepoID != 0 {
jobCond = builder.Eq{"repo_id": runner.RepoID}
} else if runner.OwnerID != 0 {
jobCond = builder.In("repo_id", builder.Select("`repository`.id").From("repository").
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
}
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
// TODO: a more efficient way to filter labels
log.Trace("runner labels: %v", runner.AgentLabels)
@@ -286,17 +275,17 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
var cursorUpdated timeutil.TimeStamp
var cursorID int64
for {
cond := baseCond
sess := JobQueueOptions{RepoID: runner.RepoID, OwnerID: runner.OwnerID, Status: StatusWaiting}.session(ctx)
if cursorID > 0 {
cond = cond.And(builder.Or(
sess.And(builder.Or(
builder.Gt{"updated": cursorUpdated},
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
))
}
var jobs []*ActionRunJob
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, false, err
if err := sess.Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, cancelledJobs, err
}
// A short page means no waiting jobs remain beyond it.
isLastPage := len(jobs) < pickTaskBatchSize
@@ -309,28 +298,29 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
if !runner.CanMatchLabels(v.RunsOn) {
continue
}
task, ok, err := claimJobForRunner(ctx, runner, v)
if err != nil {
return nil, false, err
task, cancelled, err := claimJobForRunner(ctx, runner, v)
if cancelled {
cancelledJobs = append(cancelledJobs, v)
}
if ok {
return task, true, nil
if err != nil || task != nil {
return task, cancelledJobs, err
}
// Another runner claimed this job concurrently; try the next one.
// Another runner claimed this job concurrently, or it was cancelled. Try the next one.
}
if isLastPage {
return nil, false, nil
return nil, cancelledJobs, nil
}
}
}
// claimJobForRunner attempts to atomically claim job for runner inside its own
// transaction. Returns (task, true, nil) on success, or (nil, false, nil) when
// another runner wins the optimistic-lock race (the caller should try the next
// candidate job).
// transaction. Returns the task on success, cancelled when the job's repository
// has Actions disabled, or neither when another runner wins the optimistic-lock
// race (the caller should try the next candidate job).
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
var resultTask *ActionTask
var cancelled bool
err := db.WithTx(ctx, func(ctx context.Context) error {
e := db.GetEngine(ctx)
@@ -338,6 +328,15 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
if err := job.LoadAttributes(ctx); err != nil {
return err
}
if err := job.Run.Repo.LoadUnits(ctx); err != nil {
return err
}
if !job.Run.Repo.UnitEnabled(ctx, unit.TypeActions) {
job.Status, job.Stopped = StatusCancelled, timeutil.TimeStampNow()
affected, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0, "status": StatusWaiting}, "status", "stopped")
cancelled = err == nil && affected == 1
return err
}
now := timeutil.TimeStampNow()
job.Started = now
@@ -403,13 +402,10 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
return nil
})
if errors.Is(err, errJobAlreadyClaimed) {
return nil, false, nil
}
if err != nil {
if err != nil && !errors.Is(err, errJobAlreadyClaimed) {
return nil, false, err
}
return resultTask, true, nil
return resultTask, cancelled, nil
}
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes

View File

@@ -13,6 +13,7 @@ import (
runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/timeutil"
@@ -258,6 +259,11 @@ func TestReleaseTaskForRunner(t *testing.T) {
}
require.NoError(t, db.Insert(t.Context(), run))
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
require.NoError(t, db.Insert(t.Context(), disabledRun))
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
require.NoError(t, db.Insert(t.Context(), disabledJob))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
@@ -279,11 +285,13 @@ func TestReleaseTaskForRunner(t *testing.T) {
}
runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner))
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
task, ok, err := CreateTaskForRunner(t.Context(), runner)
task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, task)
require.Len(t, cancelledJobs, 1)
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
require.Equal(t, StatusRunning, claimed.Status)
@@ -324,6 +332,11 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
}
require.NoError(t, db.Insert(t.Context(), run))
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
require.NoError(t, db.Insert(t.Context(), disabledRun))
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
require.NoError(t, db.Insert(t.Context(), disabledJob))
// 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 {
@@ -362,11 +375,13 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
}
runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner))
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
task, ok, err := CreateTaskForRunner(t.Context(), runner)
task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, task)
require.Len(t, cancelledJobs, 1)
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
assert.Equal(t, StatusRunning, claimed.Status)

View File

@@ -180,15 +180,14 @@ func updateIssueNotification(ctx context.Context, userID, issueID, commentID, up
}
// NOTICE: Only update comment id when the before notification on this issue is read, otherwise you may miss some old comments.
// But we need update update_by so that the notification will be reorder
var cols []string
// Always update so that updated_unix moves the notification to the top of the list
// Actually, it's unclear what UpdatedBy is used for, or maybe not used at all, or maybe it should just be dropped.
notification.UpdatedBy = updatedByID
cols := []string{"updated_by"}
if notification.Status == NotificationStatusRead {
notification.Status = NotificationStatusUnread
notification.CommentID = commentID
cols = []string{"status", "update_by", "comment_id"}
} else {
notification.UpdatedBy = updatedByID
cols = []string{"update_by"}
cols = append(cols, "status", "comment_id")
}
_, err = db.GetEngine(ctx).ID(notification.ID).Cols(cols...).Update(notification)

View File

@@ -71,16 +71,6 @@ func (opts FindNotificationOptions) ToOrders() string {
// receiverID > 0 just send to receiver, else send to all watcher
// Returns the set of user IDs whose notification rows were created or updated.
func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
var notifiedIDs []int64
err := db.WithTx(ctx, func(ctx context.Context) error {
var innerErr error
notifiedIDs, innerErr = createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID)
return innerErr
})
return notifiedIDs, err
}
func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
// init
var toNotify container.Set[int64]
notifications, err := db.Find[Notification](ctx, FindNotificationOptions{
@@ -181,7 +171,11 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
if notificationExists(notifications, issue.ID, userID) {
err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID)
} else {
err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID)
if err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID); err != nil {
if errUpdate := updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID); errUpdate == nil {
err = nil
}
}
}
if err != nil {
return nil, err

View File

@@ -31,6 +31,18 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) {
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 4, IssueID: issue.ID})
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
// unread notification should get latest updated_unix when re-notified
_, err = db.GetEngine(t.Context()).ID(notf.ID).NoAutoTime().Cols("updated_unix").Update(&activities_model.Notification{UpdatedUnix: 123})
assert.NoError(t, err)
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: notf.ID})
assert.EqualValues(t, 123, notf.UpdatedUnix)
_, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), issue.ID, 0, 3, 0)
assert.NoError(t, err)
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: notf.ID})
assert.NotEqualValues(t, 123, notf.UpdatedUnix)
assert.EqualValues(t, 3, notf.UpdatedBy)
}
func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) {

View File

@@ -85,7 +85,7 @@ func (opts FindGPGKeyOptions) ToConds() builder.Cond {
cond = cond.And(builder.Eq{"primary_key_id": ""})
}
if opts.OwnerID > 0 {
if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}
if opts.KeyID != "" {

View File

@@ -224,7 +224,7 @@ func (opts FindPublicKeyOptions) ToOrders() string {
func (opts FindPublicKeyOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.OwnerID > 0 {
if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}
if opts.Fingerprint != "" {

View File

@@ -12,8 +12,11 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"gitea.dev/models/db"
"gitea.dev/modules/cache"
"gitea.dev/modules/globallock"
"gitea.dev/modules/secret"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
@@ -48,6 +51,11 @@ func (err ErrTwoFactorNotEnrolled) Unwrap() error {
return util.ErrNotExist
}
const (
totpMaxAttempts = 5
totpAttemptsTTL = 15 * 60 // seconds, renewed by every failed attempt
)
// TwoFactor represents a two-factor authentication token.
type TwoFactor struct {
ID int64 `xorm:"pk autoincr"`
@@ -120,26 +128,59 @@ func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
return totp.Validate(passcode, secretStr), nil
}
type TwoFactorResult int
const (
TwoFactorResultValid TwoFactorResult = iota + 1
TwoFactorResultInvalid
TwoFactorResultTooManyAttempts
)
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
// invalid passcode as well as for a replay, including the case where a concurrent request with
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
ok, err := t.validateTOTP(passcode)
if err != nil || !ok {
return false, err
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). Invalid passcodes and replays
// return TwoFactorResultInvalid; exhausted attempts return TwoFactorResultTooManyAttempts.
// All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (result TwoFactorResult, err error) {
if passcode == "" {
// password-only Basic auth clients must not use up the attempts
return TwoFactorResultInvalid, nil
}
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
t.LastUsedPasscode = passcode
n, err := db.GetEngine(ctx).ID(t.ID).
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
Cols("last_used_passcode").Update(t)
if err != nil {
return false, err
}
return n == 1, nil
// the lock spans check, validation and counting so that concurrent requests cannot exceed the limit
attemptsKey := fmt.Sprintf("twofa_attempts_%d", t.UID)
err = globallock.LockAndDo(ctx, attemptsKey, func(ctx context.Context) error {
value, _ := cache.GetCache().Get(attemptsKey)
attempts, _ := strconv.Atoi(value)
if attempts >= totpMaxAttempts {
result = TwoFactorResultTooManyAttempts
return nil
}
valid, err := t.validateTOTP(passcode)
if err != nil {
return err
}
if valid {
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
t.LastUsedPasscode = passcode
n, err := db.GetEngine(ctx).ID(t.ID).
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
Cols("last_used_passcode").Update(t)
if err != nil {
return err
}
valid = n == 1
}
if !valid {
result = TwoFactorResultInvalid
return cache.GetCache().Put(attemptsKey, strconv.Itoa(attempts+1), totpAttemptsTTL)
}
cache.Remove(attemptsKey)
result = TwoFactorResultValid
return nil
})
return result, err
}
// NewTwoFactor creates a new two-factor authentication token.

View File

@@ -4,11 +4,15 @@
package auth_test
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
"gitea.dev/modules/cache"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp/totp"
@@ -30,21 +34,42 @@ func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
require.NoError(t, err)
// first use of a valid passcode succeeds
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
result, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, auth_model.TwoFactorResultValid, result)
// replaying the same passcode is refused, even when still inside the TOTP validity window
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
require.NoError(t, err)
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.False(t, ok)
assert.Equal(t, auth_model.TwoFactorResultInvalid, result)
// an invalid passcode is rejected without consuming anything
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
var limited atomic.Int32
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
result, err := reloaded.ValidateAndConsumeTOTP(t.Context(), "0000000")
assert.NoError(t, err)
if result == auth_model.TwoFactorResultTooManyAttempts {
limited.Add(1)
} else {
assert.Equal(t, auth_model.TwoFactorResultInvalid, result)
}
})
}
wg.Wait()
assert.EqualValues(t, 6, limited.Load())
nextPasscode, err := totp.GenerateCode(key.Secret(), time.Now().Add(30*time.Second))
require.NoError(t, err)
assert.False(t, ok)
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), nextPasscode)
require.NoError(t, err)
assert.Equal(t, auth_model.TwoFactorResultTooManyAttempts, result)
cache.Remove(fmt.Sprintf("twofa_attempts_%d", tfa.UID))
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), nextPasscode)
require.NoError(t, err)
assert.Equal(t, auth_model.TwoFactorResultValid, result)
}
func TestDisableTwoFactor(t *testing.T) {

View File

@@ -7,7 +7,7 @@
updated_unix: 1546869730
- id: 2
user_id: 3
user_id: 14
application_id: 1
counter: 1
scope: "openid"
@@ -23,7 +23,7 @@
updated_unix: 1546869730
- id: 4
user_id: 99
user_id: 2
application_id: 2
counter: 1
scope: "whatever"

View File

@@ -39,7 +39,7 @@ type CommitStatus struct {
RepoID int64 `xorm:"UNIQUE(repo_sha_index)"`
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
Repo *repo_model.Repository `xorm:"-"`
Repo *repo_model.Repository `xorm:"-" json:"-"`
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
// TargetURL points to the commit status page reported by a CI system
@@ -49,7 +49,7 @@ type CommitStatus struct {
Description string `xorm:"TEXT"`
ContextHash string `xorm:"VARCHAR(64) index"`
Context string `xorm:"TEXT"`
Creator *user_model.User `xorm:"-"`
Creator *user_model.User `xorm:"-" json:"-"`
CreatorID int64
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
@@ -182,7 +182,7 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
if status.Repo == nil {
status.Repo, err = repo_model.GetRepositoryByID(ctx, status.RepoID)
status.Repo, err = cache.GetWithContextCache(ctx, cachegroup.Repository, status.RepoID, repo_model.GetRepositoryByID)
if err != nil {
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
}
@@ -230,11 +230,9 @@ func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context)
return "", false
}
if status.Repo == nil {
if err := status.loadRepository(ctx); err != nil {
log.Error("loadRepository: %v", err)
return "", false
}
if err := status.loadRepository(ctx); err != nil {
log.Error("loadRepository: %v", err)
return "", false
}
prefix := status.Repo.Link() + "/actions"
@@ -572,11 +570,12 @@ func SignCommitsApplyDoerPermission(ctx context.Context, doer *user_model.User,
}
func statusRepoCanReadActions(ctx context.Context, doer *user_model.User, status *CommitStatus) bool {
if err := status.loadRepository(ctx); err != nil {
log.Error("loadRepository: %v", err)
return false
}
perm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(status.RepoID, doer),
func(ctx context.Context, _ string) (access_model.Permission, error) { // only runs on a cache miss
if err := status.loadRepository(ctx); err != nil {
return access_model.Permission{}, err
}
func(ctx context.Context, _key string) (access_model.Permission, error) {
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
},
)

View File

@@ -13,6 +13,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/cache"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/git"
@@ -281,8 +282,10 @@ func TestCommitStatusesApplyDoerPermission(t *testing.T) {
},
}
git_model.CommitStatusesApplyDoerPermission(t.Context(), doer, statuses)
git_model.CommitStatusesApplyDoerPermission(cache.WithCacheContext(t.Context()), doer, statuses)
assert.Equal(t, visibleURL, statuses[0].TargetURL)
assert.Empty(t, statuses[1].TargetURL)
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
assert.NotNil(t, statuses[0].Repo)
assert.Same(t, statuses[0].Repo, statuses[2].Repo)
}

View File

@@ -34,6 +34,7 @@ import (
"gitea.dev/modules/util"
"xorm.io/builder"
"xorm.io/xorm"
)
// ErrCommentNotExist represents a "CommentNotExist" kind of error.
@@ -369,12 +370,19 @@ func (c *Comment) LoadIssue(ctx context.Context) (err error) {
return err
}
var (
_ xorm.BeforeInsertProcessor = (*Comment)(nil)
_ xorm.BeforeUpdateProcessor = (*Comment)(nil)
)
// BeforeInsert will be invoked by XORM before inserting a record
func (c *Comment) BeforeInsert() {
c.PatchQuoted = c.Patch
if !utf8.ValidString(c.Patch) {
c.PatchQuoted = strconv.Quote(c.Patch)
}
sanitizeDatabaseStringContent(&c.Content)
}
// BeforeUpdate will be invoked by XORM before updating a record
@@ -383,6 +391,8 @@ func (c *Comment) BeforeUpdate() {
if !utf8.ValidString(c.Patch) {
c.PatchQuoted = strconv.Quote(c.Patch)
}
sanitizeDatabaseStringContent(&c.Content)
}
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
@@ -1164,6 +1174,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
if affected == 0 {
return ErrCommentAlreadyChanged
}
if c.Type == CommentTypeReview {
if _, err := db.GetEngine(ctx).ID(c.ReviewID).Cols("content").NoAutoTime().Update(&Review{Content: c.Content}); err != nil { // updated_unix picks a reviewer's latest review
return err
}
}
if err := c.LoadIssue(ctx); err != nil {
return err
}

View File

@@ -11,6 +11,7 @@ import (
"regexp"
"slices"
"strconv"
"strings"
"gitea.dev/models/db"
project_model "gitea.dev/models/project"
@@ -25,6 +26,7 @@ import (
"gitea.dev/modules/util"
"xorm.io/builder"
"xorm.io/xorm"
)
// ErrIssueNotExist represents a "IssueNotExist" kind of error.
@@ -53,7 +55,7 @@ var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already
// Issue represents an issue or pull request of repository.
type Issue struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"`
RepoID int64 `xorm:"INDEX(repo_closed_pull) UNIQUE(repo_index)"`
Repo *repo_model.Repository `xorm:"-"`
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
PosterID int64 `xorm:"INDEX"`
@@ -75,9 +77,9 @@ type Issue struct {
AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"`
isAssigneeLoaded bool `xorm:"-"`
IsClosed bool `xorm:"INDEX"`
IsClosed bool `xorm:"INDEX(repo_closed_pull)"`
IsRead bool `xorm:"-"`
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
IsPull bool `xorm:"INDEX(repo_closed_pull)"` // Indicates whether is a pull request or not.
PullRequest *PullRequest `xorm:"-"`
NumComments int
@@ -123,6 +125,27 @@ func init() {
db.RegisterModel(new(IssueIndex))
}
func sanitizeDatabaseStringContent(s *string) {
// postgresql doesn't like "\x00"
// this is only a simple workaround for issues like https://github.com/go-gitea/gitea/issues/28890 and https://github.com/go-gitea/gitea/issues/40003
*s = strings.ReplaceAll(*s, "\x00", " ")
}
var (
_ xorm.BeforeInsertProcessor = (*Issue)(nil)
_ xorm.BeforeUpdateProcessor = (*Issue)(nil)
)
func (issue *Issue) BeforeInsert() {
sanitizeDatabaseStringContent(&issue.Title)
sanitizeDatabaseStringContent(&issue.Content)
}
func (issue *Issue) BeforeUpdate() {
sanitizeDatabaseStringContent(&issue.Title)
sanitizeDatabaseStringContent(&issue.Content)
}
// LoadTotalTimes load total tracked time
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
opts := FindTrackedTimesOptions{IssueID: issue.ID}

View File

@@ -75,11 +75,11 @@ func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
// Since this sortType is dynamically created, it has to be treated specially.
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
scope := after
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
// a subquery instead of a JOIN keeps one row per issue, "exclusive_order=0" means unset and COALESCE sorts it last on every DB
sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+
" INNER JOIN label ON label.id = issue_label.label_id"+
" WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%").
Desc("issue.id")
return
}
@@ -218,10 +218,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
}
if opts.AllPublic {
if opts.RepoCond == nil {
opts.RepoCond = builder.NewCond()
}
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
opts.RepoCond = builder.Or(opts.RepoCond, repo_model.PublicRepoUnderPublicOwnerCond())
}
if opts.RepoCond != nil {
sess.And(opts.RepoCond)
@@ -402,22 +399,17 @@ func applyReviewRequestedCondition(sess db.Session, reviewRequestedID int64) {
From("team_user").
Where(builder.Eq{"team_user.uid": reviewRequestedID})
// if the review is approved or rejected, it should not be shown in the review requested list
maxReview := builder.Select("MAX(r.id)").
From("review as r").
Where(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})).
GroupBy("r.issue_id, r.reviewer_id, r.reviewer_team_id")
subQuery := builder.Select("review.issue_id").
From("review").
Where(builder.And(
builder.Eq{"review.type": ReviewTypeRequest},
builder.Or(
builder.Eq{"review.reviewer_id": reviewRequestedID},
builder.In("review.reviewer_team_id", existInTeamQuery),
),
builder.In("review.id", maxReview),
))
Where(builder.Eq{"review.type": ReviewTypeRequest}).
And(builder.Or(
builder.Eq{"review.reviewer_id": reviewRequestedID},
builder.In("review.reviewer_team_id", existInTeamQuery),
)).
// if the review is approved or rejected, it should not be shown in the review requested list
And(builder.NotExists(builder.Select("1").From("review as r").
Where(builder.Expr("r.issue_id = review.issue_id AND r.reviewer_id = review.reviewer_id AND r.reviewer_team_id = review.reviewer_team_id AND r.id > review.id").
And(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})))))
sess.Where("issue.poster_id <> ?", reviewRequestedID).
And(builder.In("issue.id", subQuery))
}

View File

@@ -39,12 +39,13 @@ func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList)
// FindMilestoneOptions contain options to get milestones
type FindMilestoneOptions struct {
db.ListOptions
RepoID int64
IsClosed optional.Option[bool]
Name string
SortType string
RepoCond builder.Cond
RepoIDs []int64
RepoID int64
IsClosed optional.Option[bool]
Name string
SortType string
RepoCond builder.Cond
RepoIDs []int64
ProjectID int64
}
func (opts FindMilestoneOptions) ToConds() builder.Cond {
@@ -64,6 +65,11 @@ func (opts FindMilestoneOptions) ToConds() builder.Cond {
if len(opts.Name) != 0 {
cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name))
}
if opts.ProjectID != 0 {
cond = cond.And(builder.In("id", builder.Select("issue.milestone_id").From("issue").
InnerJoin("project_issue", "project_issue.issue_id = issue.id").
Where(builder.Eq{"project_issue.project_id": opts.ProjectID})))
}
return cond
}

View File

@@ -378,7 +378,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
reviewersWritten := 0
for _, review := range reviews {
if maxReviewers > 0 && reviewersWritten > maxReviewers {
if maxReviewers > 0 && reviewersWritten >= maxReviewers {
break
}

View File

@@ -335,8 +335,8 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
"reviewer_team_id": 0,
}).
GroupBy("issue_id, reviewer_id")
// Get latest review of each reviewer, sorted in order they were made
if err := db.GetEngine(ctx).In("id", subQuery).OrderBy("review.updated_unix ASC").Find(&reviews); err != nil {
// Get latest review of each reviewer, sorted in order they were made. Joined because MySQL scans all reviews for "id IN (subquery)", xorm aliases the subquery "review".
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQuery, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&reviews); err != nil {
return nil, err
}
@@ -350,7 +350,7 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
"reviewer_team_id": 0,
}).
GroupBy("issue_id, reviewer_team_id")
if err := db.GetEngine(ctx).In("id", subQueryTeam).OrderBy("review.updated_unix ASC").Find(&teamReviewRequests); err != nil {
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQueryTeam, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&teamReviewRequests); err != nil {
return nil, err
}

View File

@@ -14,6 +14,7 @@ import (
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -403,6 +404,10 @@ func testGetApprovers(t *testing.T) {
approvers := pr.GetApprovers(t.Context())
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
assert.Equal(t, expected, approvers)
defer test.MockVariableValue(&setting.Repository.PullRequest.DefaultMergeMessageMaxApprovers, 1)()
approvers = pr.GetApprovers(t.Context())
assert.Equal(t, "Reviewed-by: User Five <user5@example.com>\n", approvers)
}
func testGetPullRequestByMergedCommit(t *testing.T) {

View File

@@ -285,23 +285,12 @@ func IsOfficialReviewer(ctx context.Context, issue *Issue, reviewer *user_model.
}
if rule == nil {
// if no rule is found, then user with write access can make official reviews
err := pr.LoadBaseRepo(ctx)
if err != nil {
if err := pr.LoadBaseRepo(ctx); err != nil {
return false, err
}
writeAccess, err := access_model.HasAccessUnit(ctx, reviewer, pr.BaseRepo, unit.TypeCode, perm.AccessModeWrite)
if err != nil {
return false, err
}
return writeAccess, nil
return access_model.HasAccessUnit(ctx, reviewer, pr.BaseRepo, unit.TypeCode, perm.AccessModeWrite)
}
official, err := git_model.IsUserOfficialReviewer(ctx, rule, reviewer)
if official || err != nil {
return official, err
}
return false, nil
return git_model.IsUserOfficialReviewer(ctx, rule, reviewer)
}
// IsOfficialReviewerTeam check if reviewer in this team can make official reviews in issue (counts towards required approvals)
@@ -313,66 +302,82 @@ func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organizatio
if err != nil {
return false, err
}
if pb == nil {
return false, nil
if pb != nil && pb.EnableApprovalsWhitelist {
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
}
if !pb.EnableApprovalsWhitelist {
return team.UnitAccessMode(ctx, unit.TypeCode) >= perm.AccessModeWrite, nil
}
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
return team.UnitAccessMode(ctx, unit.TypeCode) >= perm.AccessModeWrite, nil
}
// RecalculateReviewsOfficial re-evaluates the "official" flag of the latest approve
// and reject reviews of an issue against its pull request's current base branch.
// It must be called whenever the target branch changes, otherwise an approval that
// was official on the previous (possibly unprotected) branch would keep satisfying
// the new branch's protection rules.
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
if err := issue.LoadPullRequest(ctx); err != nil {
// updateReviewerOfficial keeps the official flag only on the reviewer's latest approval, rejection or review request, and only if the reviewer is official
func updateReviewerOfficial(ctx context.Context, issueID, reviewerID int64, official bool) error {
if _, err := db.GetEngine(ctx).Where(builder.Eq{"issue_id": issueID, "reviewer_id": reviewerID}).
NoAutoTime().Cols("official").Update(&Review{}); err != nil {
return err
}
if !official {
return nil
}
latest, err := GetReviewByIssueIDAndUserID(ctx, issueID, reviewerID)
if IsErrReviewNotExist(err) {
return nil
} else if err != nil {
return err
}
_, err = db.GetEngine(ctx).ID(latest.ID).NoAutoTime().Cols("official").Update(&Review{Official: true})
return err
}
// Clearing and restoring the official flags must happen atomically, otherwise a
// failure in between would leave the reviews without any official flag set.
// RecalculateReviewsOfficial re-evaluates the "official" flag of an issue's reviews and review requests
// against its pull request's current base branch. It must be called whenever the target branch changes,
// otherwise an approval that was official on the previous (possibly unprotected) branch would keep
// satisfying the new branch's protection rules.
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
return db.WithTx(ctx, func(ctx context.Context) error {
// Only the latest approve/reject review of each reviewer counts as official, so
// clear the flag on all of them first and restore it only where it still applies.
if _, err := db.GetEngine(ctx).
Where("issue_id = ?", issue.ID).
In("type", ReviewTypeApprove, ReviewTypeReject).
Cols("official").
Update(&Review{Official: false}); err != nil {
if _, err := db.GetEngine(ctx).Where("issue_id = ?", issue.ID).NoAutoTime().Cols("official").Update(&Review{}); err != nil {
return err
}
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
Types: []ReviewType{ReviewTypeApprove, ReviewTypeReject},
IssueID: issue.ID,
})
if err != nil {
reviewerIDs := make([]int64, 0, 5)
if err := db.GetEngine(ctx).Table("review").Cols("reviewer_id").
Where(builder.Eq{"issue_id": issue.ID, "original_author_id": 0}).
And(builder.In("type", ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest)).
And(builder.Neq{"reviewer_id": 0}).
Distinct("reviewer_id").Find(&reviewerIDs); err != nil {
return err
}
for _, review := range reviews {
if err := review.LoadReviewer(ctx); err != nil {
return err
}
if review.Reviewer == nil {
continue
}
official, err := IsOfficialReviewer(ctx, issue, review.Reviewer)
for _, reviewerID := range reviewerIDs {
_, reviewer, err := user_model.GetPossibleUserByID(ctx, reviewerID)
if err != nil {
return err
}
if official {
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(&Review{Official: true}); err != nil {
return err
}
official, err := IsOfficialReviewer(ctx, issue, reviewer)
if err != nil {
return err
}
if err := updateReviewerOfficial(ctx, issue.ID, reviewerID, official); err != nil {
return err
}
}
teamRequests := make([]*Review, 0, 5)
if err := db.GetEngine(ctx).Where(builder.Eq{"issue_id": issue.ID, "type": ReviewTypeRequest}).
And(builder.Gt{"reviewer_team_id": 0}).Find(&teamRequests); err != nil {
return err
}
for _, request := range teamRequests {
if err := request.LoadReviewerTeam(ctx); organization.IsErrTeamNotExist(err) {
continue
} else if err != nil {
return err
}
official, err := IsOfficialReviewerTeam(ctx, issue, request.ReviewerTeam)
if err != nil {
return err
}
if _, err := db.GetEngine(ctx).ID(request.ID).NoAutoTime().Cols("official").Update(&Review{Official: official}); err != nil {
return err
}
}
return nil
})
}
@@ -474,7 +479,13 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
defer committer.Close()
sess := db.GetEngine(ctx)
isApproveOrReject := reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject
official := false
if isApproveOrReject {
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
return nil, nil, err
}
}
review, err := GetCurrentReview(ctx, doer, issue)
if err != nil {
@@ -486,16 +497,6 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
return nil, nil, ContentEmptyErr{}
}
if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
// Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
return nil, nil, err
}
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
return nil, nil, err
}
}
// No current review. Create a new one!
if review, err = CreateReview(ctx, CreateReviewOptions{
Type: reviewType,
@@ -516,16 +517,6 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
return nil, nil, ContentEmptyErr{}
}
if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
// Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
return nil, nil, err
}
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
return nil, nil, err
}
}
review.Official = official
review.Issue = issue
review.Content = content
@@ -546,6 +537,16 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
}
}
if isApproveOrReject {
if err := updateReviewerOfficial(ctx, issue.ID, doer.ID, official); err != nil {
return nil, nil, err
}
}
if reviewType == ReviewTypePending {
return review, nil, committer.Commit()
}
comm, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeReview,
Doer: doer,
@@ -560,7 +561,7 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
}
// try to remove team review request if need
if issue.Repo.Owner.IsOrganization() && (reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject) {
if issue.Repo.Owner.IsOrganization() && isApproveOrReject {
teamReviewRequests := make([]*Review, 0, 10)
if err := sess.SQL("SELECT * FROM review WHERE issue_id = ? AND reviewer_team_id > 0 AND type = ?", issue.ID, ReviewTypeRequest).Find(&teamReviewRequests); err != nil {
return nil, nil, err
@@ -698,8 +699,6 @@ func InsertReviews(ctx context.Context, reviews []*Review) error {
// AddReviewRequest add a review request from one reviewer
func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User, isCodeOwners bool) (*Comment, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
sess := db.GetEngine(ctx)
review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
if err != nil && !IsErrReviewNotExist(err) {
return nil, err
@@ -725,15 +724,9 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
}
}
// if the reviewer is an official reviewer,
// remove the official flag in the all previous reviews
official, err := IsOfficialReviewer(ctx, issue, reviewer)
if err != nil {
return nil, err
} else if official {
if _, err := sess.Exec("UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, reviewer.ID); err != nil {
return nil, err
}
}
review, err = CreateReview(ctx, CreateReviewOptions{
@@ -747,6 +740,10 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
return nil, err
}
if err := updateReviewerOfficial(ctx, issue.ID, reviewer.ID, official); err != nil {
return nil, err
}
comment, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeReviewRequest,
Doer: doer,
@@ -787,10 +784,9 @@ func RemoveReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user
official, err := IsOfficialReviewer(ctx, issue, reviewer)
if err != nil {
return nil, err
} else if official {
if err := restoreLatestOfficialReview(ctx, issue.ID, reviewer.ID); err != nil {
return nil, err
}
}
if err := updateReviewerOfficial(ctx, issue.ID, reviewer.ID, official); err != nil {
return nil, err
}
return CreateComment(ctx, &CreateCommentOptions{
@@ -804,22 +800,6 @@ func RemoveReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user
})
}
// Recalculate the latest official review for reviewer
func restoreLatestOfficialReview(ctx context.Context, issueID, reviewerID int64) error {
review, err := GetReviewByIssueIDAndUserID(ctx, issueID, reviewerID)
if err != nil && !IsErrReviewNotExist(err) {
return err
}
if review != nil {
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
return err
}
}
return nil
}
// AddTeamReviewRequest add a review request from one team
func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User, isCodeOwners bool) (*Comment, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
@@ -836,10 +816,6 @@ func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organizat
official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
if err != nil {
return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err)
} else if !official {
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
return nil, fmt.Errorf("isOfficialReviewer(): %w", err)
}
}
if review, err = CreateReview(ctx, CreateReviewOptions{
@@ -852,12 +828,6 @@ func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organizat
return nil, err
}
if official {
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_team_id=?", false, issue.ID, reviewer.ID); err != nil {
return nil, err
}
}
comment, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeReviewRequest,
Doer: doer,
@@ -892,25 +862,6 @@ func RemoveTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organi
return nil, err
}
official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
if err != nil {
return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err)
}
if official {
// recalculate which is the latest official review from that team
review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, -reviewer.ID)
if err != nil && !IsErrReviewNotExist(err) {
return nil, err
}
if review != nil {
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
return nil, err
}
}
}
if doer == nil {
return nil, nil
}
@@ -1037,10 +988,8 @@ func DeleteReview(ctx context.Context, r *Review) error {
return err
}
if r.Official {
if err := restoreLatestOfficialReview(ctx, r.IssueID, r.ReviewerID); err != nil {
return err
}
if r.Official && r.OriginalAuthorID == 0 {
return updateReviewerOfficial(ctx, r.IssueID, r.ReviewerID, true)
}
return nil
})

View File

@@ -9,6 +9,7 @@ import (
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
@@ -440,3 +441,17 @@ func TestRecalculateReviewsOfficial(t *testing.T) {
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
assert.True(t, review.Official)
}
func TestTeamReviewRequestOfficial(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
assert.NoError(t, issue.LoadRepo(t.Context()))
comment, err := issues_model.AddTeamReviewRequest(t.Context(), issue, unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}), &user_model.User{ID: 2}, false)
assert.NoError(t, err)
assert.True(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: comment.ReviewID}).Official)
assert.NoError(t, db.Insert(t.Context(), &git_model.ProtectedBranch{RepoID: issue.RepoID, RuleName: "master", EnableApprovalsWhitelist: true}))
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
assert.False(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: comment.ReviewID}).Official)
}

View File

@@ -70,7 +70,7 @@ func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
func (opts FindOrgOptions) ToConds() builder.Cond {
var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization}
if opts.UserID > 0 {
if opts.UserID != 0 {
cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate)))
}
// public=0, limited=1, private=2

View File

@@ -13,12 +13,12 @@ import (
"xorm.io/builder"
)
// TeamRepo represents an team-repository relation.
// TeamRepo represents an org-team-repository relation.
type TeamRepo struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"`
TeamID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
}
// HasTeamRepo returns true if given repository belongs to team.

View File

@@ -12,12 +12,12 @@ import (
"xorm.io/builder"
)
// TeamUser represents an team-user relation.
// TeamUser represents a team-user relation.
type TeamUser struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"`
TeamID int64 `xorm:"UNIQUE(s)"`
UID int64 `xorm:"UNIQUE(s)"`
UID int64 `xorm:"UNIQUE(s) INDEX"`
}
// IsTeamMember returns true if given user is a member of team.

View File

@@ -122,8 +122,8 @@ func GetTotalUnreferencedBlobSize(ctx context.Context) (int64, error) {
}
// IsBlobAccessibleForUser tests if the user has access to the blob
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User) (bool, error) {
if user.IsAdmin {
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User, publicOnly bool) (bool, error) {
if user.IsAdmin && !publicOnly {
return true, nil
}
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
@@ -153,6 +153,9 @@ func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
)
if publicOnly {
cond = cond.And(builder.Eq{"`user`.visibility": structs.VisibleTypePublic})
}
return db.GetEngine(ctx).
Table("package_blob").

View File

@@ -64,11 +64,15 @@ func TestIsBlobAccessibleForRestrictedUser(t *testing.T) {
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
require.NoError(t, err)
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}))
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), false)
require.NoError(t, err)
assert.True(t, accessible)
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}))
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}), false)
require.NoError(t, err)
assert.False(t, accessible)
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), true)
require.NoError(t, err)
assert.False(t, accessible)
}

View File

@@ -30,9 +30,9 @@ const (
// PackageProperty represents a property of a package, version or file
type PackageProperty struct {
ID int64 `xorm:"pk autoincr"`
RefType PropertyType `xorm:"INDEX NOT NULL"`
RefID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"INDEX NOT NULL"`
RefType PropertyType `xorm:"NOT NULL"`
RefID int64 `xorm:"INDEX(ref_id_name) NOT NULL"`
Name string `xorm:"INDEX(ref_id_name) NOT NULL"`
Value string `xorm:"LONGTEXT NOT NULL"`
}

View File

@@ -25,7 +25,7 @@ import (
type Access struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
Mode perm.AccessMode
}

View File

@@ -506,13 +506,16 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos
}
}
// if user in an owner team
// Apply repository-level access from teams.
for _, team := range perm.orgRepoTeams {
if team.IsOwnerTeam() || team.AccessMode == perm_model.AccessModeOwner {
perm.AccessMode = perm_model.AccessModeOwner
perm.unitsMode = nil
return perm, nil
}
if team.AccessMode == perm_model.AccessModeAdmin {
perm.AccessMode = max(perm.AccessMode, perm_model.AccessModeAdmin)
}
}
for _, u := range repo.Units {

View File

@@ -236,6 +236,32 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
require.Empty(t, users)
})
t.Run("DoerWithAdminTeamWithoutAccessRow", func(t *testing.T) {
_, err := db.GetEngine(ctx).ID(team.ID).Cols("authorize").Update(&organization.Team{AccessMode: perm_model.AccessModeAdmin})
require.NoError(t, err)
defer func() {
_, err := db.GetEngine(ctx).ID(team.ID).Cols("authorize").Update(&organization.Team{AccessMode: perm_model.AccessModeNone})
require.NoError(t, err)
}()
for _, repo := range []*repo_model.Repository{repo3, repo32} {
perm, err := GetIndividualUserRepoPermission(ctx, repo, user)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeAdmin, perm.AccessMode)
assert.True(t, perm.IsAdmin())
assert.False(t, perm.IsOwner())
for _, repoUnit := range repo.Units {
assert.Equal(t, perm_model.AccessModeAdmin, perm.UnitAccessMode(repoUnit.Type))
}
}
unrelatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
require.Equal(t, org.ID, unrelatedRepo.OwnerID)
perm, err := GetIndividualUserRepoPermission(ctx, unrelatedRepo, user)
require.NoError(t, err)
assert.False(t, perm.IsAdmin())
})
require.NoError(t, db.Insert(ctx, repo_model.Collaboration{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
require.NoError(t, db.Insert(ctx, Access{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
t.Run("DoerWithReadIssueTeamAndWriteCollaboratorOnPrivateRepo", func(t *testing.T) {

View File

@@ -225,7 +225,7 @@ func (opts SearchOptions) ToConds() builder.Cond {
if opts.Type > 0 {
cond = cond.And(builder.Eq{"type": opts.Type})
}
if opts.OwnerID > 0 {
if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}

View File

@@ -42,6 +42,10 @@ func init() {
db.RegisterModel(new(Attachment))
}
func (a *Attachment) IsLinked() bool {
return a.IssueID > 0 || a.ReleaseID > 0 || a.CommentID > 0
}
// IncreaseDownloadCount is update download count + 1
func (a *Attachment) IncreaseDownloadCount(ctx context.Context) error {
// Update download count.

View File

@@ -151,7 +151,7 @@ func UpdateLanguageStats(ctx context.Context, repo *Repository, commitID string,
var topLang string
var s int64
for lang, size := range stats {
if size > s {
if size > s || (size == s && lang < topLang) {
s = size
topLang = lang
}

View File

@@ -0,0 +1,28 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo_test
import (
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateLanguageStatsPrimaryTie(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
require.NoError(t, repo_model.UpdateLanguageStats(t.Context(), repo, "1234567890", map[string]int64{"Go": 10, "C": 10}))
stats, err := repo_model.GetLanguageStats(t.Context(), repo)
require.NoError(t, err)
require.Len(t, stats, 2)
for _, stat := range stats {
assert.Equal(t, stat.Language == "C", stat.IsPrimary, stat.Language)
}
}

View File

@@ -494,10 +494,9 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin
repo.MustOwner(ctx)
if repo.Owner.IsOrganization() {
teams := make([]string, 0, 5)
_ = db.GetEngine(ctx).Table("team_repo").
_ = db.GetEngine(ctx).Select("team.lower_name").Table("team_repo").
Join("INNER", "team", "team.id = team_repo.team_id").
Where("team_repo.repo_id = ?", repo.ID).
Select("team.lower_name").
OrderBy("team.lower_name").
Find(&teams)
metas["teams"] = "," + strings.Join(teams, ",") + ","

View File

@@ -373,8 +373,7 @@ func SearchRepositoryCondition(opts SearchRepoOptions) builder.Cond {
cond := builder.NewCond()
if opts.Private {
if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID {
// OK we're in the context of a User
if opts.Actor != nil && !opts.Actor.IsAdmin {
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
}
} else {
@@ -762,7 +761,8 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
func PublicRepoUnderPublicOwnerCond() builder.Cond {
return builder.And(
builder.Eq{"`repository`.is_private": false},
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})),
builder.Exists(builder.Select("1").From("`user` repo_owner").
Where(builder.Expr("repo_owner.id = `repository`.owner_id").And(builder.Eq{"repo_owner.visibility": structs.VisibleTypePublic}))),
)
}

View File

@@ -77,7 +77,7 @@ func IsErrRepoAlreadyExist(err error) bool {
}
func (err ErrRepoAlreadyExist) Error() string {
return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name)
return fmt.Sprintf("repository already exists [owner: %s, repo: %s]", err.Uname, err.Name)
}
func (err ErrRepoAlreadyExist) Unwrap() error {

View File

@@ -39,7 +39,7 @@ const (
type Watch struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(watch)"`
RepoID int64 `xorm:"UNIQUE(watch)"`
RepoID int64 `xorm:"UNIQUE(watch) INDEX"`
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
@@ -112,6 +112,9 @@ func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
}
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
if watch.UserID <= 0 {
return nil
}
if watch.Mode == mode {
return nil
}

View File

@@ -34,7 +34,7 @@ func TestGetWatchers(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
assert.NoError(t, err)
// One watchers are inactive, thus minus 1
// One watcher is inactive, thus minus 1
assert.Len(t, watches, repo.NumWatches-1)
for _, watch := range watches {
assert.Equal(t, repo.ID, watch.RepoID)
@@ -90,24 +90,30 @@ func TestWatchIfAuto(t *testing.T) {
setting.Service.AutoWatchOnChanges = true
// Must not add watch
// Must not add watch (user 8 has "don't watch" set)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should not add watch
// Should not add watch (read operation)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Len(t, watchers, prevCount)
// Should add watch
// Should add watch (write operation)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Len(t, watchers, prevCount+1)
// Should not add watch (system user should not be able to watch)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), user_model.ActionsUserID, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Len(t, watchers, prevCount+1)
// Should remove watch, inhibit from adding auto
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})

View File

@@ -12,6 +12,7 @@ import (
"net/url"
"os"
"slices"
"strconv"
"strings"
"testing"
@@ -103,6 +104,10 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
require.NoError(t, err, "creating fixture %s", fixturePath)
defer out.Close()
if resp.StatusCode != http.StatusOK {
_, err = fmt.Fprintf(out, "HTTP/1.1 %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode))
require.NoError(t, err, "writing fixture header for %s", liveURL)
}
for _, name := range slices.Sorted(maps.Keys(resp.Header)) {
if slices.Contains(ignoredHeaders, strings.ToLower(name)) {
continue
@@ -125,14 +130,21 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL)
headers, body, _ := strings.Cut(replayed, "\n\n")
code := http.StatusOK
for line := range strings.SplitSeq(headers, "\n") {
if strings.HasPrefix(line, "HTTP/") {
parts := strings.SplitN(line, " ", 3)
require.Len(t, parts, 3)
code, _ = strconv.Atoi(parts[1])
continue
}
name, value, ok := strings.Cut(line, ": ")
if !ok || strings.EqualFold(name, "Content-Length") {
continue
}
w.Header().Set(name, value)
}
w.WriteHeader(http.StatusOK)
w.WriteHeader(code)
_, err = w.Write([]byte(body))
require.NoError(t, err)
})

View File

@@ -8,6 +8,7 @@ import (
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
@@ -32,6 +33,9 @@ func IsFollowing(ctx context.Context, userID, followID int64) bool {
// FollowUser marks someone be another's follower.
func FollowUser(ctx context.Context, user, follow *User) (err error) {
if !follow.IsFollowable() {
return util.NewInvalidArgumentErrorf("user %d can not be followed", follow.ID)
}
if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) {
return nil
}

View File

@@ -68,13 +68,20 @@ const (
UserTypeRemoteUser // 5
)
// AdminListedUserTypes HINT: USER-TYPE-GLOSSARY: administrators list every user type except organizations
var AdminListedUserTypes = []UserType{UserTypeIndividual, UserTypeUserReserved, UserTypeBot, UserTypeRemoteUser}
// DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead
func (t UserType) DisplayName() string {
switch t {
case UserTypeOrganization, UserTypeOrganizationReserved:
return "Organization"
case UserTypeUserReserved:
return "Reserved"
case UserTypeBot:
return "Bot"
case UserTypeRemoteUser:
return "Remote"
default:
return "User"
}
@@ -250,10 +257,10 @@ func (u *User) GetEmail() string {
return u.Email
}
// GetAllUsers returns a slice of all individual users found in DB.
func GetAllUsers(ctx context.Context) ([]*User, error) {
users := make([]*User, 0)
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users)
// GetAllUsersForAdminListing returns a slice of all users of AdminListedUserTypes found in DB.
func GetAllUsersForAdminListing(ctx context.Context) (users []*User, err error) {
err = db.GetEngine(ctx).OrderBy("id").In("type", AdminListedUserTypes).Find(&users)
return users, err
}
// IsLocal returns true if user login type is LoginPlain.
@@ -440,6 +447,18 @@ func (u *User) IsIndividual() bool {
return u.ID > 0 && u.Type == UserTypeIndividual
}
// IsFollowable returns whether other users can follow this user (individuals, bots and organizations)
func (u *User) IsFollowable() bool {
if u.ID <= 0 {
return false
}
switch u.Type {
case UserTypeIndividual, UserTypeBot, UserTypeOrganization:
return true
}
return false
}
// IsTypeBot returns whether the user is of type bot
func (u *User) IsTypeBot() bool {
return u.Type == UserTypeBot
@@ -1048,7 +1067,7 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err
}
// Glossaries for XxxUser:
// HINT: USER-TYPE-GLOSSARY: Glossaries for XxxUser:
// * Possible: The user may or may not exist in the database, but we can still return a valid user object (e.g. Ghost user) for the given ID.
// * Individual: The user is a real person, not an organization or a bot.
// * Doer: The user is the one performing an action

View File

@@ -11,7 +11,6 @@ import (
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/log"
)
@@ -36,19 +35,11 @@ func GetCommitActionsStatusMap(ctx context.Context, statuses []*git_model.Commit
return nil
}
statusByJobID := make(map[int64]*git_model.CommitStatus)
repoByID := make(map[int64]*repo_model.Repository)
for _, status := range statuses {
if status == nil || status.TargetURL == "" {
continue
}
if status.Repo == nil {
status.Repo = repoByID[status.RepoID]
}
// ParseGiteaActionsTargetURL lazy-loads status.Repo on miss; cache the
// outcome so later entries with the same RepoID skip that load.
_, jobID, ok := status.ParseGiteaActionsTargetURL(ctx)
repoByID[status.RepoID] = status.Repo
if ok {
if _, jobID, ok := status.ParseGiteaActionsTargetURL(ctx); ok {
statusByJobID[jobID] = status
}
}

View File

@@ -8,5 +8,6 @@ const (
EmailAvatarLink = "email_avatar_link"
UserEmailAddresses = "user_email_addresses"
GPGKeyWithSubKeys = "gpg_key_with_subkeys"
Repository = "repository"
RepoUserPermission = "repo_user_permission"
)

View File

@@ -52,7 +52,7 @@ func Lookup(label string) (e encoding.Encoding, name string) {
type ConvertOpts struct {
KeepBOM bool
ErrorReplacement []byte
ErrorReplacement []byte // leave it empty to remove the invalid bytes
ErrorReturnOrigin bool
}
@@ -99,7 +99,12 @@ func ToUTF8DropErrors(content []byte) []byte {
func ToUTF8(content []byte, opts ConvertOpts) []byte {
charsetLabel, _ := DetectEncoding(content)
if charsetLabel == "UTF-8" {
return maybeRemoveBOM(content, opts)
// even if the detected charset is UTF-8, the content may contain invalid UTF-8 sequences, so need to convert it to valid UTF-8
content = maybeRemoveBOM(content, opts)
if opts.ErrorReturnOrigin {
return content
}
return bytes.ToValidUTF8(content, opts.ErrorReplacement)
}
encoding, _ := Lookup(charsetLabel)
@@ -123,11 +128,7 @@ func ToUTF8(content []byte, opts ConvertOpts) []byte {
if opts.ErrorReturnOrigin {
return content
}
if opts.ErrorReplacement == nil {
decoded = append(decoded, content[idx+n])
} else {
decoded = append(decoded, opts.ErrorReplacement...)
}
decoded = append(decoded, opts.ErrorReplacement...)
idx += n + 1
}
return maybeRemoveBOM(decoded, opts)

View File

@@ -171,6 +171,8 @@ func TestToUTF8DropErrors(t *testing.T) {
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
assert.Equal(t, "caf ", string(ToUTF8DropErrors([]byte("caf\xe9"))))
}
func TestDetectEncoding(t *testing.T) {

View File

@@ -55,11 +55,9 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
es.allowed = opts[0].Allowed
}
readCount := 0
lastIsTag := false
isContentBegin := true
for {
parts, partInTag, err := es.readRunes()
readCount++
if err == io.EOF {
return es.escaped, nil
} else if err != nil {
@@ -67,17 +65,14 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
}
for i, part := range parts {
if partInTag[i] {
lastIsTag = true
isContentBegin = true
es.trackHtmlTag(part)
if _, err := out.Write(part); err != nil {
return nil, err
}
} else {
// if last part is tag, then this part is content begin
// if the content is the first part of the first read, then it's also content begin
isContentBegin := lastIsTag || (readCount == 1 && i == 0)
lastIsTag = false
if isContentBegin {
isContentBegin = false
if part, err = es.trimAndWriteBom(part); err != nil {
return nil, err
}
@@ -236,18 +231,23 @@ func (e *htmlChunkReader) readRunes() (parts [][]byte, partInTag []bool, _ error
copy(curPart, e.readBuf[pos:curPartEnd])
// now we get the curPart bytes, but we can't directly use it, the last rune in it might have been cut
// try to decode the last rune, if it's invalid, then we cut the last byte and try again until we get a valid rune or no byte left
for i := curPartLen - 1; i >= 0; i-- {
last, lastSize := utf8.DecodeRune(curPart[i:])
if last == utf8.RuneError && lastSize == 1 {
curPartLen--
} else {
curPartLen += lastSize - 1
break
if e.readErr == nil && curPartEnd == len(e.readBuf) {
for i := curPartLen - 1; i >= 0; i-- {
last, lastSize := utf8.DecodeRune(curPart[i:])
if last == utf8.RuneError && lastSize == 1 {
curPartLen--
} else {
curPartLen += lastSize - 1
break
}
}
}
if curPartLen == 0 {
if !utf8.FullRune(curPart) && len(curPart) <= cap(e.readBuf)*3/4 {
break // keep a rune cut by the read boundary for the next read
}
// actually it's impossible that the part doesn't contain any valid rune,
// the only case is that the cap(readBuf) is too small, or the origin contain indeed doesn't contain any valid rune
// the only case is that the cap(readBuf) is too small, or the origin content indeed doesn't contain any valid rune
// * try to leave the last 4 bytes (possible longest utf-8 encoding) to next round
// * at least consume 1 byte to avoid infinite loop
curPartLen = max(len(curPart)-utf8.UTFMax, 1)

View File

@@ -6,6 +6,7 @@ package charset
import (
"strings"
"testing"
"testing/iotest"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/setting"
@@ -160,6 +161,11 @@ func TestEscapeControlReader(t *testing.T) {
assert.Equal(t, tt.result, outStr)
})
}
output := &htmlutil.HTMLBuilder{}
_, err := EscapeControlReader(iotest.OneByteReader(strings.NewReader("\xef\xbb\xbftest")), output, &translation.MockLocale{})
assert.NoError(t, err)
assert.Equal(t, "\xef\xbb\xbftest", output.String())
}
func TestTrackHtmlTag(t *testing.T) {
@@ -234,4 +240,11 @@ func TestHTMLChunkReader(t *testing.T) {
testReadChunks(t, 5, input, []textPart{{"<Aé", true}, {"啊", true}, {"🌞>", true}, {"Aé", false}, {"啊", false}, {"🌞", false}})
testReadChunks(t, 6, input, []textPart{{"<Aé", true}, {"啊", true}, {"🌞>", true}, {"A", false}, {"é啊", false}, {"🌞", false}})
testReadChunks(t, 7, input, []textPart{{"<Aé啊", true}, {"🌞>", true}, {"A", false}, {"é啊", false}, {"🌞", false}})
for chunkSize := 6; chunkSize < 5+len(rune4); chunkSize++ {
testReadChunks(t, chunkSize, "<abc>"+rune4, []textPart{{"<abc>", true}, {rune4, false}})
}
testReadChunks(t, 6, "<abc>"+rune3[:2], []textPart{{"<abc>", true}, {rune3[:2], false}})
testReadChunks(t, 7, "<abc>"+rune3[:2]+"<b>", []textPart{{"<abc>", true}, {rune3[:2], false}, {"<b>", true}})
testReadChunks(t, 1, rune2, []textPart{{rune2[:1], false}, {rune2[1:], false}})
}

View File

@@ -5,6 +5,7 @@ package git
import (
"bufio"
"cmp"
"context"
"fmt"
"io"
@@ -200,22 +201,18 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
}
if old {
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
end, _ = strconv.ParseInt(groups["endOld"], 10, 64)
end, _ = strconv.ParseInt(cmp.Or(groups["endOld"], "1"), 10, 64)
// init otherLine with begin of opposite side
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
} else {
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
if groups["endNew"] != "" {
end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
} else {
end = 0
}
end, _ = strconv.ParseInt(cmp.Or(groups["endNew"], "1"), 10, 64)
// init otherLine with begin of opposite side
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
}
end += begin // end is for real only the number of lines in hunk
// lof is between begin and end
if begin <= line && end >= line {
if begin <= line && line < end {
hunk = append(hunk, lof)
currentLine = begin
continue
@@ -265,27 +262,26 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
oldBegin = otherLine
newBegin = currentLine
}
// headers + hunk header
newHunk := make([]string, headerLines)
// transfer existing headers
copy(newHunk, hunk[:headerLines])
// transfer last n lines
newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
// calculate newBegin, ... by counting lines
for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- {
switch hunk[i][0] {
start := len(hunk)
for count := 0; count < numbersOfLine && start > headerLines+1; {
start--
switch hunk[start][0] {
case '+':
newBegin--
newNumOfLines++
case '-':
oldBegin--
oldNumOfLines++
case '\\':
continue
default:
oldBegin--
newBegin--
newNumOfLines++
oldNumOfLines++
}
count++
}
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
@@ -294,9 +290,9 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
// construct the new hunk header
newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
hunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
oldBegin, oldNumOfLines, newBegin, newNumOfLines)
return strings.Join(newHunk, "\n"), nil
return strings.Join(append(hunk[:headerLines+1], hunk[start:]...), "\n"), nil
}
// GetAffectedFiles returns the affected files between two commits
@@ -312,31 +308,18 @@ func GetAffectedFiles(ctx context.Context, repo *Repository, branchName, oldComm
oldCommitID = startCommitID
}
affectedFiles := make([]string, 0, 32)
// Run `git diff --name-only` to get the names of the changed files
cmd := gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID)
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := cmd.WithEnv(env).WithRepo(repo).
WithPipelineFunc(func(ctx gitcmd.Context) error {
// Now scan the output from the command
scanner := bufio.NewScanner(stdoutReader)
for scanner.Scan() {
path := strings.TrimSpace(scanner.Text())
if len(path) == 0 {
continue
}
affectedFiles = append(affectedFiles, path)
}
return scanner.Err()
}).
Run(ctx)
// use "diff-tree -r -z": paths are NUL-separated (never quoted) and renames report both the old and new path
cmd := gitcmd.NewCommand("diff-tree", "--name-only", "--no-commit-id", "-r", "-z").AddDynamicArguments(oldCommitID, newCommitID)
stdout, _, err := cmd.WithEnv(env).WithRepo(repo).RunStdString(ctx)
if err != nil {
log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.LogString(), err)
return nil, err
}
return affectedFiles, err
affectedFiles := strings.Split(strings.TrimSuffix(stdout, "\x00"), "\x00")
if len(affectedFiles) == 1 && affectedFiles[0] == "" {
affectedFiles = nil
}
return affectedFiles, nil
}
// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.

View File

@@ -4,10 +4,14 @@
package git
import (
"path/filepath"
"strings"
"testing"
"gitea.dev/modules/git/gitcmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const exampleDiff = `diff --git a/README.md b/README.md
@@ -120,10 +124,18 @@ func TestCutDiffAroundLine(t *testing.T) {
assert.Empty(t, emptyResult)
// Line is out of scope
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0)
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 7, false, 3)
assert.NoError(t, err)
assert.Empty(t, emptyResult)
noNewlineDiff := "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+a"
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, false, 2)
assert.NoError(t, err)
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,1 +1,1 @@\n-a\n\\ No newline at end of file\n+a", result)
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, true, 2)
assert.NoError(t, err)
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a", result)
// Handle minus diffs properly
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
assert.NoError(t, err)
@@ -182,3 +194,40 @@ func TestParseDiffHunkString(t *testing.T) {
assert.Equal(t, 19, rightLine)
assert.Equal(t, 5, rightHunk)
}
func TestGetAffectedFilesSpecialNames(t *testing.T) {
repoDir := filepath.Join(t.TempDir(), "repo.git")
require.NoError(t, gitcmd.NewCommand("init").AddDynamicArguments(repoDir).Run(t.Context()))
stdin := `commit refs/heads/main
committer User <user@example.com> 1714310400 +0000
data 1
a
M 100644 inline base.txt
data 1
x
commit refs/heads/main
committer User <user@example.com> 1714310401 +0000
data 1
b
M 100644 inline "protected/we\"ird.md"
data 1
x
M 100644 inline "protected/\344\270\255\346\226\207.md"
data 1
x
M 100644 inline protected/normal.md
data 1
x
R base.txt protected/renamed.md
`
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
repo, err := OpenRepository(t.Context(), mockRepository(repoDir))
require.NoError(t, err)
defer repo.Close()
files, err := GetAffectedFiles(t.Context(), repo, "main", "main~1", "main", nil)
require.NoError(t, err)
assert.ElementsMatch(t, []string{`protected/we"ird.md`, "protected/中文.md", "protected/normal.md", "base.txt", "protected/renamed.md"}, files)
}

View File

@@ -71,12 +71,12 @@ func DefaultFeatures() *Features {
func loadGitVersionFeatures() (*Features, error) {
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
if runErr != nil {
return nil, runErr
return nil, fmt.Errorf(`failed to run command "git version": %w`, runErr)
}
ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
if err != nil {
return nil, err
return nil, fmt.Errorf(`failed to parse output of command "git version": %w`, err)
}
features := &Features{gitVersion: ver, UsingGogit: isGogit}
@@ -108,7 +108,11 @@ func parseGitVersionLine(s string) (*version.Version, error) {
if len(versionFields) > 3 {
versionFields = versionFields[:3]
}
return version.NewVersion(strings.Join(versionFields, "."))
ver, err := version.NewVersion(strings.Join(versionFields, "."))
if err != nil {
return nil, fmt.Errorf("invalid git version: %q: %w", s, err)
}
return ver, nil
}
func checkGitVersionCompatibility(gitVer *version.Version) error {

View File

@@ -93,6 +93,7 @@ const (
StderrPathSpec StderrPrefix = "fatal: pathspec"
StderrBadRevision StderrPrefix = "fatal: bad revision"
StderrNoSuchPath StderrPrefix = "fatal: no such path"
StderrBadObject StderrPrefix = "fatal: bad object "
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"path"
"strconv"
@@ -60,11 +61,13 @@ func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) {
header.Set("X-Content-Type-Options", "nosniff")
csp := serveHeaderCspDefault
if strings.HasPrefix(contentType, "application/pdf") {
csp = serveHeaderCspPdf
}
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
csp = serveHeaderCspAudioVideo
if mediaType, _, err := mime.ParseMediaType(contentType); err == nil { // browsers may parse malformed types differently
if mediaType == "application/pdf" {
csp = serveHeaderCspPdf
}
if strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/") {
csp = serveHeaderCspAudioVideo
}
}
if csp != "" {
header.Set("Content-Security-Policy", csp)

View File

@@ -117,9 +117,10 @@ func TestServeSetHeaderContentRelated(t *testing.T) {
{"", serveHeaderCspDefault},
{"any", serveHeaderCspDefault},
{"application/pdf", serveHeaderCspPdf},
{"application/pdf; other", serveHeaderCspPdf},
{"application/pdf; charset=binary", serveHeaderCspPdf},
{"audio/mp4", serveHeaderCspAudioVideo},
{"video/ogg; other", serveHeaderCspAudioVideo},
{"video/ogg; codecs=theora", serveHeaderCspAudioVideo},
{"video/mp4;,text/html", serveHeaderCspDefault},
{typesniffer.MimeTypeImageSvg, serveHeaderCspDefault},
}
for _, c := range cases {

View File

@@ -9,6 +9,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/modules/git/languagestats"
"gitea.dev/modules/globallock"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/process"
@@ -23,6 +24,13 @@ func (db *DBIndexer) Index(id int64) error {
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().ShutdownContext(), fmt.Sprintf("Stats.DB Index Repo[%d]", id))
defer finished()
// the unique queue doesn't stop two workers from indexing the same repository at once
releaser, err := globallock.Lock(ctx, fmt.Sprintf("repo_stats_index_%d", id))
if err != nil {
return err
}
defer releaser()
repo, err := repo_model.GetRepositoryByID(ctx, id)
if err != nil {
return err

View File

@@ -23,7 +23,7 @@ type Decoder interface {
// Interface represents an interface to handle json data
type Interface interface {
Marshal(v any) ([]byte, error)
MarshalWrite(w io.Writer, v any) error
MarshalWriteRelaxed(w io.Writer, v any) error
Unmarshal(data []byte, v any) error
NewEncoder(writer io.Writer) Encoder
NewDecoder(reader io.Reader) Decoder
@@ -37,9 +37,10 @@ func Marshal(v any) ([]byte, error) {
return DefaultJSONHandler.Marshal(v)
}
// MarshalWrite writes the JSON encoding of v to the given writer
func MarshalWrite(w io.Writer, v any) error {
return DefaultJSONHandler.MarshalWrite(w, v)
// MarshalWriteRelaxed writes the JSON encoding of v to the given writer
// It tolerates invalid UTF-8 in strings and replaces them with the Unicode replacement character U+FFFD.
func MarshalWriteRelaxed(w io.Writer, v any) error {
return DefaultJSONHandler.MarshalWriteRelaxed(w, v)
}
// Unmarshal decodes object from bytes

View File

@@ -13,6 +13,7 @@ import (
type JSONv2 struct {
marshalOptions jsonv2.Options
marshalRelaxedOptions jsonv2.Options
marshalKeepOptionalEmptyOptions jsonv2.Options
unmarshalOptions jsonv2.Options
unmarshalCaseInsensitiveOptions jsonv2.Options
@@ -26,6 +27,8 @@ func init() {
jsonv2.FormatNilMapAsNull(true),
}
jsonV2.marshalOptions = jsonv2.JoinOptions(commonMarshalOptions...)
// when handling user's data, we want to allow invalid UTF-8 to be replaced with the replacement character, rather than returning an error.
jsonV2.marshalRelaxedOptions = jsonv2.JoinOptions(append(commonMarshalOptions, jsontext.AllowInvalidUTF8(true))...)
jsonV2.unmarshalOptions = jsonv2.DefaultOptionsV2()
// By default, "json/v2" omitempty removes all `""` empty strings, no matter where it comes from.
@@ -53,8 +56,8 @@ func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions)
}
func (j *JSONv2) MarshalWrite(w io.Writer, v any) error {
return jsonv2.MarshalWrite(w, v, j.marshalOptions)
func (j *JSONv2) MarshalWriteRelaxed(w io.Writer, v any) error {
return jsonv2.MarshalWrite(w, v, j.marshalRelaxedOptions)
}
func (j *JSONv2) Unmarshal(data []byte, v any) error {

View File

@@ -22,7 +22,7 @@ type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
// Client is used to communicate with a LFS source
type Client interface {
BatchSize() int
Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error
Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error // ref is the full git ref name the objects belong to, may be empty
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
}

View File

@@ -38,7 +38,7 @@ func (c *FilesystemClient) objectPath(oid string) (string, error) {
}
// Download reads the specific LFS object from the target path
func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
func (c *FilesystemClient) Download(ctx context.Context, _ string, objects []Pointer, callback DownloadCallback) error {
for _, object := range objects {
p := Pointer{object.Oid, object.Size}

View File

@@ -67,18 +67,18 @@ func (c *HTTPClient) transferNames() []string {
return keys
}
func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
func (c *HTTPClient) batch(ctx context.Context, operation, ref string, objects []Pointer) (*BatchResponse, error) {
log.Trace("BATCH operation with objects: %v", objects)
url := c.endpoint + "/objects/batch"
// Original: In some lfs server implementations, they require the ref attribute. #32838
// `ref` is an "optional object describing the server ref that the objects belong to"
// but some (incorrect) lfs servers like aliyun require it, so maybe adding an empty ref here doesn't break the correct ones.
// `ref` is optional, but some servers like aliyun require it, while others like Hugging Face reject an empty or null one
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
//
// UPDATE: it can't use "empty ref" here because it breaks others like https://github.com/go-gitea/gitea/issues/33453
request := &BatchRequest{operation, c.transferNames(), nil, objects}
// Related issues: #32838, #33453, #39891
request := &BatchRequest{Operation: operation, Transfers: c.transferNames(), Objects: objects}
if ref != "" {
request.Ref = &Reference{Name: ref}
}
payload := new(bytes.Buffer)
err := json.NewEncoder(payload).Encode(request)
@@ -113,17 +113,17 @@ func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Poin
}
// Download reads the specific LFS object from the LFS server
func (c *HTTPClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
return c.performOperation(ctx, objects, callback, nil)
func (c *HTTPClient) Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error {
return c.performOperation(ctx, ref, objects, callback, nil)
}
// Upload sends the specific LFS object to the LFS server
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
return c.performOperation(ctx, objects, nil, callback)
return c.performOperation(ctx, "", objects, nil, callback)
}
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
func (c *HTTPClient) performOperation(ctx context.Context, ref string, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
if len(objects) == 0 {
return nil
}
@@ -133,7 +133,7 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
operation = "upload"
}
result, err := c.batch(ctx, operation, objects)
result, err := c.batch(ctx, operation, ref, objects)
if err != nil {
return err
}

View File

@@ -175,6 +175,7 @@ func TestHTTPClientDownload(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "download", batchRequest.Operation)
assert.Equal(t, &Reference{Name: "refs/heads/main"}, batchRequest.Ref)
assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
@@ -248,7 +249,7 @@ func TestHTTPClientDownload(t *testing.T) {
},
}
err := client.Download(t.Context(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
err := client.Download(t.Context(), "refs/heads/main", []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
if objectError != nil {
return objectError
}
@@ -283,6 +284,7 @@ func TestHTTPClientUpload(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "upload", batchRequest.Operation)
assert.Nil(t, batchRequest.Ref)
assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)

View File

@@ -153,8 +153,6 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
issueIndexPatternProcessor,
commitCrossReferencePatternProcessor,
hashCurrentPatternProcessor,
emojiShortCodeProcessor,
emojiProcessor,
linkProcessor,
}
@@ -175,6 +173,9 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
node.DataAtom = atom.A
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}}
node.FirstChild, node.LastChild = ch, ch
for n := node.FirstChild; n != nil; {
n = visitNode(ctx, emojiProcessors, n)
}
})
rendered := postProcessHTML(ctx, procs, htmlutil.EscapeString(content))
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)

View File

@@ -31,8 +31,9 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
// Chroma always uses 1-2 letters for style names, we could tolerate it at the moment
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^\w{0,2}$`)).OnElements("span")
// Line numbers on codepreview
// Line numbers and paste behavior on codepreview
policy.AllowAttrs("data-line-number").OnElements("span")
policy.AllowAttrs("data-paste-markdown-skip").OnElements("table")
// emoji aliases for dark theme inversion
policy.AllowAttrs("data-alias").OnElements("span")

View File

@@ -49,6 +49,8 @@ func TestSanitizer(t *testing.T) {
`<p style="bad-color: red">Hello World</p>`, `<p>Hello World</p>`,
`<code style="bad-color: red">Hello World</code>`, `<code>Hello World</code>`,
`<table data-paste-markdown-skip><tr><td>1</td></tr></table>`, `<table data-paste-markdown-skip=""><tr><td>1</td></tr></table>`,
// Org mode status of list items.
`<li class="checked"></li>`, `<li class="checked"></li>`,
`<li class="unchecked"></li>`, `<li class="unchecked"></li>`,

View File

@@ -4,17 +4,28 @@
package optional
import (
"reflect"
"gitea.dev/modules/json"
"go.yaml.in/yaml/v4"
)
func (o *Option[T]) assignUnmarshalledValue(v *T) {
if reflect.TypeFor[T]().Kind() == reflect.Pointer && v == nil {
var zero T
*o = Some[T](zero)
} else {
*o = FromPtr(v)
}
}
func (o *Option[T]) UnmarshalJSON(data []byte) error {
var v *T
if err := json.Unmarshal(data, &v); err != nil {
return err
}
*o = FromPtr(v)
o.assignUnmarshalledValue(v)
return nil
}
@@ -31,7 +42,7 @@ func (o *Option[T]) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&v); err != nil {
return err
}
*o = FromPtr(v)
o.assignUnmarshalledValue(v)
return nil
}

View File

@@ -6,6 +6,7 @@ package optional_test
import (
std_json "encoding/json" //nolint:depguard // for testing purpose
"testing"
"time"
"gitea.dev/modules/json"
"gitea.dev/modules/optional"
@@ -28,6 +29,14 @@ type testSerializationStruct struct {
OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"`
}
type testSerializationNullValue struct {
OptString optional.Option[string] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
}
type testSerializationNullPointer struct {
OptNull optional.Option[*time.Time] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
}
func TestOptionalToJson(t *testing.T) {
tests := []struct {
name string
@@ -63,6 +72,32 @@ func TestOptionalToJson(t *testing.T) {
assert.Equal(t, tc.want, string(b), "std json module returned unexpected")
})
}
t.Run("NullValue", func(t *testing.T) {
var v1 testSerializationNullValue
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
assert.NoError(t, err)
assert.False(t, v1.OptString.Has())
})
t.Run("NullPointer", func(t *testing.T) {
var v1 testSerializationNullPointer
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
assert.NoError(t, err)
assert.True(t, v1.OptNull.Has())
assert.Nil(t, v1.OptNull.Value())
var v2 testSerializationNullPointer
err = std_json.Unmarshal([]byte(`{}`), &v2)
assert.NoError(t, err)
assert.False(t, v2.OptNull.Has())
var v3 testSerializationNullPointer
err = std_json.Unmarshal([]byte(`{"opt_null":"2006-01-02T15:04:05Z"}`), &v3)
assert.NoError(t, err)
assert.True(t, v3.OptNull.Has())
assert.NotNil(t, v3.OptNull.Value())
})
}
func TestOptionalFromJson(t *testing.T) {

View File

@@ -15,6 +15,7 @@ import (
"regexp"
"strings"
"gitea.dev/modules/charset"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
"gitea.dev/modules/validation"
@@ -261,6 +262,7 @@ func ParsePackage(r ReadSeekAt, optVersion ...string) (*PackageInfo, error) {
cj.Readme = "README.md"
}
dataReadmeMd, _ := readPackageFile(pkgExt, r, cj.Readme, 10*1024)
dataReadmeMd = charset.ToUTF8DropErrors(dataReadmeMd)
// FIXME: legacy problem, the "Readme" field is abused, it should always be the path to the readme file
if len(dataReadmeMd) == 0 {

View File

@@ -4,8 +4,6 @@
package composer
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"io"
@@ -13,6 +11,7 @@ import (
"testing"
"gitea.dev/modules/json"
"gitea.dev/modules/test"
"github.com/dsnet/compress/bzip2"
"github.com/stretchr/testify/assert"
@@ -76,37 +75,8 @@ func TestCommentsUnmarshal(t *testing.T) {
}
func TestParsePackage(t *testing.T) {
createArchive := func(files map[string]string) []byte {
var buf bytes.Buffer
archive := zip.NewWriter(&buf)
for name, content := range files {
w, _ := archive.Create(name)
_, _ = w.Write([]byte(content))
}
_ = archive.Close()
return buf.Bytes()
}
createArchiveTar := func(comp func(io.Writer) io.WriteCloser, files map[string]string) []byte {
var buf bytes.Buffer
w := comp(&buf)
archive := tar.NewWriter(w)
for name, content := range files {
hdr := &tar.Header{
Name: name,
Mode: 0o600,
Size: int64(len(content)),
}
_ = archive.WriteHeader(hdr)
_, _ = archive.Write([]byte(content))
}
_ = w.Close()
_ = archive.Close()
return buf.Bytes()
}
t.Run("MissingComposerFile", func(t *testing.T) {
data := createArchive(map[string]string{"dummy.txt": ""})
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp)
@@ -114,7 +84,7 @@ func TestParsePackage(t *testing.T) {
})
t.Run("MissingComposerFileInRoot", func(t *testing.T) {
data := createArchive(map[string]string{"sub/sub/composer.json": ""})
data := test.WriteZipArchive(map[string]string{"sub/sub/composer.json": ""}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp)
@@ -122,7 +92,7 @@ func TestParsePackage(t *testing.T) {
})
t.Run("InvalidComposerFile", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": ""})
data := test.WriteZipArchive(map[string]string{"composer.json": ""}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp)
@@ -130,7 +100,7 @@ func TestParsePackage(t *testing.T) {
})
t.Run("InvalidPackageName", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": "{}"})
data := test.WriteZipArchive(map[string]string{"composer.json": "{}"}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp)
@@ -138,7 +108,7 @@ func TestParsePackage(t *testing.T) {
})
t.Run("InvalidPackageVersion", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "version": "1.a.3"}`})
data := test.WriteZipArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "version": "1.a.3"}`}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp)
@@ -146,7 +116,7 @@ func TestParsePackage(t *testing.T) {
})
t.Run("InvalidReadmePath", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "readme": "sub/README.md"}`})
data := test.WriteZipArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "readme": "sub/README.md"}`}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
assert.NoError(t, err)
@@ -177,22 +147,29 @@ func TestParsePackage(t *testing.T) {
}
t.Run("ValidZip", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": buildComposerContent(""), "README.md": readme})
data := test.WriteZipArchive(map[string]string{"composer.json": buildComposerContent(""), "README.md": readme}).Bytes()
assertValidPackage(t, data, "", "gitea-composer-package.zip")
})
t.Run("ValidTarBz2", func(t *testing.T) {
data := createArchiveTar(func(w io.Writer) io.WriteCloser {
data := test.WriteTarCompression(func(w io.Writer) io.WriteCloser {
bz2Writer, _ := bzip2.NewWriter(w, nil)
return bz2Writer
}, map[string]string{"composer.json": buildComposerContent("1.0"), "README.md": readme})
}, map[string]string{"composer.json": buildComposerContent("1.0"), "README.md": readme}).Bytes()
assertValidPackage(t, data, "1.0", "gitea-composer-package.1.0.tar.bz2")
})
t.Run("ValidTarGz", func(t *testing.T) {
data := createArchiveTar(func(w io.Writer) io.WriteCloser {
data := test.WriteTarCompression(func(w io.Writer) io.WriteCloser {
return gzip.NewWriter(w)
}, map[string]string{"composer.json": buildComposerContent(""), "README.md": readme})
}, map[string]string{"composer.json": buildComposerContent(""), "README.md": readme}).Bytes()
assertValidPackage(t, data, "", "gitea-composer-package.tar.gz")
})
t.Run("ReadmeEncoding", func(t *testing.T) {
data := test.WriteZipArchive(map[string]string{"composer.json": buildComposerContent(""), "README.md": "🌞🌞"[:6]}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
require.NoError(t, err)
assert.Equal(t, "🌞 ", cp.Metadata.Readme)
})
}

View File

@@ -10,6 +10,7 @@ import (
"io"
"strings"
"gitea.dev/modules/packages"
"gitea.dev/modules/util"
"gitea.dev/modules/validation"
@@ -69,6 +70,8 @@ type Dependency struct {
Alias string `json:"alias,omitempty" yaml:"alias,omitempty"`
}
const maxArchiveSize = 256 * 1024 * 1024 // tar members are inflated while looking for Chart.yaml
// ParseChartArchive parses the metadata of a Helm archive
func ParseChartArchive(r io.Reader) (*Metadata, error) {
gzr, err := gzip.NewReader(r)
@@ -77,7 +80,7 @@ func ParseChartArchive(r io.Reader) (*Metadata, error) {
}
defer gzr.Close()
tr := tar.NewReader(gzr)
tr := tar.NewReader(packages.NewLimitedReader(gzr, maxArchiveSize))
for {
hd, err := tr.Next()
if err == io.EOF {

View File

@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package packages
import (
"io"
"gitea.dev/modules/util"
)
var ErrContentTooLarge = util.NewInvalidArgumentErrorf("content exceeds the size limit")
type limitedReader struct {
io.LimitedReader
}
// NewLimitedReader returns a reader that fails with ErrContentTooLarge once it reads a byte past limit
func NewLimitedReader(r io.Reader, limit int64) io.Reader {
return &limitedReader{io.LimitedReader{R: r, N: limit + 1}}
}
func (l *limitedReader) Read(p []byte) (int, error) {
n, err := l.LimitedReader.Read(p)
if l.N == 0 {
return max(n-1, 0), ErrContentTooLarge
}
return n, err
}

View File

@@ -8,6 +8,8 @@ import (
"compress/gzip"
"io"
"regexp"
"slices"
"strings"
"sync"
"gitea.dev/modules/util"
@@ -23,6 +25,8 @@ var (
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
// ErrInvalidVersion indicates an invalid version in the metadata.gz file
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
// ErrInvalidMetadata indicates metadata that would corrupt the generated index
ErrInvalidMetadata = util.NewInvalidArgumentErrorf("package metadata is invalid")
)
var globalVars = sync.OnceValue(func() (ret struct {
@@ -220,6 +224,19 @@ func parseMetadataFile(r io.Reader) (*Package, error) {
}
}
values := []string{m.Platform}
reqs := slices.Concat(m.RequiredRubyVersion, m.RequiredRubygemsVersion)
for _, dep := range slices.Concat(m.RuntimeDependencies, m.DevelopmentDependencies) {
values = append(values, dep.Name)
reqs = append(reqs, dep.Version...)
}
for _, req := range reqs {
values = append(values, req.Restriction, req.Version)
}
if slices.ContainsFunc(values, func(s string) bool { return strings.ContainsAny(s, "\r\n") }) {
return nil, ErrInvalidMetadata // the compact index is line-based, a line break would forge entries
}
return &Package{
Name: spec.Name,
Version: spec.Version.Version,

View File

@@ -43,6 +43,19 @@ version:
assert.Nil(t, rp)
}
})
t.Run("LineBreakInIndexedMetadata", func(t *testing.T) {
for _, field := range []string{
`platform: "ruby\nx"`,
`dependencies: [{name: "d\nx"}]`,
`dependencies: [{name: d, requirement: {requirements: [[">=", {version: "1\nx"}]]}}]`,
`required_ruby_version: {requirements: [[">=", {version: "1\nx"}]]}`,
} {
rp, err := parseMetadataFile(test.CompressGzip("name: g\nversion:\n version: 1\n" + field + "\n"))
assert.ErrorIs(t, err, ErrInvalidMetadata, field)
assert.Nil(t, rp)
}
})
}
func TestParseMetadataFile(t *testing.T) {

View File

@@ -5,10 +5,8 @@ package queue
import (
"context"
"sync/atomic"
"gitea.dev/modules/nosql"
"gitea.dev/modules/queue/lqinternal"
"gitea.com/lunny/levelqueue"
"github.com/syndtr/goleveldb/leveldb"
@@ -16,7 +14,7 @@ import (
type baseLevelQueue struct {
*baseLevelQueueCommonImpl
internal atomic.Pointer[levelqueue.Queue]
internal *levelqueue.Queue
conn string
cfg *BaseConfig
@@ -41,12 +39,11 @@ func newBaseLevelQueueSimple(cfg *BaseConfig) (baseQueue, error) {
return nil, err
}
q := &baseLevelQueue{conn: conn, cfg: cfg, db: db}
lq, err := levelqueue.NewQueue(db, []byte(cfg.QueueFullName), false)
q.internal, err = levelqueue.NewQueue(db, []byte(cfg.QueueFullName), false)
if err != nil {
return nil, err
}
q.internal.Store(lq)
q.baseLevelQueueCommonImpl = baseLevelQueueCommon(q.cfg, nil, func() baseLevelQueuePushPoper { return q.internal.Load() })
q.baseLevelQueueCommonImpl = baseLevelQueueCommon(q.cfg, nil, q.internal)
return q, nil
}
@@ -55,24 +52,16 @@ func (q *baseLevelQueue) HasItem(ctx context.Context, data []byte) (bool, error)
}
func (q *baseLevelQueue) Len(ctx context.Context) (int, error) {
return int(q.internal.Load().Len()), nil
return int(q.internal.Len()), nil
}
func (q *baseLevelQueue) Close() error {
err := q.internal.Load().Close()
err := q.internal.Close()
_ = nosql.GetManager().CloseLevelDB(q.conn)
q.db = nil // the db is not managed by us, it's managed by the nosql manager
return err
}
func (q *baseLevelQueue) RemoveAll(ctx context.Context) error {
lqinternal.RemoveLevelQueueKeys(q.db, []byte(q.cfg.QueueFullName))
lq, err := levelqueue.NewQueue(q.db, []byte(q.cfg.QueueFullName), false)
if err != nil {
return err
}
old := q.internal.Load()
q.internal.Store(lq)
_ = old.Close() // Not ideal for concurrency. Luckily, the levelqueue only sets its db=nil because it doesn't manage the db, so far so good
return nil
return q.internal.RemoveAll()
}

View File

@@ -26,9 +26,9 @@ type baseLevelQueuePushPoper interface {
type baseLevelQueueCommonImpl struct {
*baseQueueNotifiable
length int
internalFunc func() baseLevelQueuePushPoper
muCommon *sync.Mutex
length int
internal baseLevelQueuePushPoper
muCommon *sync.Mutex
}
func (q *baseLevelQueueCommonImpl) PushItem(ctx context.Context, data []byte) error {
@@ -38,11 +38,11 @@ func (q *baseLevelQueueCommonImpl) PushItem(ctx context.Context, data []byte) er
defer q.muCommon.Unlock()
}
cnt := int(q.internalFunc().Len())
cnt := int(q.internal.Len())
if cnt >= q.length {
return true, nil, nil
}
retry, err = false, q.internalFunc().RPush(data)
retry, err = false, q.internal.RPush(data)
if err == levelqueue.ErrAlreadyInQueue {
err = ErrAlreadyInQueue
}
@@ -61,7 +61,7 @@ func (q *baseLevelQueueCommonImpl) PopItem(ctx context.Context) ([]byte, error)
defer q.muCommon.Unlock()
}
data, err = q.internalFunc().LPop()
data, err = q.internal.LPop()
if err == levelqueue.ErrNotFound {
return true, nil, nil
}
@@ -72,8 +72,8 @@ func (q *baseLevelQueueCommonImpl) PopItem(ctx context.Context) ([]byte, error)
})
}
func baseLevelQueueCommon(cfg *BaseConfig, mu *sync.Mutex, internalFunc func() baseLevelQueuePushPoper) *baseLevelQueueCommonImpl {
return &baseLevelQueueCommonImpl{length: cfg.Length, muCommon: mu, internalFunc: internalFunc, baseQueueNotifiable: newBaseQueueNotifiable()}
func baseLevelQueueCommon(cfg *BaseConfig, mu *sync.Mutex, internal baseLevelQueuePushPoper) *baseLevelQueueCommonImpl {
return &baseLevelQueueCommonImpl{length: cfg.Length, muCommon: mu, internal: internal, baseQueueNotifiable: newBaseQueueNotifiable()}
}
func prepareLevelDB(cfg *BaseConfig) (conn string, db *leveldb.DB, err error) {

View File

@@ -6,13 +6,9 @@ package queue
import (
"testing"
"gitea.dev/modules/queue/lqinternal"
"gitea.dev/modules/setting"
"gitea.com/lunny/levelqueue"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/syndtr/goleveldb/leveldb"
)
func TestBaseLevelDB(t *testing.T) {
@@ -27,53 +23,3 @@ func TestBaseLevelDB(t *testing.T) {
testQueueBasic(t, newBaseLevelQueueSimple, toBaseConfig("baseLevelQueue", setting.QueueSettings{Datadir: t.TempDir() + "/queue-test", Length: 10}), optsSimple)
testQueueBasic(t, newBaseLevelQueueUnique, toBaseConfig("baseLevelQueueUnique", setting.QueueSettings{ConnStr: "leveldb://" + t.TempDir() + "/queue-test", Length: 10}), optsUnique)
}
func TestCorruptedLevelQueue(t *testing.T) {
// sometimes the levelqueue could be in a corrupted state, this test is to make sure it can recover from it
dbDir := t.TempDir() + "/levelqueue-test"
db, err := leveldb.OpenFile(dbDir, nil)
require.NoError(t, err)
defer db.Close()
assert.NoError(t, db.Put([]byte("other-key"), []byte("other-value"), nil))
nameQueuePrefix := []byte("queue_name")
nameSetPrefix := []byte("set_name")
lq, err := levelqueue.NewUniqueQueue(db, nameQueuePrefix, nameSetPrefix, false)
assert.NoError(t, err)
assert.NoError(t, lq.RPush([]byte("item-1")))
itemKey := lqinternal.QueueItemKeyBytes(nameQueuePrefix, 1)
itemValue, err := db.Get(itemKey, nil)
assert.NoError(t, err)
assert.Equal(t, []byte("item-1"), itemValue)
// there should be 5 keys in db: queue low, queue high, 1 queue item, 1 set item, and "other-key"
keys := lqinternal.ListLevelQueueKeys(db)
assert.Len(t, keys, 5)
// delete the queue item key, to corrupt the queue
assert.NoError(t, db.Delete(itemKey, nil))
// now the queue is corrupted, it never works again
_, err = lq.LPop()
assert.ErrorIs(t, err, levelqueue.ErrNotFound)
assert.NoError(t, lq.Close())
// remove all the queue related keys to reset the queue
lqinternal.RemoveLevelQueueKeys(db, nameQueuePrefix)
lqinternal.RemoveLevelQueueKeys(db, nameSetPrefix)
// now there should be only 1 key in db: "other-key"
keys = lqinternal.ListLevelQueueKeys(db)
assert.Len(t, keys, 1)
assert.Equal(t, []byte("other-key"), keys[0])
// re-create a queue from db
lq, err = levelqueue.NewUniqueQueue(db, nameQueuePrefix, nameSetPrefix, false)
assert.NoError(t, err)
assert.NoError(t, lq.RPush([]byte("item-new-1")))
// now the queue works again
itemValue, err = lq.LPop()
assert.NoError(t, err)
assert.Equal(t, []byte("item-new-1"), itemValue)
assert.NoError(t, lq.Close())
}

View File

@@ -6,10 +6,8 @@ package queue
import (
"context"
"sync"
"sync/atomic"
"gitea.dev/modules/nosql"
"gitea.dev/modules/queue/lqinternal"
"gitea.com/lunny/levelqueue"
"github.com/syndtr/goleveldb/leveldb"
@@ -17,13 +15,13 @@ import (
type baseLevelQueueUnique struct {
*baseLevelQueueCommonImpl
internal atomic.Pointer[levelqueue.UniqueQueue]
internal *levelqueue.UniqueQueue
conn string
cfg *BaseConfig
db *leveldb.DB
muBase sync.Mutex // the levelqueue.UniqueQueue is not thread-safe, there is no mutex protecting the underlying queue&set together
muBase sync.Mutex // protects multi-step queue+set operations, e.g. the length check followed by a push
}
var (
@@ -37,31 +35,30 @@ func newBaseLevelQueueUnique(cfg *BaseConfig) (baseQueue, error) {
return nil, err
}
q := &baseLevelQueueUnique{conn: conn, cfg: cfg, db: db}
lq, err := levelqueue.NewUniqueQueue(db, []byte(cfg.QueueFullName), []byte(cfg.SetFullName), false)
q.internal, err = levelqueue.NewUniqueQueue(db, []byte(cfg.QueueFullName), []byte(cfg.SetFullName), false)
if err != nil {
return nil, err
}
q.internal.Store(lq)
q.baseLevelQueueCommonImpl = baseLevelQueueCommon(q.cfg, &q.muBase, func() baseLevelQueuePushPoper { return q.internal.Load() })
q.baseLevelQueueCommonImpl = baseLevelQueueCommon(q.cfg, &q.muBase, q.internal)
return q, nil
}
func (q *baseLevelQueueUnique) HasItem(ctx context.Context, data []byte) (bool, error) {
q.muBase.Lock()
defer q.muBase.Unlock()
return q.internal.Load().Has(data)
return q.internal.Has(data)
}
func (q *baseLevelQueueUnique) Len(ctx context.Context) (int, error) {
q.muBase.Lock()
defer q.muBase.Unlock()
return int(q.internal.Load().Len()), nil
return int(q.internal.Len()), nil
}
func (q *baseLevelQueueUnique) Close() error {
q.muBase.Lock()
defer q.muBase.Unlock()
err := q.internal.Load().Close()
err := q.internal.Close()
q.db = nil // the db is not managed by us, it's managed by the nosql manager
_ = nosql.GetManager().CloseLevelDB(q.conn)
return err
@@ -70,14 +67,5 @@ func (q *baseLevelQueueUnique) Close() error {
func (q *baseLevelQueueUnique) RemoveAll(ctx context.Context) error {
q.muBase.Lock()
defer q.muBase.Unlock()
lqinternal.RemoveLevelQueueKeys(q.db, []byte(q.cfg.QueueFullName))
lqinternal.RemoveLevelQueueKeys(q.db, []byte(q.cfg.SetFullName))
lq, err := levelqueue.NewUniqueQueue(q.db, []byte(q.cfg.QueueFullName), []byte(q.cfg.SetFullName), false)
if err != nil {
return err
}
old := q.internal.Load()
q.internal.Store(lq)
_ = old.Close() // Not ideal for concurrency. Luckily, the levelqueue only sets its db=nil because it doesn't manage the db, so far so good
return nil
return q.internal.RemoveAll()
}

View File

@@ -25,7 +25,7 @@ func testQueueBasic(t *testing.T, newFn func(cfg *BaseConfig) (baseQueue, error)
assert.NoError(t, err)
ctx := t.Context()
_ = q.RemoveAll(ctx)
assert.NoError(t, q.RemoveAll(ctx))
cnt, err := q.Len(ctx)
assert.NoError(t, err)
assert.Equal(t, 0, cnt)
@@ -138,11 +138,14 @@ func testQueueBasic(t *testing.T, newFn func(cfg *BaseConfig) (baseQueue, error)
assert.NoError(t, err)
assert.Equal(t, cfg.Length, cnt)
_ = q.RemoveAll(ctx)
assert.NoError(t, q.RemoveAll(ctx))
cnt, err = q.Len(ctx)
assert.NoError(t, err)
assert.Equal(t, 0, cnt)
// a removed payload must not stay reserved in the set, or this returns ErrAlreadyInQueue
assert.NoError(t, q.PushItem(ctx, []byte("item-0")))
})
}

Some files were not shown because too many files have changed in this diff Show More