Compare commits

...

24 Commits

Author SHA1 Message Date
techknowlogick
a1ce52fa45 Merge branch 'main' into renovate/tool-dependencies 2026-06-15 13:35:01 -04:00
metsw24-max
0eba0e371f fix(packages): validate module version in goproxy ParsePackage (#38104)
**Unvalidated version in goproxy ParsePackage**
The module version is read straight from the zip directory path and
never checked, so a crafted upload can leave a newline in it;
`EnumeratePackageVersions` then writes each stored version on its own
line for the `@v/list` endpoint, letting a module advertise fabricated
versions to `go` clients. Validated the parsed version with
`semver.IsValid` inside the parser, matching the version checks the
other package parsers already do.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-15 19:14:14 +02:00
bircni
ba66cdeaa8 Merge branch 'main' into renovate/tool-dependencies 2026-06-15 19:13:36 +02:00
Rafail Giavrimis
052feee34a feat: add raw diff/patch endpoint for repository comparisons (#37632)
## Summary

Adds `GET
/repos/{owner}/{repo}/compare/{basehead}.{diffType:diff|patch}`,
mirroring the existing `/git/commits/{sha}.{diffType}` endpoint but for
comparisons between two arbitrary refs.

The new endpoint streams a raw unified diff or `git format-patch` output
between any two refs:

GET /repos/{owner}/{repo}/compare/main...feature.diff
GET /repos/{owner}/{repo}/compare/v1.0..v1.1.patch
GET /repos/{owner}/{repo}/compare/abc1234...def5678.diff

Resolves #5561, #13416 and #17165.

AI was used while creating this PR. Automated tests were added as per
the contribution policy.

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:37:15 -07:00
Lunny Xiao
275fdea3a9 Merge branch 'main' into renovate/tool-dependencies 2026-06-15 09:35:22 -07:00
Giteabot
b4cb192fba chore(deps): update pnpm to v11.5.3 (#38133)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [pnpm](https://pnpm.io)
([source](https://redirect.github.com/pnpm/pnpm/tree/HEAD/pnpm)) |
[`11.5.2` →
`11.5.3`](https://renovatebot.com/diffs/npm/pnpm/11.5.2/11.5.3) |
![age](https://developer.mend.io/api/mc/badges/age/npm/pnpm/11.5.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pnpm/11.5.2/11.5.3?slim=true)
|

---

### Release Notes

<details>
<summary>pnpm/pnpm (pnpm)</summary>

###
[`v11.5.3`](https://redirect.github.com/pnpm/pnpm/blob/HEAD/pnpm/CHANGELOG.md#1153)

[Compare
Source](https://redirect.github.com/pnpm/pnpm/compare/v11.5.2...v11.5.3)

##### Patch Changes

- Stopped expanding environment variables in repository-controlled
registry/proxy request destinations and registry credential values from
`.npmrc`, and in workspace registry URLs from `pnpm-workspace.yaml`.
Move dynamic registry URL and token configuration to trusted user,
global, CLI, or environment config.

- Resolve package-manager bootstrap dependencies with trusted user or
CLI registry and network config, and reject package-manager env-lockfile
records that do not use registry package paths with integrity-only
resolutions before auto-switch execution.

- Avoid writing `packageManagerDependencies` to `pnpm-lock.yaml` when
package manager policy is set to `onFail: ignore` or `pmOnFail: ignore`
[#&#8203;12228](https://redirect.github.com/pnpm/pnpm/issues/12228).

- Avoid running dependency-status auto-install when the dependency
status is unavailable without a project manifest.

- Using the `$` version reference syntax in `overrides` (e.g. `"react":
"$react"`) now prints a deprecation warning. The syntax still works, but
[catalogs](https://pnpm.io/catalogs) are the recommended way to keep an
overridden version in sync with the rest of the workspace. Reference a
catalog entry with the `catalog:` protocol instead.

- Fixed `pnpm config get globalconfig` to return the global
`config.yaml` path again
[pnpm/pnpm#11962](https://redirect.github.com/pnpm/pnpm/issues/11962).

- Fixed bare `--color` so it does not consume the following CLI flag,
allowing command shorthands like `--parallel` to expand correctly and
forms like `pnpm --color with current <command>` to dispatch the inner
command instead of failing with `MISSING_WITH_CURRENT_CMD`.

- Fix `pnpm install` ignoring `enableGlobalVirtualStore` toggle by
including it in the workspace state settings check
[#&#8203;12142](https://redirect.github.com/pnpm/pnpm/issues/12142).

- Security: pnpm now verifies the npm registry signature of a
package-manager binary before spawning it, so a cloned repository cannot
make pnpm download and execute an arbitrary native binary.

This covers two paths that select an executable from
repository-controlled input:

- **pacquet install engine** — declaring `pacquet` (or `@pnpm/pacquet`)
in `configDependencies` opts in to pnpm's Rust install engine. pnpm now
verifies that the installed `pacquet` shim and the host's
`@pacquet/<platform>-<arch>` binary carry a valid npm registry signature
for their exact `name@version`, and refuses to run pacquet (failing the
command) if the signature does not verify or cannot be checked. The only
graceful fallback to pnpm's own engine is when pacquet has no binary for
the current platform.
- **automatic version switch / `self-update`** — the `packageManager` /
`devEngines.packageManager` field makes pnpm download and run a specific
pnpm version. pnpm now verifies the registry signature of `pnpm`,
`@pnpm/exe`, and the host platform binary before installing/spawning
them, and refuses to run an engine whose signature does not match a
published, signed release. The check runs only on an actual download
(store cache miss), so it does not add a network round trip to every
command.

In both cases the signature is verified over the *installed* integrity,
against npm's public signing keys that ship embedded in the pnpm CLI
(like corepack), so bytes substituted via a tampered lockfile or a
repository-controlled registry fail verification — and a registry the
user did not vouch for cannot supply its own signing keys. The signed
packument is fetched from the configured registry, so an npm mirror
works transparently. Verification fails closed: if it cannot be
completed (for example, the registry is unreachable), the command fails
rather than running an unverified binary. The embedded keys are kept
current by a release-time check against npm's signing-keys endpoint.

- Made peer-dependent deduplication deterministic. When a peer-suffixed
package variant was a subset of two or more mutually incompatible larger
variants, the variant it collapsed into depended on the order importers
were resolved in, which varies between machines. This could resolve the
same workspace to different lockfiles on different platforms and make
`pnpm dedupe --check` alternate between passing and failing.

- Reject invalid package names and versions from staged tarball
manifests before deriving filenames for `pnpm stage download`.

- Clarified in CLI help that the pnpm store is trusted shared state and
store integrity checks are corruption detection, not a tamper boundary
for untrusted store writers.

- Reject reserved manifest `bin` names (`""`, `"."`, `".."`, and scoped
forms such as `@scope/..`) when resolving a package's bins. These names
previously passed the bin-name guard and, when joined to the global bin
directory during global remove/update/add operations, could resolve to
the global bin directory itself or its parent and have it recursively
deleted.

- Require trusted package identity before package-name `allowBuilds`
entries can approve lifecycle scripts for git, git-hosted tarball,
direct tarball, and local directory artifacts. To approve one of those
artifacts explicitly, use its peer-suffix-free lockfile depPath as the
`allowBuilds` key. Lockfile verification now rejects lockfiles where a
registry-style dependency path (`name@semver`) is backed by a git,
directory, or git-hosted tarball resolution
(`ERR_PNPM_RESOLUTION_SHAPE_MISMATCH`), so the dependency path is a
reliable artifact identity by the time scripts can run.

- Security: pnpm now verifies the OpenPGP signature of a downloaded
Node.js runtime's `SHASUMS256.txt` before trusting its integrity hashes.

When a repository requests a Node.js runtime (e.g. via
`devEngines.runtime` / `useNodeVersion`), the download mirror is
repository-configurable through `node-mirror:<channel>`. The integrity
of the downloaded binary was only checked against `SHASUMS256.txt`
fetched from that same mirror — a circular check that a malicious mirror
could satisfy by serving a tampered binary together with a matching
`SHASUMS256.txt`. pnpm then executes the binary (for example to run
lifecycle scripts).

pnpm now fetches `SHASUMS256.txt.sig` and verifies the detached OpenPGP
signature against the Node.js release team's public keys, which ship
embedded in the pnpm CLI. A mirror that serves a tampered binary cannot
also produce a valid signature, so the download fails to verify. The
embedded keys are kept current by a release-time check against the
canonical `nodejs/release-keys` list.

The musl variants from the hardcoded `unofficial-builds.nodejs.org`
mirror are not repository-configurable and are signed by a different
key, so they continue to be trusted over TLS.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Only on Monday (`* * * * 1`)
- Automerge
  - At any time (no schedule defined)

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

♻ **Rebasing**: Whenever PR is behind base branch, 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](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNDEuNSIsInVwZGF0ZWRJblZlciI6IjQzLjE0MS41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: bircni <bircni@icloud.com>
2026-06-15 18:16:16 +02:00
Giteabot
1363b097e2 chore(deps): update action dependencies (#38121)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| redis | service | digest | `e74c9b9` → `a505f8b` |
|
[renovatebot/github-action](https://redirect.github.com/renovatebot/github-action)
| action | patch | `v46.1.14` → `v46.1.15` |

---

### Release Notes

<details>
<summary>renovatebot/github-action (renovatebot/github-action)</summary>

###
[`v46.1.15`](https://redirect.github.com/renovatebot/github-action/releases/tag/v46.1.15)

[Compare
Source](https://redirect.github.com/renovatebot/github-action/compare/v46.1.14...v46.1.15)

##### Documentation

- update references to actions/checkout to v6.0.3
([#&#8203;1033](https://redirect.github.com/renovatebot/github-action/issues/1033))
([fb473e1](fb473e186b))
- update references to renovatebot/github-action to v46.1.14
([34e09dd](34e09dd76c))

##### Miscellaneous Chores

- **deps:** update linters to v8.60.0
([1abcc51](1abcc518dc))
- **deps:** update node.js to v24.16.0
([7bbd8b1](7bbd8b12ba))
- **deps:** update pnpm to v10.34.1
([fc48fa8](fc48fa8e31))
- **deps:** update semantic-release monorepo to v12.0.8
([7ae9fb9](7ae9fb9e94))

##### Build System

- **deps:** lock file maintenance
([3e7e656](3e7e6563b3))

##### Continuous Integration

- **deps:** update actions/checkout action to v6.0.3
([bb87b51](bb87b5131a))
- **deps:** update ghcr.io/renovatebot/renovate docker tag to v43.171.3
([f4736a8](f4736a876f))
- **deps:** update ghcr.io/renovatebot/renovate docker tag to v43.173.0
([4374486](4374486206))
- **deps:** update ghcr.io/renovatebot/renovate docker tag to v43.214.5
([3fbdafe](3fbdafedb1))
- **deps:** update ghcr.io/zizmorcore/zizmor docker tag to v1.25.2
([#&#8203;1034](https://redirect.github.com/renovatebot/github-action/issues/1034))
([58252bc](58252bce69))
- **deps:** update zizmorcore/zizmor-action action to v0.5.6
([b8cc935](b8cc935bc1))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Only on Monday (`* * * * 1`)
- Automerge
  - At any time (no schedule defined)

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

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

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

---

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

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNDEuNSIsInVwZGF0ZWRJblZlciI6IjQzLjE0MS41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: bircni <bircni@icloud.com>
2026-06-15 15:20:43 +00:00
silverwind
ae22719b2a Merge branch 'main' into renovate/tool-dependencies 2026-06-15 17:13:04 +02:00
6543
d2186ecd03 docs: update missed gov docs update (#38131) 2026-06-15 14:22:51 +02:00
Giteabot
76f8d122fe fix(deps): update npm dependencies (#38123)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| @&#8203;codemirror/lint | [`6.9.6` →
`6.9.7`](https://renovatebot.com/diffs/npm/@codemirror%2flint/6.9.6/6.9.7)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@codemirror%2flint/6.9.7?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@codemirror%2flint/6.9.6/6.9.7?slim=true)
|
| @&#8203;codemirror/view | [`6.43.0` →
`6.43.1`](https://renovatebot.com/diffs/npm/@codemirror%2fview/6.43.0/6.43.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@codemirror%2fview/6.43.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@codemirror%2fview/6.43.0/6.43.1?slim=true)
|
| [@primer/octicons](https://primer.style/octicons)
([source](https://redirect.github.com/primer/octicons)) | [`19.28.0` →
`19.28.1`](https://renovatebot.com/diffs/npm/@primer%2focticons/19.28.0/19.28.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@primer%2focticons/19.28.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@primer%2focticons/19.28.0/19.28.1?slim=true)
|
|
[@types/jquery](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jquery)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jquery))
| [`4.0.0` →
`4.0.1`](https://renovatebot.com/diffs/npm/@types%2fjquery/4.0.0/4.0.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fjquery/4.0.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fjquery/4.0.0/4.0.1?slim=true)
|
|
[@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node))
| [`25.9.1` →
`25.9.2`](https://renovatebot.com/diffs/npm/@types%2fnode/25.9.1/25.9.2)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/25.9.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/25.9.1/25.9.2?slim=true)
|
|
[@typescript-eslint/parser](https://typescript-eslint.io/packages/parser)
([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser))
| [`8.60.1` →
`8.61.0`](https://renovatebot.com/diffs/npm/@typescript-eslint%2fparser/8.60.1/8.61.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@typescript-eslint%2fparser/8.61.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@typescript-eslint%2fparser/8.60.1/8.61.0?slim=true)
|
|
[@vitest/eslint-plugin](https://redirect.github.com/vitest-dev/eslint-plugin-vitest)
| [`1.6.19` →
`1.6.20`](https://renovatebot.com/diffs/npm/@vitest%2feslint-plugin/1.6.19/1.6.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2feslint-plugin/1.6.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2feslint-plugin/1.6.19/1.6.20?slim=true)
|
| [happy-dom](https://redirect.github.com/capricorn86/happy-dom) |
[`20.10.1` →
`20.10.2`](https://renovatebot.com/diffs/npm/happy-dom/20.10.1/20.10.2)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/happy-dom/20.10.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/happy-dom/20.10.1/20.10.2?slim=true)
|
| [pnpm](https://pnpm.io)
([source](https://redirect.github.com/pnpm/pnpm/tree/HEAD/pnpm)) |
[`11.5.1` →
`11.5.2`](https://renovatebot.com/diffs/npm/pnpm/11.5.1/11.5.2) |
![age](https://developer.mend.io/api/mc/badges/age/npm/pnpm/11.5.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pnpm/11.5.1/11.5.2?slim=true)
|
| [stylelint](https://stylelint.io)
([source](https://redirect.github.com/stylelint/stylelint)) | [`17.12.0`
→
`17.13.0`](https://renovatebot.com/diffs/npm/stylelint/17.12.0/17.13.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/stylelint/17.13.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/stylelint/17.12.0/17.13.0?slim=true)
|
|
[typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint)
([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint))
| [`8.60.1` →
`8.61.0`](https://renovatebot.com/diffs/npm/typescript-eslint/8.60.1/8.61.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/typescript-eslint/8.61.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/typescript-eslint/8.60.1/8.61.0?slim=true)
|
| [updates](https://redirect.github.com/silverwind/updates) | [`17.17.3`
→ `17.18.0`](https://renovatebot.com/diffs/npm/updates/17.17.3/17.18.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/updates/17.18.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/updates/17.17.3/17.18.0?slim=true)
|
| [vue-tsc](https://redirect.github.com/vuejs/language-tools)
([source](https://redirect.github.com/vuejs/language-tools/tree/HEAD/packages/tsc))
| [`3.3.3` →
`3.3.4`](https://renovatebot.com/diffs/npm/vue-tsc/3.3.3/3.3.4) |
![age](https://developer.mend.io/api/mc/badges/age/npm/vue-tsc/3.3.4?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-tsc/3.3.3/3.3.4?slim=true)
|

---

### Release Notes

<details>
<summary>primer/octicons (@&#8203;primer/octicons)</summary>

###
[`v19.28.1`](https://redirect.github.com/primer/octicons/blob/HEAD/CHANGELOG.md#19281)

[Compare
Source](https://redirect.github.com/primer/octicons/compare/v19.28.0...v19.28.1)

##### Patch Changes

- [#&#8203;1215](https://redirect.github.com/primer/octicons/pull/1215)
[`378d7af0`](378d7af0e3)
Thanks [@&#8203;ktravers](https://redirect.github.com/ktravers)! -
Remove fill from StackRemove and StackCheck icons

* [#&#8203;1221](https://redirect.github.com/primer/octicons/pull/1221)
[`9d7b366a`](9d7b366a6e)
Thanks [@&#8203;CameronFoxly](https://redirect.github.com/CameronFoxly)!
- Add stack-add-16.svg

</details>

<details>
<summary>typescript-eslint/typescript-eslint
(@&#8203;typescript-eslint/parser)</summary>

###
[`v8.61.0`](https://redirect.github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/parser/CHANGELOG.md#8610-2026-06-08)

[Compare
Source](https://redirect.github.com/typescript-eslint/typescript-eslint/compare/v8.60.1...v8.61.0)

This was a version bump only for parser to align it with other projects,
there were no code changes.

See [GitHub
Releases](https://redirect.github.com/typescript-eslint/typescript-eslint/releases/tag/v8.61.0)
for more information.

You can read about our [versioning
strategy](https://typescript-eslint.io/users/versioning) and
[releases](https://typescript-eslint.io/users/releases) on our website.

</details>

<details>
<summary>vitest-dev/eslint-plugin-vitest
(@&#8203;vitest/eslint-plugin)</summary>

###
[`v1.6.20`](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/releases/tag/v1.6.20)

[Compare
Source](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/compare/v1.6.19...v1.6.20)

#####    🐞 Bug Fixes

- **hoisted-apis-on-top**: Detect vitest.mock and aliased vi/vitest mock
calls  -  by [@&#8203;spokodev](https://redirect.github.com/spokodev) in
[#&#8203;909](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/issues/909)
[<samp>(8fff9)</samp>](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/commit/8fff969)
- **require-test-timeout**: Treat imported bindings as explicit timeouts
 -  by [@&#8203;spokodev](https://redirect.github.com/spokodev) in
[#&#8203;906](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/issues/906)
[<samp>(bd82c)</samp>](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/commit/bd82c7d)
- **valid-expect**: Treat .finally() as part of async assertion promise
chains  -  by [@&#8203;spokodev](https://redirect.github.com/spokodev)
in
[#&#8203;908](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/issues/908)
[<samp>(7c697)</samp>](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/commit/7c697f8)

#####     [View changes on
GitHub](https://redirect.github.com/vitest-dev/eslint-plugin-vitest/compare/v1.6.19...v1.6.20)

</details>

<details>
<summary>capricorn86/happy-dom (happy-dom)</summary>

###
[`v20.10.2`](https://redirect.github.com/capricorn86/happy-dom/releases/tag/v20.10.2)

[Compare
Source](https://redirect.github.com/capricorn86/happy-dom/compare/v20.10.1...v20.10.2)

##### :construction\_worker\_man: Patch fixes

- Updates external dependencies - By
**[@&#8203;capricorn86](https://redirect.github.com/capricorn86)** in
task
[#&#8203;2163](https://redirect.github.com/capricorn86/happy-dom/issues/2163)

</details>

<details>
<summary>pnpm/pnpm (pnpm)</summary>

###
[`v11.5.2`](https://redirect.github.com/pnpm/pnpm/blob/HEAD/pnpm/CHANGELOG.md#1152)

[Compare
Source](https://redirect.github.com/pnpm/pnpm/compare/v11.5.1...v11.5.2)

##### Patch Changes

- Peer dependency resolution now reuses the peer contexts already
recorded in the lockfile when those providers are still present in the
dependency graph and still satisfy the peer ranges. This avoids
unnecessary peer-context rewrites during lockfile regeneration. Current
manifest choices remain authoritative: a newly added, explicitly
updated, or aliased direct provider, a changed nested provider, or a
locked version that no longer satisfies the range still takes
precedence.

- The lockfile verifier now checks that a registry entry pinning an
explicit `tarball` URL points at the artifact the registry's own
metadata lists for that `name@version`. Previously a tampered lockfile
could pair a trusted `name@version` with an attacker-chosen tarball URL
(and a matching integrity for those bytes), so the install fetched the
attacker's bytes. A mismatch — or any entry that can't be confirmed
against the registry — is rejected with `ERR_PNPM_TARBALL_URL_MISMATCH`.
Non-registry resolutions (`file:`, git-hosted, etc.) and registry
entries without an explicit tarball URL (the URL is reconstructed from
name+version+registry, so it is inherently bound) are unaffected;
non-standard registry tarball URLs (npm Enterprise, GitHub Packages)
still pass because they match the metadata.

- Fix `pnpm update --recursive --lockfile-only <pkg>@&#8203;<version>`
crashing with `Invalid Version` when the catalog entry for `<pkg>` is a
version range (e.g. `^21.2.10`) and `catalogMode` is `strict` or
`prefer`. The catalog–version comparison now skips the equality check
when either side is a range rather than passing a range to
`semver.eq()`, so range specifiers fall through to the existing mismatch
handling instead of throwing
[#&#8203;11570](https://redirect.github.com/pnpm/pnpm/issues/11570).

- Avoided a Node.js crash when pnpm exits after network requests on
Windows.

- Fixed packages being materialized into the virtual store without their
root-level files (`package.json`, `LICENSE`, README, root entrypoints)
when multiple `pnpm install` processes ran against the same
store/workspace concurrently. The fast import path used to destructively
empty the shared target directory, so a concurrent importer could wipe
files another importer had already written; if the surviving files
included the `package.json` completion marker, every later install
treated the broken directory as complete and never repaired it. The fast
path now imports directly only when it can create the target directory
exclusively, and otherwise builds the package in a private temp
directory and atomically renames it into place
[#&#8203;12197](https://redirect.github.com/pnpm/pnpm/issues/12197).

- Fix dependency build scripts not running under the global virtual
store (`enableGlobalVirtualStore`).

In a workspace install, dependency build scripts are deferred to a
single `rebuild` pass (`buildProjects`). That pass resolved each
package's location from the classic
`node_modules/.pnpm/<depPathToFilename>` layout, which does not exist
under the global virtual store — so native dependencies (e.g. packages
using `node-gyp` / `prebuild-install`) were never built and failed to
load at runtime (`Cannot find module .../build/Release/*.node`).

`buildProjects` now resolves the global-virtual-store projection
directory (`<storeDir>/links/<hash>`, computed with the same graph hash
the installer uses) when `enableGlobalVirtualStore` is set, and
serializes concurrent builds of the same shared projection so parallel
workspace projects don't race on the same directory.

- Don't promote a `runtime:` dependency (such as the Node.js version
from `devEngines.runtime` or `pnpm runtime set`) into a catalog when
`catalogMode` is `strict` or `prefer`. A `runtime:` dependency
round-trips to `devEngines.runtime`, which only recognizes the
`runtime:` protocol; cataloging it rewrote the manifest entry to
`catalog:`, which broke that round-trip, stranded it in
`devDependencies`, and left `devEngines.runtime` untouched.

- Skip lockfile `minimumReleaseAge`/`trustPolicy` verification for
non-registry tarball protocols (for example `file:`), so local tarball
dependencies are not incorrectly checked against npm registry metadata.

</details>

<details>
<summary>stylelint/stylelint (stylelint)</summary>

###
[`v17.13.0`](https://redirect.github.com/stylelint/stylelint/blob/HEAD/CHANGELOG.md#17130---2026-06-06)

[Compare
Source](https://redirect.github.com/stylelint/stylelint/compare/17.12.0...17.13.0)

It fixes 3 bugs, including a false negative one.

- Fixed: `declaration-block-no-duplicate-properties` false negatives for
interleaved non-consecutive duplicates with `ignore:
["consecutive-duplicates(-*)"]`
([#&#8203;9324](https://redirect.github.com/stylelint/stylelint/pull/9324))
([@&#8203;sarathfrancis90](https://redirect.github.com/sarathfrancis90)).
- Fixed: `selector-max-type` false positives for nested selectors
([#&#8203;9319](https://redirect.github.com/stylelint/stylelint/pull/9319))
([@&#8203;romainmenke](https://redirect.github.com/romainmenke)).
- Fixed: `selector-type-no-unknown` false positives for `install`
([#&#8203;9308](https://redirect.github.com/stylelint/stylelint/pull/9308))
([@&#8203;Mouvedia](https://redirect.github.com/Mouvedia)).

</details>

<details>
<summary>typescript-eslint/typescript-eslint
(typescript-eslint)</summary>

###
[`v8.61.0`](https://redirect.github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/typescript-eslint/CHANGELOG.md#8610-2026-06-08)

[Compare
Source](https://redirect.github.com/typescript-eslint/typescript-eslint/compare/v8.60.1...v8.61.0)

This was a version bump only for typescript-eslint to align it with
other projects, there were no code changes.

See [GitHub
Releases](https://redirect.github.com/typescript-eslint/typescript-eslint/releases/tag/v8.61.0)
for more information.

You can read about our [versioning
strategy](https://typescript-eslint.io/users/versioning) and
[releases](https://typescript-eslint.io/users/releases) on our website.

</details>

<details>
<summary>silverwind/updates (updates)</summary>

###
[`v17.18.0`](https://redirect.github.com/silverwind/updates/releases/tag/17.18.0)

[Compare
Source](https://redirect.github.com/silverwind/updates/compare/17.17.3...17.18.0)

- Remove debug logging from test default route (silverwind)
- update deps (silverwind)
- fix a batch of correctness bugs across modes, parsing and CLI
(silverwind)
- Document make mode in modes list and package metadata (silverwind)
- Support docker images in make mode (silverwind)
- Add make mode for go tool versions in Makefiles (silverwind)
- simplify control flow and drop redundant guards (silverwind)
- fix a batch of correctness bugs in version resolution and parsing
(silverwind)
- skip redundant version filtering and replace-free go.mod scans
(silverwind)
- fix -t comma split, multiline toml strings, and prerelease ordering
(silverwind)
- make: collapse patch/minor/major into one rule (silverwind)
- simplify matchesAny contract, toml parser, misc cleanups (silverwind)

</details>

<details>
<summary>vuejs/language-tools (vue-tsc)</summary>

###
[`v3.3.4`](https://redirect.github.com/vuejs/language-tools/blob/HEAD/CHANGELOG.md#334-2026-06-08)

[Compare
Source](https://redirect.github.com/vuejs/language-tools/compare/v3.3.3...v3.3.4)

##### language-core

- **fix:** only exclude already-set props from inherited attrs when
`checkRequiredFallthroughAttributes` is enabled
([#&#8203;6088](https://redirect.github.com/vuejs/language-tools/issues/6088))
- Thanks to [@&#8203;KazariEX](https://redirect.github.com/KazariEX)!
- **fix:** camelize slot props regardless of `htmlAttributes` option
([#&#8203;6089](https://redirect.github.com/vuejs/language-tools/issues/6089))
- Thanks to [@&#8203;KazariEX](https://redirect.github.com/KazariEX)!
- **fix:** detect duplicate event listeners across name formats
([#&#8203;6094](https://redirect.github.com/vuejs/language-tools/issues/6094))
- Thanks to [@&#8203;whysopaul](https://redirect.github.com/whysopaul)!

##### language-service

- **fix:** respect var hoisting for destructured props hints
([#&#8203;6092](https://redirect.github.com/vuejs/language-tools/issues/6092))
- Thanks to [@&#8203;KazariEX](https://redirect.github.com/KazariEX)!

##### typescript-plugin

- **fix:** do not treat `class` and `style` as a boolean property
([#&#8203;6081](https://redirect.github.com/vuejs/language-tools/issues/6081))
- Thanks to [@&#8203;KazariEX](https://redirect.github.com/KazariEX)!

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Only on Monday (`* * * * 1`)
- Automerge
  - At any time (no schedule defined)

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

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

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

---

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

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNDEuNSIsInVwZGF0ZWRJblZlciI6IjQzLjE0MS41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: bircni <bircni@icloud.com>
2026-06-15 05:03:39 +00:00
bircni
f79f8a4d9d Merge branch 'main' into renovate/tool-dependencies 2026-06-15 06:59:30 +02:00
Giteabot
4ca706d6a9 chore(deps): update dependency djlint to v1.39.0 (#38124)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [djlint](https://redirect.github.com/djlint/djLint) | `==1.36.4` →
`==1.39.0` |
![age](https://developer.mend.io/api/mc/badges/age/pypi/djlint/1.39.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/djlint/1.36.4/1.39.0?slim=true)
|

---

### Release Notes

<details>
<summary>djlint/djLint (djlint)</summary>

###
[`v1.39.0`](https://redirect.github.com/djlint/djLint/blob/HEAD/CHANGELOG.md#1390---2026-06-05)

[Compare
Source](https://redirect.github.com/djlint/djLint/compare/v1.38.2...v1.39.0)

##### Feature

- Add `preserve_class_newlines` / `--preserve-class-newlines` to keep
authored line breaks inside multiline `class` attributes.

##### Fix

- Fix Django 6.0 `{% partialdef %}` block indentation so `{%
endpartialdef %}` aligns with its opener.
- Preserve multiline Django/Jinja control-flow blocks instead of
condensing short bodies onto one line.
- Preserve single-line inline HTML and template tag bodies during
expansion, even when they exceed `max_line_length`.

###
[`v1.38.2`](https://redirect.github.com/djlint/djLint/blob/HEAD/CHANGELOG.md#1382---2026-06-05)

[Compare
Source](https://redirect.github.com/djlint/djLint/compare/v1.38.1...v1.38.2)

##### Fix

- Fix `python -m djlint` not working due to mypyc compilation.

###
[`v1.38.1`](https://redirect.github.com/djlint/djLint/blob/HEAD/CHANGELOG.md#1381---2026-06-04)

[Compare
Source](https://redirect.github.com/djlint/djLint/compare/v1.38.0...v1.38.1)

##### Fix

- Match exclude paths on path boundaries.

###
[`v1.38.0`](https://redirect.github.com/djlint/djLint/blob/HEAD/CHANGELOG.md#1380---2026-06-04)

[Compare
Source](https://redirect.github.com/djlint/djLint/compare/v1.37.0...v1.38.0)

##### Feature

- Add support for `.djlint.toml` project and global config files.

##### Fix

- Preserve single-line inline HTML tag bodies when they fit within
`max_line_length`.
- Avoid evaluating template expressions while formatting tag contents.

##### Packaging

- Fix npm publish workflow.

###
[`v1.37.0`](https://redirect.github.com/djlint/djLint/blob/HEAD/CHANGELOG.md#1370---2026-06-04)

[Compare
Source](https://redirect.github.com/djlint/djLint/compare/v1.36.4...v1.37.0)

##### Feature

- Add `--format-attribute-js-json` for formatting JavaScript and JSON
inside HTML attributes. It also supports
`format_attribute_js_json_pattern` and
`format_attribute_js_json_min_props` for tuning which attributes are
formatted. Thanks,
[@&#8203;oliverhaas](https://redirect.github.com/oliverhaas).
- Add `--github-output` for GitHub Actions annotations. Thanks,
[@&#8203;iloveitaly](https://redirect.github.com/iloveitaly).

##### Fix

- Fix `ignore_blocks` matching when ignored blocks are indented. Thanks,
[@&#8203;tdryer](https://redirect.github.com/tdryer).
- Use relative paths for `--exclude` and `--use-gitignore` matching so
path filters work consistently from nested directories. Thanks,
[@&#8203;satya-waylit](https://redirect.github.com/satya-waylit).
- Stop D018/J018 from flagging root links such as `href="/"`. Thanks,
[@&#8203;SAY-5](https://redirect.github.com/SAY-5).
- Do not treat soft hyphen entities as text for H023. Thanks,
[@&#8203;kotutuloro](https://redirect.github.com/kotutuloro).
- Fix Handlebars `{{#unless}}` indentation. Thanks,
[@&#8203;S1mplePixels](https://redirect.github.com/S1mplePixels).
- Fix formatting when `/>` appears inside an HTML attribute value.
Thanks, [@&#8203;novucs](https://redirect.github.com/novucs).
- Improve CPU count handling for worker setup.

##### Performance

- Improve formatter caching and reduce cache memory usage. Formatting is
about 19% faster.

##### Documentation

- Add Chinese translation. Thanks,
[@&#8203;Twisuki](https://redirect.github.com/Twisuki).
- Add Homebrew installation instructions. Thanks,
[@&#8203;alfawal](https://redirect.github.com/alfawal).
- Add EFM Neovim integration documentation. Thanks,
[@&#8203;danielebra](https://redirect.github.com/danielebra).
- Add copy-pastable pre-commit YAML to the README. Thanks,
[@&#8203;Pierre-Sassoulas](https://redirect.github.com/Pierre-Sassoulas).
- Polish linter and CLI documentation. Thanks,
[@&#8203;jasonaowen](https://redirect.github.com/jasonaowen) and
[@&#8203;dotWee](https://redirect.github.com/dotWee).

##### Packaging

- Drop Python 3.9 support.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Only on Monday (`* * * * 1`)
- Automerge
  - At any time (no schedule defined)

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

♻ **Rebasing**: Whenever PR is behind base branch, 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](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNDEuNSIsInVwZGF0ZWRJblZlciI6IjQzLjE0MS41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: bircni <bircni@icloud.com>
2026-06-15 04:58:34 +00:00
bircni
bce6df24b7 feat(actions): show run status on browser tab favicon (#38071) 2026-06-15 06:56:26 +02:00
Giteabot
f5ecd74307 chore(deps): update module github.com/go-swagger/go-swagger to v0.34.1 2026-06-15 03:38:55 +00:00
Karthik Bhandary
e70b91d8ec chore: center info message for unsupported jupyter notebook versions (#38114)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-15 10:29:41 +08:00
Lunny Xiao
a77edc7ba4 chore(actions): Add icon for status filter (#38082)
<img width="352" height="391" alt="image"
src="https://github.com/user-attachments/assets/261dd567-49c2-4fc6-a646-5f8641e08192"
/>

---------

Co-authored-by: bircni <bircni@icloud.com>
2026-06-14 19:44:53 +00:00
bircni
55250407dd feat(org): add team visibility so org members can discover teams (#37680)
Closes #37670.

Today, org members in Gitea only see teams they're a member of. In
larger orgs that hurts onboarding and discoverability — there's no way
to look up which team owns what without asking around. GitHub solves
this with a per-team visibility setting; this PR brings the same model
to Gitea.

## What changes

- Every team gets a `visibility` setting:
- `private` *(default)* — only team members and org owners can see the
team. Same as today's behavior.
- `limited` — listable by any member of the organization. Members and
the repos the team has access to are visible too. Non-org-members still
see nothing.
  - `public` — listable by any signed-in user.
- The Owners team visibility is fixed and cannot be changed via
settings.
- Existing teams default to `private`, so this is a no-op for anyone who
doesn't change anything.

## API

- `Team`, `CreateTeamOption`, `EditTeamOption` all gain a `visibility`
field (string enum: `private` | `limited` | `public`).
- `GET /orgs/{org}/teams` and `/orgs/{org}/teams/search` now apply the
same visibility rules as the web UI:
  - site admins and org owners still see every team
- other org members see their own teams plus any `limited` or `public`
team
  - `private` teams are no longer leaked through these endpoints
- Swagger/OpenAPI specs regenerated.

## UI

View from admin2 (not an owner):
<img width="1669" height="726"
src="https://github.com/user-attachments/assets/daf4bccb-644b-4426-b178-71963aeaf73b"
/>

View from admin (owner):

<img width="2559" height="863"
src="https://github.com/user-attachments/assets/4f22cebc-e9df-4fd2-8ed4-724d31fadb7a"
/>

---------

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-14 19:07:25 +00:00
techknowlogick
80ca22a9ef chore(deps): bump dockerfile to use Alpine 3.24 (#38077) 2026-06-14 11:48:14 -07:00
wxiaoguang
47d48eb208 chore: fix form string abuse (#38106) 2026-06-14 18:26:22 +00:00
delvh
3417bc8979 docs: Clarify criteria for becoming a merger (#38113) 2026-06-14 18:06:41 +00:00
TheFox0x7
c6167d1ff5 feat(api): add token introspection and self-deletion endpoint (#37995)
Adds a /api/v1/token endpoint that allows tokens to introspect and
delete themselves.
partially fixes: https://github.com/go-gitea/gitea/issues/33583

Assisted-by: Mistral Vibe:mistral-medium-3.5

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-14 20:05:18 +02:00
bircni
b8ef6a91e6 docs: Publish TOC Election Result 2026 (#38111)
- Adjusted the wording for what happens on a draw (somehow we managed to
get a draw)

The new members are:

- @delvh
- @bircni
- @TheFox0x7

Closes #37551
2026-06-14 09:42:01 -07:00
Pycub
c7af379672 fix(api): nil pointer panic when filtering tracked times by a non-existent user (#38112)
## Problem

`GET /repos/{owner}/{repo}/times` and `GET
/repos/{owner}/{repo}/issues/{index}/times` crash with a nil pointer
dereference when the `user` query filter names a user that does not
exist.

## Root cause

In `ListTrackedTimes` and `ListTrackedTimesByRepository`, the
`IsErrUserNotExist` branch sends the 404 but is missing a `return`, so
execution falls through to `opts.UserID = user.ID` with a nil `user`.

---------

Co-authored-by: bircni <bircni@icloud.com>
2026-06-14 17:23:48 +02:00
Karthik Bhandary
e82352f156 feat(web): Add Jupyter Notebook (.ipynb) Rendering Support (#37433)
### Summary

Closes #37308

Adds native rendering support for Jupyter notebook files (`.ipynb`) in
Gitea using backend rendering, allowing users to view formatted
notebooks with code cells, markdown, outputs, and visualizations
directly in the repository browser.

### Motivation

Jupyter notebooks are widely used in data science, machine learning, and
scientific computing. Currently, Gitea displays `.ipynb` files as raw
JSON, making them difficult to read. This feature enables users to view
notebooks in a formatted, readable way similar to GitHub and GitLab.

### Implementation Approach

**Evolution:** Initially implemented frontend rendering using `marked`
and `Shiki` libraries. After review feedback, migrated to backend
rendering for better performance, security, and consistency with Gitea
architecture.

#### Backend Rendering Advantages

- Server-side HTML generation eliminates client-side parsing overhead
- Integrates with Gitea existing markup sanitizer for security
- Uses Chroma for syntax highlighting (consistent with code files)
- Uses Goldmark for markdown rendering (consistent with `.md` files)
- No additional frontend dependencies required
- Better performance for large notebooks

### Features

#### Supported Cell Types

- **Markdown cells:** Rendered with Goldmark (tables, lists, links, code
blocks, etc.)
- **Code cells:** Syntax-highlighted with Chroma, execution counts,
language detection from notebook metadata
- **Output cells:** Multiple output types in a single cell

#### Supported Output Types

-  Text/plain outputs
-  Images (PNG, JPEG, SVG) with base64 data URIs
-  HTML outputs (tables, DataFrames, formatted text)
-  LaTeX/math equations (rendered as code blocks)
-  Error outputs with traceback (styled in red)
-  Stream outputs (`stdout`/`stderr`)
- ⚠️ Interactive widgets (Plotly, ipywidgets) show informative messages
- ⚠️ JavaScript outputs show security warning (disabled for safety)

#### Edge Cases Handled

- Empty notebooks or notebooks with no outputs
- Corrupted JSON with graceful error display
- Mixed output types in single cell
- Large base64-encoded images
- Execution count of `null` or `0`
- `nbformat` version compatibility (only renders `nbformat 4+`, shows
message for older versions)

### Changes

#### Backend (Go)

- `modules/markup/jupyter/jupyter.go` (**NEW**)

  - Jupyter notebook renderer implementation
  - Parses `.ipynb` JSON structure and generates HTML
  - Integrates Chroma for code syntax highlighting
  - Integrates Goldmark for markdown cell rendering
  - Dynamic language detection from notebook metadata
  - Handles all standard Jupyter output types
  - Comprehensive error handling with user-friendly messages

- `modules/markup/renderer.go` (**MODIFIED**)

  - Registered Jupyter renderer in markup system

- `main.go` (**MODIFIED**)

  - Import Jupyter renderer package for initialization

#### Styling (CSS)

- `web_src/css/markup/jupyter.css` (**NEW**)

  - Comprehensive styling for notebook cells, code, outputs
  - Uses Gitea CSS variables for consistent theming
  - Responsive layout with proper spacing
  - Table styling for DataFrame outputs
- Removed parent container padding for consistency with other renderers

#### Sanitizer Rules

- `modules/markup/jupyter/jupyter.go` → `SanitizerRules()`

  - Configured HTML sanitization rules for safe rendering:
    - Cell structure (markdown, code, input/output wrappers)
    - Code highlighting (Chroma classes)
    - Images (base64 data URIs only)
    - Tables (DataFrames)
    - Markdown elements (headers, lists, links, etc.)

### Security Considerations

- Server-side rendering: No client-side JavaScript execution
- HTML sanitization: Strict allowlist for HTML elements and attributes
- Image security: Only base64 data URIs allowed (no external URLs)
- JavaScript disabled: `application/javascript` outputs show warning
- XSS protection: Gitea markup sanitizer handles all HTML output

### Testing

Manual testing performed with various notebooks:

- Markdown rendering (headers, lists, tables, links, code blocks)
- Code cells with execution counts and syntax highlighting
- Multiple output types (text, images, HTML, LaTeX, errors, streams)
- Error handling for edge cases
- Theme compatibility (light/dark mode)

### Screenshots

<img width="1080" height="553" alt="image"
src="https://github.com/user-attachments/assets/aef9afa7-ed96-434d-98b0-b160565fc967"
/>
<img width="1092" height="552" alt="image"
src="https://github.com/user-attachments/assets/6e61e792-4737-41c1-851e-5c375c1f932a"
/>
<img width="1104" height="622" alt="image"
src="https://github.com/user-attachments/assets/4ac630c1-3a75-4e1c-9bba-c0a27484d001"
/>
<img width="1104" height="529" alt="image"
src="https://github.com/user-attachments/assets/33750c47-70de-4ab2-893d-e5d09fa8d9c4"
/>
<img width="1111" height="343" alt="image"
src="https://github.com/user-attachments/assets/52107d9f-0e06-420b-9ab4-1603dcd676b1"
/>
<img width="1091" height="650" alt="image"
src="https://github.com/user-attachments/assets/0addae21-efa4-44bb-a56e-0418e3d4d227"
/>
<img width="1077" height="298" alt="image"
src="https://github.com/user-attachments/assets/a3a8c5be-638c-45ff-82f3-816264254ead"
/>

### Dependencies

No new dependencies required:

- Chroma (existing) - Syntax highlighting
- Goldmark (existing) - Markdown rendering
- Standard library - JSON parsing

### Key Design Decisions

- Backend rendering for performance and security
- Reuses existing Gitea infrastructure (Chroma, Goldmark, sanitizer)
- Consistent styling with other markup renderers
- Graceful degradation for unsupported features

---

**Development Note:** This PR was developed with assistance from Amazon
Q Developer and Claude AI for implementation, debugging, and testing.

---------

Signed-off-by: Karthik Bhandary <34509856+karthikbhandary2@users.noreply.github.com>
Co-authored-by: karthik.bhandary <karthik.bhandary@kfintech.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-06-14 15:52:37 +02:00
96 changed files with 3216 additions and 623 deletions

View File

@@ -21,7 +21,7 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14
- uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
with:
renovate-version: ${{ env.RENOVATE_VERSION }}
configurationFile: renovate.json5

View File

@@ -131,7 +131,7 @@ jobs:
ports:
- "7700:7700"
redis:
image: redis:latest@sha256:e74c9b933d78e2829583d88f92793f4524752a15ac59c8baff2dd5ed000b7432
image: redis:latest@sha256:a505f8b9d8ac3ff7b0848055b4abf1901d6d77606774aa1e38bd37f1197ed2b5
options: >- # wait until redis has started
--health-cmd "redis-cli ping"
--health-interval 5s

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.24 AS frontend-build
RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
@@ -9,7 +9,7 @@ COPY --exclude=.git/ . .
RUN make frontend
# Build backend for each target platform
FROM docker.io/library/golang:1.26-alpine3.23 AS build-env
FROM docker.io/library/golang:1.26-alpine3.24 AS build-env
ARG GITEA_VERSION
ARG TAGS=""
@@ -44,7 +44,7 @@ RUN chmod 755 /tmp/local/usr/bin/entrypoint \
/tmp/local/etc/s6/.s6-svscan/* \
/go/src/gitea.dev/gitea
FROM docker.io/library/alpine:3.23 AS gitea
FROM docker.io/library/alpine:3.24 AS gitea
EXPOSE 22 3000

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.24 AS frontend-build
RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
@@ -9,7 +9,7 @@ COPY --exclude=.git/ . .
RUN make frontend
# Build backend for each target platform
FROM docker.io/library/golang:1.26-alpine3.23 AS build-env
FROM docker.io/library/golang:1.26-alpine3.24 AS build-env
ARG GITEA_VERSION
ARG TAGS=""
@@ -39,7 +39,7 @@ COPY docker/rootless /tmp/local
RUN chmod 755 /tmp/local/usr/local/bin/* \
/go/src/gitea.dev/gitea
FROM docker.io/library/alpine:3.23 AS gitea-rootless
FROM docker.io/library/alpine:3.24 AS gitea-rootless
EXPOSE 2222 3000

View File

@@ -16,7 +16,7 @@ EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-che
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.1 # renovate: datasource=go
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go

View File

@@ -57,8 +57,8 @@ func main() {
log.Fatalf("scanning swagger:enum annotations: %v", err)
}
names := make([]string, 0, len(astEnumMap))
for _, n := range astEnumMap {
names = append(names, n)
for _, ns := range astEnumMap {
names = append(names, ns...)
}
sort.Strings(names)
fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", "))

View File

@@ -6,6 +6,7 @@ package openapi3gen
import (
"fmt"
"regexp"
"sort"
"strings"
"gitea.dev/modules/json"
@@ -25,10 +26,12 @@ var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`)
// Gitea-specific post-processing: file-schema fixups, URI formats,
// deprecated flags, and shared-enum extraction.
//
// astEnumMap is a value-set-key → Go-type-name map (built by
// ScanSwaggerEnumTypes). If a shared enum in the spec has no entry in the
// map, Convert returns an error — no fallback naming.
func Convert(swaggerJSON []byte, astEnumMap map[string]string) (*openapi3.T, error) {
// astEnumMap is a value-set-key → Go-type-name(s) map (built by
// ScanSwaggerEnumTypes). When a value set is shared by multiple Go types,
// per-property disambiguation uses the x-go-enum-desc extension. If a shared
// enum in the spec has no matching entry, Convert returns an error — no
// fallback naming.
func Convert(swaggerJSON []byte, astEnumMap map[string][]string) (*openapi3.T, error) {
var swagger2 openapi2.T
if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil {
return nil, fmt.Errorf("parsing swagger 2.0: %w", err)
@@ -176,12 +179,24 @@ type enumUsage struct {
// If the derived enum name collides with an existing component schema, or
// no // swagger:enum annotation matches the value set, generation aborts
// with an actionable error — there are no silent fallbacks.
func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
func extractSharedEnums(doc *openapi3.T, astEnumMap map[string][]string) error {
if doc.Components == nil {
return nil
}
enumGroups := map[string][]enumUsage{}
type groupKey struct {
valueSet string
typeName string
}
enumGroups := map[groupKey][]enumUsage{}
groupOrder := []groupKey{} // deterministic iteration
addUsage := func(key groupKey, u enumUsage) {
if _, seen := enumGroups[key]; !seen {
groupOrder = append(groupOrder, key)
}
enumGroups[key] = append(enumGroups[key], u)
}
for schemaName, schemaRef := range doc.Components.Schemas {
if schemaRef.Value == nil {
@@ -192,24 +207,31 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
continue
}
if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") {
key := EnumKey(propRef.Value.Enum)
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, false})
key := groupKey{
valueSet: EnumKey(propRef.Value.Enum),
typeName: extractEnumTypeName(propRef.Value, astEnumMap),
}
addUsage(key, enumUsage{schemaName, propName, propRef, false})
}
if propRef.Value.Type.Is("array") && propRef.Value.Items != nil &&
propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" &&
len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") {
key := EnumKey(propRef.Value.Items.Value.Enum)
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, true})
key := groupKey{
valueSet: EnumKey(propRef.Value.Items.Value.Enum),
typeName: extractEnumTypeName(propRef.Value.Items.Value, astEnumMap),
}
addUsage(key, enumUsage{schemaName, propName, propRef, true})
}
}
}
for key, usages := range enumGroups {
for _, key := range groupOrder {
usages := enumGroups[key]
if len(usages) < 2 {
continue
}
enumName, err := deriveEnumName(key, usages, astEnumMap)
enumName, err := deriveEnumName(key.valueSet, key.typeName, usages, astEnumMap)
if err != nil {
return err
}
@@ -257,12 +279,17 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
return nil
}
// deriveEnumName looks up a shared enum's Go type name from astEnumMap by
// value-set key. If no annotation matches, returns an error identifying the
// offending properties and the fix.
func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string) (string, error) {
if name, ok := astEnumMap[key]; ok {
return name, nil
// deriveEnumName looks up a shared enum's Go type name. If typeName is
// non-empty (because we recovered it from x-go-enum-desc), it is used
// directly. Otherwise the value-set must map to exactly one known type. On
// failure, returns an error identifying the offending properties.
func deriveEnumName(key, typeName string, usages []enumUsage, astEnumMap map[string][]string) (string, error) {
if typeName != "" {
return typeName, nil
}
names := astEnumMap[key]
if len(names) == 1 {
return names[0], nil
}
props := map[string]bool{}
@@ -273,9 +300,87 @@ func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string
for p := range props {
propList = append(propList, p)
}
if len(names) > 1 {
return "", fmt.Errorf(
"value-set %q is shared by multiple swagger:enum types %v and could not be disambiguated for properties: %v; "+
"ensure go-swagger emits x-go-enum-desc for those properties",
key, names, propList,
)
}
return "", fmt.Errorf(
"no swagger:enum annotation matches value-set %q used by %d properties: %v; "+
"fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus",
key, len(usages), propList,
)
}
// extractEnumTypeName recovers the Go type name a schema's enum came from by
// parsing the property's x-go-enum-desc extension. go-swagger emits one line
// per value as "<value> <ConstName>[ <free text>]"; the type is the longest
// common prefix of the const names, narrowed to the candidate set in
// astEnumMap. Returns "" if extraction is inconclusive.
func extractEnumTypeName(s *openapi3.Schema, astEnumMap map[string][]string) string {
if s == nil || s.Extensions == nil {
return ""
}
raw, ok := s.Extensions["x-go-enum-desc"]
if !ok {
return ""
}
desc, ok := raw.(string)
if !ok {
return ""
}
candidates := astEnumMap[EnumKey(s.Enum)]
if len(candidates) == 0 {
return ""
}
// Collect the const names (second whitespace-separated field per line).
var consts []string
for line := range strings.SplitSeq(desc, "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
consts = append(consts, fields[1])
}
}
if len(consts) == 0 {
return ""
}
// A candidate matches when it is a prefix of every const name AND the
// first character after the prefix is an uppercase ASCII letter — this
// rejects e.g. "Alpha" matching "Alphabet" (suffix "bet" starts lower)
// while still accepting both "Alpha" and "AlphaPlus" against "AlphaPlusX"
// (both prefixes valid). The most specific (longest) wins; ties return
// "" so deriveEnumName surfaces the ambiguity rather than silently
// picking a winner.
ordered := append([]string(nil), candidates...)
sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) })
var matches []string
for _, name := range ordered {
ok := true
for _, c := range consts {
if !strings.HasPrefix(c, name) {
ok = false
break
}
suffix := c[len(name):]
// Empty suffix means the const name exactly equals the type name — valid exact match.
// A non-empty suffix must begin with an uppercase letter to reject incidental
// prefix matches (e.g. "Alpha" should not match "Alphabet").
if len(suffix) > 0 && (suffix[0] < 'A' || suffix[0] > 'Z') {
ok = false
break
}
}
if ok {
matches = append(matches, name)
}
}
if len(matches) == 0 {
return ""
}
if len(matches) > 1 && len(matches[0]) == len(matches[1]) {
return ""
}
return matches[0]
}

View File

@@ -12,9 +12,9 @@ import (
func TestDeriveEnumName_hit(t *testing.T) {
key := EnumKey([]any{"red", "green", "blue"})
astMap := map[string]string{key: "Color"}
astMap := map[string][]string{key: {"Color"}}
usages := []enumUsage{{schemaName: "Paint", propName: "color"}}
got, err := deriveEnumName(key, usages, astMap)
got, err := deriveEnumName(key, "", usages, astMap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -26,7 +26,7 @@ func TestDeriveEnumName_hit(t *testing.T) {
func TestDeriveEnumName_miss(t *testing.T) {
key := EnumKey([]any{"x", "y"})
usages := []enumUsage{{schemaName: "Thing", propName: "kind"}}
_, err := deriveEnumName(key, usages, map[string]string{})
_, err := deriveEnumName(key, "", usages, map[string][]string{})
if err == nil {
t.Fatal("expected miss error, got nil")
}
@@ -64,7 +64,7 @@ func TestExtractSharedEnums_usesASTMap(t *testing.T) {
},
},
}
astMap := map[string]string{EnumKey([]any{"red", "green", "blue"}): "Color"}
astMap := map[string][]string{EnumKey([]any{"red", "green", "blue"}): {"Color"}}
if err := extractSharedEnums(doc, astMap); err != nil {
t.Fatalf("extractSharedEnums: %v", err)
}
@@ -139,6 +139,54 @@ func TestFixFileSchemas_recursesIntoNested(t *testing.T) {
}
}
func TestExtractEnumTypeName_TeamVisibility(t *testing.T) {
enum := []any{"public", "limited", "private"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"UserVisibility", "TeamVisibility"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
},
}
if got := extractEnumTypeName(schema, astMap); got != "TeamVisibility" {
t.Fatalf("got %q, want %q", got, "TeamVisibility")
}
}
func TestExtractEnumTypeName_ambiguousPrefixTie(t *testing.T) {
enum := []any{"one", "two"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"AB", "AC"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "one ABOne\ntwo ACTwo",
},
}
if got := extractEnumTypeName(schema, astMap); got != "" {
t.Fatalf("got %q, want empty string for ambiguous tie", got)
}
}
func TestExtractEnumTypeName_rejectsIncidentalPrefix(t *testing.T) {
enum := []any{"a", "b"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"Alpha", "Alphabet"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "a AlphabetA\nb AlphabetB",
},
}
if got := extractEnumTypeName(schema, astMap); got != "Alphabet" {
t.Fatalf("got %q, want %q", got, "Alphabet")
}
}
func TestExtractSharedEnums_missReturnsError(t *testing.T) {
doc := &openapi3.T{
Components: &openapi3.Components{
@@ -164,7 +212,7 @@ func TestExtractSharedEnums_missReturnsError(t *testing.T) {
},
},
}
if err := extractSharedEnums(doc, map[string]string{}); err == nil {
if err := extractSharedEnums(doc, map[string][]string{}); err == nil {
t.Fatal("expected miss error")
}
}

View File

@@ -34,13 +34,16 @@ func EnumKey(values []any) string {
var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`)
// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from
// a canonical value-set key (see EnumKey) to the Go type name declared with
// // swagger:enum TypeName.
// a canonical value-set key (see EnumKey) to the Go type names declared with
// // swagger:enum TypeName. Multiple type names per key are allowed (e.g.
// distinct enum types that happen to share a value set such as
// {public, limited, private}); callers must disambiguate per-usage (typically
// by parsing the property's x-go-enum-desc extension to recover the const
// type prefix).
//
// Returns an error on parse failure, on an annotation for a type whose
// constants can't be extracted, or on value-set collisions between two
// different enum types.
func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) {
// Returns an error on parse failure or on an annotation for a type whose
// constants can't be extracted.
func ScanSwaggerEnumTypes(dirs []string) (map[string][]string, error) {
fset := token.NewFileSet()
parsed := []*ast.File{}
@@ -92,17 +95,18 @@ func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) {
}
}
result := map[string]string{}
result := map[string][]string{}
for typeName := range enumTypes {
values, ok := enumValues[typeName]
if !ok || len(values) == 0 {
return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName)
}
key := EnumKey(values)
if existing, ok := result[key]; ok && existing != typeName {
return nil, fmt.Errorf("swagger:enum value-set collision: %s and %s both use %q", existing, typeName, key)
}
result[key] = typeName
result[key] = append(result[key], typeName)
}
for key, names := range result {
sort.Strings(names)
result[key] = names
}
return result, nil
}

View File

@@ -6,10 +6,19 @@ package openapi3gen
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func single(got map[string][]string, key string) string {
v := got[key]
if len(v) != 1 {
return ""
}
return v[0]
}
func TestEnumKey_sortsAndJoins(t *testing.T) {
key := EnumKey([]any{"b", "a", "c"})
if key != "a|b|c" {
@@ -47,7 +56,7 @@ const (
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
wantKey := EnumKey([]any{"red", "green", "blue"})
if got[wantKey] != "Color" {
if single(got, wantKey) != "Color" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color")
}
}
@@ -98,13 +107,14 @@ const (
t.Fatal(err)
}
_, err := ScanSwaggerEnumTypes([]string{dir})
if err == nil {
t.Fatal("expected collision error, got nil")
got, err := ScanSwaggerEnumTypes([]string{dir})
if err != nil {
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
msg := err.Error()
if !strings.Contains(msg, "Alpha") || !strings.Contains(msg, "Beta") {
t.Fatalf("error %q should mention both Alpha and Beta", msg)
key := EnumKey([]any{"x", "y"})
names := got[key]
if !slices.Equal(names, []string{"Alpha", "Beta"}) {
t.Fatalf("map[%q] = %v, want [Alpha Beta]", key, names)
}
}
@@ -168,7 +178,7 @@ type Hue string
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
wantKey := EnumKey([]any{"a", "b"})
if got[wantKey] != "Hue" {
if single(got, wantKey) != "Hue" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue")
}
}
@@ -194,7 +204,7 @@ type Shade string
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
wantKey := EnumKey([]any{"dark", "light"})
if got[wantKey] != "Shade" {
if single(got, wantKey) != "Shade" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade")
}
}
@@ -230,10 +240,10 @@ const (
}
colorKey := EnumKey([]any{"red", "blue"})
shadeKey := EnumKey([]any{"dark", "light"})
if got[colorKey] != "Color" {
if single(got, colorKey) != "Color" {
t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color")
}
if got[shadeKey] != "Shade" {
if single(got, shadeKey) != "Shade" {
t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade")
}
}

View File

@@ -164,7 +164,12 @@ Mergers are the maintainers who carry out the final merge of approved PRs. Their
#### Becoming a merger
A merger should already be a Gitea maintainer. To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel. Mergers teams may also invite contributors.
A merger must already be a Gitea maintainer.
To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel.
The minimum requirement for applications to become a merger is to have participated actively in the community for at least four months before applying.
Ultimately, regardless of previous participation, you can only become a merger if the TOC votes in your favor.
You may also be invited by the TOC to become a merger.
### Technical Oversight Committee (TOC)
@@ -185,17 +190,30 @@ As long as seats are empty in the TOC, members of the previous TOC can fill them
If an elected member that accepts the seat does not have 2FA configured yet, they will be temporarily counted as `answer pending` until they manage to configure 2FA, thus leaving their seat empty for this duration.
If multiple persons have the same amount of votes, a random draw will be used to determine the order of the candidates with the same amount of votes, and thus who gets the seat first.
The candidates will be placed in the list in an alphabetical insensitive order by their username.
We use this script to determine the order of candidates with the same amount of votes:
```python
import random
random.seed("Gitea TOC <YEAR> Election")
random.choice([<CANDIDATE_1>, <CANDIDATE_2>, ...])
```
The result of this script needs then to be published in the TOC election issue to ensure transparency of the process.
### Current TOC members
- 2025-01-01 ~ 2026-06-14
- 2026-06-14 ~ 2026-12-31
- Company
- [Jason Song](https://gitea.com/wolfogre) <i@wolfogre.com>
- [Lunny Xiao](https://gitea.com/lunny) <xiaolunwen@gmail.com>
- [Matti Ranta](https://gitea.com/techknowlogick) <techknowlogick@gitea.com>
- Community
- [6543](https://gitea.com/6543) <6543@obermui.de>
- [bircni](https://gitea.com/bircni) <bircni@icloud.com>
- [delvh](https://gitea.com/delvh) <dev.lh@web.de>
- [lafriks](https://gitea.com/lafriks) <lauris@nix.lv>
- [TheFox0x7](https://gitea.com/TheFox0x7) <thefox0x7@gmail.com>
### Previous TOC/owners members
@@ -204,10 +222,10 @@ Here's the history of the owners and the time they served:
- [Lunny Xiao](https://gitea.com/lunny) - 2016, 2017, [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
- [Kim Carlbäcker](https://github.com/bkcsoft) - 2016, 2017
- [Thomas Boerger](https://gitea.com/tboerger) - 2016, 2017
- [Lauris Bukšis-Haberkorns](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801)
- [Lauris Bukšis](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), 2025
- [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
- [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
- [6543](https://gitea.com/6543) - 2023
- [6543](https://gitea.com/6543) - 2023, 2025
- [John Olheiser](https://gitea.com/jolheiser) - 2023, 2024
- [Jason Song](https://gitea.com/wolfogre) - 2023

2
go.mod
View File

@@ -105,6 +105,7 @@ require (
go.yaml.in/yaml/v4 v4.0.0-rc.5
golang.org/x/crypto v0.53.0
golang.org/x/image v0.42.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
@@ -267,7 +268,6 @@ require (
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect

View File

@@ -17,6 +17,7 @@ import (
// register supported doc types
_ "gitea.dev/modules/markup/console"
_ "gitea.dev/modules/markup/csv"
_ "gitea.dev/modules/markup/jupyter"
_ "gitea.dev/modules/markup/markdown"
_ "gitea.dev/modules/markup/orgmode"

View File

@@ -111,6 +111,7 @@ func (opts FindRunOptions) ToOrders() string {
type StatusInfo struct {
Status int
StatusName string
DisplayedStatus string
}
@@ -122,6 +123,7 @@ func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInf
for _, s := range allStatus {
statusInfoList = append(statusInfoList, StatusInfo{
Status: int(s),
StatusName: s.String(),
DisplayedStatus: s.LocaleString(lang),
})
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"gitea.dev/models/unittest"
"gitea.dev/modules/translation"
"github.com/stretchr/testify/assert"
)
@@ -22,3 +23,15 @@ func TestGetRunWorkflowIDs(t *testing.T) {
assert.NoError(t, err)
assert.Empty(t, ids)
}
func TestGetStatusInfoList(t *testing.T) {
statusInfoList := GetStatusInfoList(t.Context(), translation.MockLocale{})
assert.Equal(t, []StatusInfo{
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
{Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
}, statusInfoList)
}

View File

@@ -20,42 +20,6 @@ import (
"xorm.io/builder"
)
// ErrAccessTokenNotExist represents a "AccessTokenNotExist" kind of error.
type ErrAccessTokenNotExist struct {
Token string
}
// IsErrAccessTokenNotExist checks if an error is a ErrAccessTokenNotExist.
func IsErrAccessTokenNotExist(err error) bool {
_, ok := err.(ErrAccessTokenNotExist)
return ok
}
func (err ErrAccessTokenNotExist) Error() string {
return fmt.Sprintf("access token does not exist [sha: %s]", err.Token)
}
func (err ErrAccessTokenNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrAccessTokenEmpty represents a "AccessTokenEmpty" kind of error.
type ErrAccessTokenEmpty struct{}
// IsErrAccessTokenEmpty checks if an error is a ErrAccessTokenEmpty.
func IsErrAccessTokenEmpty(err error) bool {
_, ok := err.(ErrAccessTokenEmpty)
return ok
}
func (err ErrAccessTokenEmpty) Error() string {
return "access token is empty"
}
func (err ErrAccessTokenEmpty) Unwrap() error {
return util.ErrInvalidArgument
}
var successfulAccessTokenCache *lru.Cache[string, any]
// AccessToken represents a personal access token.
@@ -134,21 +98,11 @@ func getAccessTokenIDFromCache(token string) int64 {
// GetAccessTokenBySHA returns access token by given token value
func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error) {
if token == "" {
return nil, ErrAccessTokenEmpty{}
}
// A token is defined as being SHA1 sum these are 40 hexadecimal bytes long
if len(token) != 40 {
return nil, ErrAccessTokenNotExist{token}
}
for _, x := range []byte(token) {
if x < '0' || (x > '9' && x < 'a') || x > 'f' {
return nil, ErrAccessTokenNotExist{token}
}
if len(token) < 8 {
return nil, util.NewNotExistErrorf("access token not found")
}
lastEight := token[len(token)-8:]
if id := getAccessTokenIDFromCache(token); id > 0 {
accessToken := &AccessToken{
TokenLastEight: lastEight,
@@ -169,7 +123,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error
if err != nil {
return nil, err
} else if len(tokens) == 0 {
return nil, ErrAccessTokenNotExist{token}
return nil, util.NewNotExistErrorf("access token not found")
}
for _, t := range tokens {
@@ -181,7 +135,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error
return &t, nil
}
}
return nil, ErrAccessTokenNotExist{token}
return nil, util.NewNotExistErrorf("access token not found")
}
// AccessTokenByNameExists checks if a token name has been used already by a user.
@@ -218,13 +172,11 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error {
// DeleteAccessTokenByID deletes access token by given ID.
func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{
UID: userID,
})
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID})
if err != nil {
return err
} else if cnt != 1 {
return ErrAccessTokenNotExist{}
return util.NewNotExistErrorf("access token not found")
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -76,11 +77,11 @@ func TestGetAccessTokenBySHA(t *testing.T) {
_, err = auth_model.GetAccessTokenBySHA(t.Context(), "notahash")
assert.Error(t, err)
assert.True(t, auth_model.IsErrAccessTokenNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
_, err = auth_model.GetAccessTokenBySHA(t.Context(), "")
assert.Error(t, err)
assert.True(t, auth_model.IsErrAccessTokenEmpty(err))
assert.ErrorIs(t, err, util.ErrNotExist)
}
func TestListAccessTokens(t *testing.T) {
@@ -128,5 +129,5 @@ func TestDeleteAccessTokenByID(t *testing.T) {
err = auth_model.DeleteAccessTokenByID(t.Context(), 100, 100)
assert.Error(t, err)
assert.True(t, auth_model.IsErrAccessTokenNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
}

View File

@@ -28,9 +28,8 @@ var (
registeredInitFuncs []func() error
)
// Engine represents a xorm engine or session.
type Engine interface {
Table(tableNameOrBean any) *xorm.Session
// SQLSession represents a common interface for engine and session to execute SQLs
type SQLSession interface {
Count(...any) (int64, error)
Decr(column string, arg ...any) *xorm.Session
Delete(...any) (int64, error)
@@ -52,7 +51,6 @@ type Engine interface {
Limit(limit int, start ...int) *xorm.Session
NoAutoTime() *xorm.Session
SumInt(bean any, columnName string) (res int64, err error)
Sync(...any) error
Select(string) *xorm.Session
SetExpr(string, any) *xorm.Session
NotIn(string, ...any) *xorm.Session
@@ -61,12 +59,20 @@ type Engine interface {
Distinct(...string) *xorm.Session
Query(...any) ([]map[string][]byte, error)
Cols(...string) *xorm.Session
Table(tableNameOrBean any) *xorm.Session
Context(ctx context.Context) *xorm.Session
Ping() error
QueryInterface(sqlOrArgs ...any) ([]map[string]any, error)
IsTableExist(tableNameOrBean any) (bool, error)
}
// Session represents a xorm session interface, used as an abstraction over *xorm.Session.
// Engine represents a xorm engine
type Engine interface {
SQLSession
Sync(...any) error
Ping() error
}
// Session represents a xorm session interface
type Session interface {
Engine
And(query any, args ...any) *xorm.Session
@@ -89,7 +95,6 @@ type EngineMigration interface {
Dialect() dialects.Dialect
DropTables(beans ...any) error
NewSession() *xorm.Session
QueryInterface(sqlOrArgs ...any) ([]map[string]any, error)
SetMapper(mapper names.Mapper)
SyncWithOptions(opts xorm.SyncOptions, beans ...any) (*xorm.SyncResult, error)
TableInfo(bean any) (*schemas.Table, error)

View File

@@ -24,10 +24,9 @@ type Paginator interface {
}
// SetSessionPagination sets pagination for a database session
func SetSessionPagination(sess Engine, p Paginator) Session {
func SetSessionPagination(sess Engine, p Paginator) {
skip, take := p.GetSkipTake()
return sess.Limit(take, skip)
sess.Limit(take, skip)
}
// ListOptions options to paginate results

View File

@@ -181,7 +181,7 @@ func PullRequests(ctx context.Context, baseRepoID int64, opts *PullRequestsOptio
findSession := listPullRequestStatement(ctx, baseRepoID, opts)
applySorts(findSession, opts.SortType, 0)
findSession = db.SetSessionPagination(findSession, opts)
db.SetSessionPagination(findSession, opts)
prs := make([]*PullRequest, 0, opts.PageSize)
found := findSession.Find(&prs)
return prs, maxResults, found

View File

@@ -414,6 +414,7 @@ func prepareMigrationTasks() []*migration {
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
}
return preparedMigrations
}

View File

@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/models/db"
"xorm.io/xorm"
)
type VisibleType int
type teamWithVisibility struct {
Visibility VisibleType `xorm:"NOT NULL DEFAULT 2"`
}
func (teamWithVisibility) TableName() string {
return "team"
}
func AddVisibilityToTeam(x db.EngineMigration) error {
if _, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
IgnoreConstrains: true,
}, new(teamWithVisibility)); err != nil {
return err
}
// Owner teams must remain listable to all org members; new orgs create
// them as "limited", so make existing owner teams limited too.
// Filter on authorize=4 (AccessModeOwner) so a user-created team that
// happens to share the name "owners" is not accidentally affected.
_, err := x.Exec("UPDATE `team` SET visibility = ? WHERE lower_name = ? AND authorize = ?", 1, "owners", 4)
return err
}

View File

@@ -370,6 +370,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
NumMembers: 1,
IncludesAllRepositories: true,
CanCreateOrgRepo: true,
Visibility: structs.VisibleTypeLimited,
}
if err = db.Insert(ctx, t); err != nil {
return fmt.Errorf("insert owner team: %w", err)

View File

@@ -14,6 +14,7 @@ import (
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"xorm.io/builder"
@@ -81,9 +82,36 @@ type Team struct {
Members []*user_model.User `xorm:"-"`
NumRepos int
NumMembers int
Units []*TeamUnit `xorm:"-"`
IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"`
CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"`
Units []*TeamUnit `xorm:"-"`
IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"`
CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"`
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 2"`
}
func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
// the team's metadata, based on the team's visibility tier and the parent org's
// visibility. Privileged callers (site admins, org owners, team members) are
// decided by the caller before reaching here.
func (t *Team) CanNonMemberReadMeta(ctx context.Context, org, doer *user_model.User) (bool, error) {
switch t.Visibility {
case structs.VisibleTypePublic:
return HasOrgOrUserVisible(ctx, org, doer), nil
case structs.VisibleTypeLimited:
return IsOrganizationMember(ctx, t.OrgID, doer.ID)
default:
return false, nil
}
}
func NormalizeTeamVisibility(s string) structs.VisibleType {
if vt, ok := structs.VisibilityModes[s]; ok {
return vt
}
return structs.VisibleTypePrivate
}
func init() {

View File

@@ -10,6 +10,8 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
"xorm.io/builder"
)
@@ -50,9 +52,15 @@ type SearchTeamOptions struct {
Keyword string
OrgID int64
IncludeDesc bool
// IncludeVisibilities, when combined with UserID, also returns teams whose
// visibility is in this list, even if UserID is not a member. Typical values:
// - {limited,public} for org members
// - {public} for signed-in users who are not org members
// Leave empty to return only teams the user is a member of.
IncludeVisibilities []structs.VisibleType
}
func (opts *SearchTeamOptions) toCond() builder.Cond {
func (opts *SearchTeamOptions) applyToSession(sess db.SQLSession) {
cond := builder.NewCond()
if len(opts.Keyword) > 0 {
@@ -68,11 +76,51 @@ func (opts *SearchTeamOptions) toCond() builder.Cond {
cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
}
if opts.UserID > 0 {
switch {
case opts.UserID > 0 && len(opts.IncludeVisibilities) > 0:
sess = sess.Join("LEFT", "team_user", "team_user.team_id = team.id AND team_user.uid = ?", opts.UserID)
cond = cond.And(builder.Or(
builder.Eq{"team_user.uid": opts.UserID},
builder.In("`team`.visibility", opts.IncludeVisibilities),
))
case opts.UserID > 0:
sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
case len(opts.IncludeVisibilities) > 0:
cond = cond.And(builder.In("`team`.visibility", opts.IncludeVisibilities))
}
sess.Where(cond)
}
return cond
func VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn bool) []structs.VisibleType {
switch {
case isOrgMember:
return []structs.VisibleType{structs.VisibleTypeLimited, structs.VisibleTypePublic}
case isSignedIn:
return []structs.VisibleType{structs.VisibleTypePublic}
default:
return nil
}
}
func ApplyTeamListFilter(ctx context.Context, orgID int64, viewer *user_model.User, isSignedIn bool, opts *SearchTeamOptions) error {
if viewer.IsAdmin {
return nil
}
isOwner, err := IsOrganizationOwner(ctx, orgID, viewer.ID)
if err != nil {
return err
}
if isOwner {
return nil
}
isOrgMember, err := IsOrganizationMember(ctx, orgID, viewer.ID)
if err != nil {
return err
}
opts.UserID = viewer.ID
opts.IncludeVisibilities = VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn)
return nil
}
// SearchTeam search for teams. Caller is responsible to check permissions.
@@ -80,15 +128,12 @@ func SearchTeam(ctx context.Context, opts *SearchTeamOptions) (TeamList, int64,
sess := db.GetEngine(ctx)
opts.SetDefaultValues()
cond := opts.toCond()
opts.applyToSession(sess)
if opts.UserID > 0 {
sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
}
db.SetSessionPagination(sess, opts)
teams := make([]*Team, 0, opts.PageSize)
count, err := sess.Where(cond).OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams)
count, err := sess.OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams)
if err != nil {
return nil, 0, err
}

View File

@@ -10,6 +10,8 @@ import (
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
)
@@ -38,6 +40,43 @@ func TestTeam_IsMember(t *testing.T) {
assert.False(t, team.IsMember(t.Context(), unittest.NonexistentID))
}
func TestTeam_CanNonMemberReadMeta(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // public org
org35 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 35}) // private org
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // member of org 3 and org 35
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // member of neither org
test := func(name string, team *organization.Team, org, doer *user_model.User, expected bool) {
t.Run(name, func(t *testing.T) {
ok, err := team.CanNonMemberReadMeta(t.Context(), org, doer)
assert.NoError(t, err)
assert.Equal(t, expected, ok)
})
}
// Public team is gated only by the parent org's visibility.
publicTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePublic}
test("public team, public org, member", publicTeam, org3, member, true)
test("public team, public org, outsider", publicTeam, org3, outsider, true)
// Public team inside a private org: only org members may see it.
publicTeamPrivOrg := &organization.Team{OrgID: 35, Visibility: structs.VisibleTypePublic}
test("public team, private org, org member", publicTeamPrivOrg, org35, member, true)
test("public team, private org, outsider", publicTeamPrivOrg, org35, outsider, false)
// Limited team: any org member, but never outsiders.
limitedTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypeLimited}
test("limited team, org member", limitedTeam, org3, member, true)
test("limited team, outsider", limitedTeam, org3, outsider, false)
// Private team is never visible to non-members; members/owners are admitted by the caller.
privateTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePrivate}
test("private team, org member", privateTeam, org3, member, false)
test("private team, outsider", privateTeam, org3, outsider, false)
}
func TestTeam_GetRepositories(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@@ -172,6 +211,52 @@ func TestGetUserOrgTeams(t *testing.T) {
test(3, unittest.NonexistentID)
}
func TestSearchTeamIncludeVisible(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const orgID int64 = 3
// User 5 is an org member but only belongs to team 1 (Owners) — make sure
// they don't see team 2 (default private) but do see a freshly added
// limited team they are not a member of.
visible := &organization.Team{
OrgID: orgID,
LowerName: "visible-team",
Name: "visible-team",
AccessMode: 1, // read
Visibility: structs.VisibleTypeLimited,
}
assert.NoError(t, db.Insert(t.Context(), visible))
teams, _, err := organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{
OrgID: orgID,
UserID: 2,
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true),
})
assert.NoError(t, err)
ids := make(map[int64]bool, len(teams))
for _, team := range teams {
assert.Equal(t, orgID, team.OrgID)
ids[team.ID] = true
}
// user 2 is in team 1 and team 2 in org 3, plus should see the new visible team.
assert.True(t, ids[1], "expected to see team 1 (member)")
assert.True(t, ids[2], "expected to see team 2 (member)")
assert.True(t, ids[visible.ID], "expected to see visible team")
// user 5 is only an org member in team 1, must not see secret team 2 but must see the visible one.
teams, _, err = organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{
OrgID: orgID,
UserID: 5,
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true),
})
assert.NoError(t, err)
ids = make(map[int64]bool, len(teams))
for _, team := range teams {
ids[team.ID] = true
}
assert.False(t, ids[2], "user 5 must not see private team 2")
assert.True(t, ids[visible.ID], "user 5 must see the limited team")
}
func TestHasTeamRepo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

View File

@@ -783,7 +783,8 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor
sess = sess.Where(cond).OrderBy(opts.OrderBy.String())
repos := make(RepositoryList, 0, opts.PageSize)
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos)
db.SetSessionPagination(sess, &opts)
return repos, count, sess.Find(&repos)
}
func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) {

View File

@@ -4,6 +4,7 @@
package htmlutil
import (
"errors"
"fmt"
"html/template"
"io"
@@ -88,6 +89,52 @@ func EscapeString(s string) template.HTML {
return template.HTML(template.HTMLEscapeString(s))
}
type HTMLWriter interface {
OriginWriter() io.Writer
WriteString(s string) HTMLWriter
WriteHTML(s template.HTML) HTMLWriter
WriteFormat(fmt template.HTML, args ...any) HTMLWriter
Err() error
}
type htmlWriter struct {
w io.Writer
errs []error
}
func (h *htmlWriter) OriginWriter() io.Writer {
return h.w
}
func (h *htmlWriter) WriteString(s string) HTMLWriter {
if _, err := io.WriteString(h.w, template.HTMLEscapeString(s)); err != nil {
h.errs = append(h.errs, err)
}
return h
}
func (h *htmlWriter) WriteHTML(s template.HTML) HTMLWriter {
if _, err := io.WriteString(h.w, string(s)); err != nil {
h.errs = append(h.errs, err)
}
return h
}
func (h *htmlWriter) WriteFormat(fmt template.HTML, args ...any) HTMLWriter {
if _, err := HTMLPrintf(h.w, fmt, args...); err != nil {
h.errs = append(h.errs, err)
}
return h
}
func (h *htmlWriter) Err() error {
return errors.Join(h.errs...)
}
func NewHTMLWriter(w io.Writer) HTMLWriter {
return &htmlWriter{w: w}
}
type HTMLBuilder struct {
sb strings.Builder
}

View File

@@ -5,6 +5,7 @@ package htmlutil
import (
"html/template"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -29,3 +30,11 @@ func TestHTMLBuilder(t *testing.T) {
assert.Equal(t, "&lt;<hr><span>&gt;&gt;</span>", b.String())
assert.Equal(t, template.HTML("&lt;<hr><span>&gt;&gt;</span>"), b.HTMLString())
}
func TestHTMLWriter(t *testing.T) {
sb := new(strings.Builder)
w := NewHTMLWriter(sb)
w.WriteString("<").WriteHTML("<hr>").WriteFormat("<span>%s%s</span>", ">", EscapeString(">"))
assert.Equal(t, "&lt;<hr><span>&gt;&gt;</span>", sb.String())
assert.NoError(t, w.Err())
}

View File

@@ -0,0 +1,74 @@
{
"metadata": {},
"nbformat": 4,
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["print('very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong')"],
"outputs": [
{
"output_type": "execute_result",
"text": ["very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong ...\n"]
},
{
"output_type": "stream",
"name": "stdout",
"text": ["stdout 1 ...\n", "stdout 2 ...\n"]
},
{
"output_type": "stream",
"name": "stderr",
"text": ["stderr ...\n"]
},
{
"data": {
"text/plain": ["data text 1\n", "data text 2\n"]
}
},
{
"data": {
"text/plain": true
}
},
{
"data": {
"image/svg+xml": ["<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2000\" height=\"20\"><rect width=\"2000\" height=\"20\" x=\"0\" y=\"0\" rx=\"5\" ry=\"5\" fill=\"red\"/></svg>"]
}
},
{
"data": {
"text/html": "<a href='/'>HTML Link</a>"
}
},
{
"data": {
"text/latex": "$$a=1$$"
}
},
{
"data": {
"text/plain": "plain text"
}
},
{
"output_type": "error",
"ename": "Error Name",
"traceback": ["stacktrace 1", "stacktrace 2"]
}
]
},
{
"cell_type": "unknown-cell"
},
{
"cell_type": "markdown",
"source": [
"# h1\n", "## h2\n", "### h3\n", "\n", "paragraph 1\n", "\n",
"very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong\n",
"- list item 1\n", "- list item 2\n", "\n", "```python\n", "print('code block')\n", "```\n",
"<table><tr><th>th1</th><th>th2</th></tr><tr><td>td1</td><td>td2</td></tr></table>\n"
]
}
]
}

View File

@@ -0,0 +1,397 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jupyter
import (
"encoding/base64"
"fmt"
"html/template"
"io"
"strings"
"sync"
"gitea.dev/modules/highlight"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
func init() {
markup.RegisterRenderer(renderer{})
}
// Renderer implements markup.Renderer for Jupyter notebooks
type renderer struct{}
var (
_ markup.Renderer = (*renderer)(nil)
_ markup.PostProcessRenderer = (*renderer)(nil)
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
)
type mimeHandler struct {
Mime string
Fn func(w htmlutil.HTMLWriter, data string) error
}
func renderCellCodeOutputTextPlain(w htmlutil.HTMLWriter, text string) error {
w.WriteFormat(`<div class="cell-output-text"><pre>%s</pre></div>`, text)
return w.Err()
}
func renderCellCodeOutputUnsupported(w htmlutil.HTMLWriter, message string) error {
w.WriteFormat(`<div class="cell-output-unsupported">%s</div>`, message)
return w.Err()
}
var dataMimeHandlers = sync.OnceValue(func() []mimeHandler {
renderImage := func(w htmlutil.HTMLWriter, subtype, payload string) error {
w.WriteFormat(`<div class="cell-output-image"><img src="data:image/%s;base64,%s"></div>`, subtype, payload)
return w.Err()
}
renderUnsupportedOutput := func(message string) func(htmlutil.HTMLWriter, string) error {
return func(w htmlutil.HTMLWriter, _ string) error {
return renderCellCodeOutputUnsupported(w, message)
}
}
return []mimeHandler{
// Images (PNG, JPEG, SVG)
{"image/png", func(w htmlutil.HTMLWriter, d string) error {
return renderImage(w, "png", d)
}},
{"image/jpeg", func(w htmlutil.HTMLWriter, d string) error {
return renderImage(w, "jpeg", d)
}},
{"image/svg+xml", func(w htmlutil.HTMLWriter, d string) error {
return renderImage(w, "svg+xml", base64.StdEncoding.EncodeToString(util.UnsafeStringToBytes(d)))
}},
// Rich & Math Layouts
{"text/html", func(w htmlutil.HTMLWriter, d string) error {
// To future developers: don't allow custom CSS classes or attributes,
// because ".link-action" or "data-fetch-xxx" can send POST requests and lead to XSS.
// If you'd really like to support more, do remember to correctly sanitize the values.
w.WriteFormat(`<div class="cell-output-html">%s</div>`, markup.Sanitize(d))
return w.Err()
}},
{"text/latex", func(w htmlutil.HTMLWriter, d string) error {
w.WriteFormat(`<div class="cell-output-latex"><pre><code class="language-math display">%s</code></pre></div>`, trimMathDelimiters(d))
return w.Err()
}},
{"text/plain", renderCellCodeOutputTextPlain},
// Security Placeholders
{"application/javascript", renderUnsupportedOutput("[JavaScript output - execution disabled for security]")},
{"application/vnd.plotly.v1+json", renderUnsupportedOutput("[Plotly output - interactive plots not supported]")},
{"application/vnd.jupyter.widget-view+json", renderUnsupportedOutput("[Jupyter widget - interactive widgets not supported]")},
}
})
func (renderer) Name() string {
return "jupyter-render"
}
func (renderer) NeedPostProcess() bool { return true }
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
return markup.ExternalRendererOptions{
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
// This render must guarantee that the output is safe and no XSS
SanitizerDisabled: true,
}
}
func (renderer) FileNamePatterns() []string {
return []string{"*.ipynb"}
}
func (renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return nil
}
// Notebook structures
type Notebook struct {
Cells []Cell `json:"cells"`
Metadata map[string]any `json:"metadata"`
Nbformat int `json:"nbformat"`
}
type Cell struct {
CellType string `json:"cell_type"`
Source any `json:"source"` // string or []string
Outputs []Output `json:"outputs,omitempty"`
ExecutionCount any `json:"execution_count,omitempty"` // int or null
Metadata map[string]any `json:"metadata,omitempty"`
}
type Output struct {
OutputType string `json:"output_type"`
Data map[string]any `json:"data,omitempty"`
Text any `json:"text,omitempty"` // string or []string
Name string `json:"name,omitempty"`
Traceback any `json:"traceback,omitempty"` // []string
Ename string `json:"ename,omitempty"`
Evalue string `json:"evalue,omitempty"`
}
// Render renders Jupyter notebook to HTML
func (renderer) Render(ctx *markup.RenderContext, input io.Reader, outputWriter io.Writer) error {
htmlWriter := htmlutil.NewHTMLWriter(outputWriter)
// the size is (should be) checked and/or limited by the caller to avoid OOM
var notebook Notebook
if err := json.NewDecoder(input).Decode(&notebook); err != nil {
htmlWriter.WriteFormat(`<div class="ui error message">Failed to parse notebook JSON: %v</div>`, err)
return htmlWriter.Err()
}
// Check nbformat version
if notebook.Nbformat < 4 {
msg := htmlutil.HTMLFormat("This notebook uses an older format (nbformat %d). Only nbformat 4+ is supported for rendering. Please upgrade the notebook in Jupyter or view the raw JSON.", notebook.Nbformat)
htmlWriter.WriteFormat(`<div class="file-not-rendered-prompt">%s</div>`, msg)
return htmlWriter.Err()
}
// Detect language
language := "python" // default
if metadata, ok := notebook.Metadata["language_info"].(map[string]any); ok {
if name, ok := metadata["name"].(string); ok {
language = name
}
} else if kernelSpec, ok := notebook.Metadata["kernelspec"].(map[string]any); ok {
if lang, ok := kernelSpec["language"].(string); ok {
language = lang
}
}
// Start rendering
htmlWriter.WriteHTML(`<div class="jupyter-notebook">`)
// limiting the cell rendering to 100 cells
cells := notebook.Cells
truncated := false
const maxRenderedCells = 100
if len(cells) > maxRenderedCells {
cells = cells[:maxRenderedCells] // Slice down to exactly 100 elements instantly at the pointer layer
truncated = true
}
for _, cell := range cells {
if err := renderCell(ctx, htmlWriter, cell, language); err != nil {
log.Warn("Failed to render cell: %v", err) // TODO: RENDER-LOG-HANDLING: see other comments
continue
}
}
if truncated {
renderCellPrompt(htmlWriter, "Warning:", "Output truncated. This notebook contains too many cells to display efficiently.")
}
htmlWriter.WriteHTML(`</div>`)
return htmlWriter.Err()
}
func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) error {
source := joinSource(cell.Source)
var executionCount *int64
if cell.ExecutionCount != nil {
if count, err := util.ToInt64(cell.ExecutionCount); err == nil {
executionCount = &count
}
}
output.WriteHTML(`<div class="cell-line">`)
{
if executionCount != nil {
output.WriteFormat(`<div class="cell-left cell-prompt">In [%d]:</div>`, *executionCount)
} else {
output.WriteHTML(`<div class="cell-left cell-prompt">In [ ]:</div>`)
}
// Highlight code
lexer := highlight.DetectChromaLexerByFileName("", language)
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(language))
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
output.WriteHTML("</code></pre></div>")
}
output.WriteHTML(`</div>`)
// Render outputs
if len(cell.Outputs) > 0 {
hasExecutionResult := false
for _, out := range cell.Outputs {
if out.OutputType == "execute_result" {
hasExecutionResult = true
break
}
}
output.WriteHTML(`<div class="cell-line">`)
{
if hasExecutionResult && executionCount != nil {
output.WriteFormat(`<div class="cell-left cell-prompt">Out [%d]:</div>`, *executionCount)
} else {
output.WriteHTML(`<div class="cell-left cell-prompt"></div>`)
}
output.WriteHTML(`<div class="cell-right cell-output">`)
for _, out := range cell.Outputs {
renderCellCodeOutput(output, out)
}
output.WriteHTML(`</div>`)
}
output.WriteHTML(`</div>`)
}
return output.Err()
}
func renderCellPrompt(output htmlutil.HTMLWriter, left, right template.HTML) {
output.WriteFormat(`
<div class="notebook-cell">
<div class="cell-line">
<div class="cell-left cell-prompt">%s</div>
<div class="cell-right cell-prompt">%s</div>
</div>
</div>`, left, right)
}
func renderCell(ctx *markup.RenderContext, output htmlutil.HTMLWriter, cell Cell, language string) error {
switch cell.CellType {
case "markdown":
output.WriteHTML(`
<div class="notebook-cell cell-type-markdown">
<div class="cell-line">
<div class="cell-left cell-prompt"></div>
<div class="cell-right">`)
if err := renderCellMarkdown(ctx, output, joinSource(cell.Source)); err != nil {
return err
}
output.WriteHTML(`
</div>
</div>
</div>`)
case "code":
output.WriteHTML(`<div class="notebook-cell cell-type-code">`)
if err := renderCellCode(output, cell, language); err != nil {
return err
}
output.WriteHTML(`</div>`)
default:
renderCellPrompt(output, "Cell:", htmlutil.HTMLFormat("[Cell type %s - unsupported, skipped]", cell.CellType))
}
return output.Err()
}
func renderCellMarkdown(rctx *markup.RenderContext, output htmlutil.HTMLWriter, source string) error {
markdownCtx := markup.NewRenderContext(rctx)
// make sure the markdown render use the same options and helper to generate correct contents (e.g.: links)
markdownCtx.RenderOptions = rctx.RenderOptions
markdownCtx.RenderHelper = rctx.RenderHelper
output.WriteHTML(`<div class="embedded-markdown">`)
if err := markdown.Render(markdownCtx, strings.NewReader(source), output.OriginWriter()); err != nil {
return err
}
output.WriteHTML(`</div>`)
return output.Err()
}
func renderCellCodeOutput(output htmlutil.HTMLWriter, out Output) {
if out.Data != nil {
// Iterate through our priority list to find the best matching MIME handler available
for _, h := range dataMimeHandlers() {
if rawPayload, exists := out.Data[h.Mime]; exists {
var stringPayload string
// Flatten the polymorphic JSON input (string or []any) into a single clean string
switch v := rawPayload.(type) {
case string:
stringPayload = v
case []any:
stringPayload = joinSource(v)
default:
_ = renderCellCodeOutputUnsupported(output, fmt.Sprintf("[Data output - unsupported data type %T for mime type %s]", rawPayload, h.Mime))
continue
}
if err := h.Fn(output, stringPayload); err != nil {
// TODO: RENDER-LOG-HANDLING: outputting render's error to sever's log is not a proper approach
// The errors can be:
// * unsupported element (cell, data, etc): it should render the message on the UI to tell users that the content is not supported, or ignore them if they are ignore-able
// * logic error: it should report to server logs
// * network error: io.Writer tries to write to the HTTP connection, so the error can also be a network error, such error should be ignored
log.Error("Jupyter rendering engine failed for MIME type %s: %v", h.Mime, err)
}
// Return immediately after rendering the top matching priority format
return
}
}
}
// Stream output
if out.OutputType == "stream" && out.Text != nil {
streamName := util.Iif(out.Name == "stderr", "stderr", "stdout")
output.WriteFormat(`<pre class="cell-output-stream stream-%s">%s</pre>`, streamName, joinSource(out.Text))
return
}
// Error output
if out.OutputType == "error" {
traceback := ""
if tb, ok := out.Traceback.([]any); ok {
lines := make([]string, len(tb))
for i, line := range tb {
lines[i] = fmt.Sprint(line)
}
traceback = strings.Join(lines, "\n")
}
if traceback == "" && out.Ename != "" {
traceback = fmt.Sprintf("%s: %s", out.Ename, out.Evalue)
}
output.WriteFormat(`<pre class="cell-output-error">%s</pre>`, traceback)
return
}
// Generic text output
if out.Text != nil {
_ = renderCellCodeOutputTextPlain(output, joinSource(out.Text))
}
}
func joinSource(source any) string {
switch v := source.(type) {
case nil:
return ""
case string:
return v
case []any:
// the "source slice item" has EOL ("\n"), so just join them together
parts := make([]string, len(v))
for i, part := range v {
parts[i] = fmt.Sprint(part)
}
return strings.Join(parts, "")
default:
return fmt.Sprint(v)
}
}
// trimMathDelimiters strips a single pair of surrounding math delimiters ("$$...$$" or "$...$"),
// so the inner expression is handled by the math post-processor. Unlike strings.Trim, it does not
// eat unrelated "$" characters elsewhere in multi-expression content.
func trimMathDelimiters(s string) string {
s = strings.TrimSpace(s)
if t, ok := strings.CutPrefix(s, "$$"); ok {
return strings.TrimSuffix(t, "$$")
}
if t, ok := strings.CutPrefix(s, "$"); ok {
return strings.TrimSuffix(t, "$")
}
return s
}

View File

@@ -0,0 +1,314 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jupyter
import (
"fmt"
"strings"
"testing"
"gitea.dev/modules/markup"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestRender(t *testing.T) {
r := renderer{}
t.Run("Basic notebook", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["print('hello')"],
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": ["hello\n"]
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := &markup.RenderContext{}
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.Contains(t, result, `<div class="jupyter-notebook">`)
assert.Contains(t, result, `<div class="notebook-cell cell-type-code">`)
assert.Contains(t, result, `In [1]:`)
assert.Contains(t, result, `print`)
assert.Contains(t, result, `hello`)
assert.Contains(t, result, `stream-stdout`)
})
t.Run("Markdown cell with XSS Protection", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "markdown",
"source": [
"# Title\n",
"Some text\n",
"[click me](javascript:alert(1))\n",
"<script>alert('dangerous')</script>"
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
// Assert normal markup still renders correctly
assert.Contains(t, result, `<div class="notebook-cell cell-type-markdown">`)
assert.Contains(t, result, `Title`)
assert.Contains(t, result, `Some text`)
assert.Contains(t, result, `click me`)
// CRITICAL SECURITY ASSERTIONS: Ensure XSS vectors are completely stripped
assert.NotContains(t, result, `javascript:alert`)
assert.NotContains(t, result, `<script>`)
})
t.Run("Cell limit truncation guardrail", func(t *testing.T) {
// Generate an oversized notebook containing 105 cells dynamically
var cellBlocks []string
for range 105 {
cellBlocks = append(cellBlocks, `{"cell_type": "markdown", "source": ["cell text"]}`)
}
input := fmt.Sprintf(`{"cells": [%s], "metadata": {}, "nbformat": 4}`, strings.Join(cellBlocks, ","))
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
// Verify it halts rendering gracefully and shows the truncation warning
assert.Contains(t, result, "Output truncated.")
assert.Contains(t, result, "This notebook contains too many cells to display efficiently.")
// Count occurrences of the rendered cells to ensure it sliced down to exactly 100 elements
assert.Equal(t, 100, strings.Count(result, `class="notebook-cell cell-type-markdown"`))
})
t.Run("Image output", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["import matplotlib.pyplot as plt"],
"outputs": [
{
"output_type": "display_data",
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.Contains(t, result, `<img src="data:image/png;base64,`)
assert.Contains(t, result, `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`)
})
t.Run("HTML output with style tag", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["import pandas as pd"],
"outputs": [
{
"output_type": "execute_result",
"data": {
"text/html": ["<style scoped>.dataframe tbody tr th { vertical-align: top; }</style><table class=\"dataframe\"><tr><td>1</td></tr></table>"]
}
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.NotContains(t, result, `<style scoped>`)
assert.Contains(t, result, `<table><tr><td>1</td></tr></table>`)
assert.Contains(t, result, `<td>1</td>`)
})
t.Run("Error output", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["raise ValueError('test error')"],
"outputs": [
{
"output_type": "error",
"ename": "ValueError",
"evalue": "test error",
"traceback": ["ValueError: test error"]
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.Contains(t, result, `ValueError: test error`)
assert.Contains(t, result, `cell-output-error`)
})
t.Run("Old nbformat version", func(t *testing.T) {
input := `{
"cells": [],
"metadata": {},
"nbformat": 3
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
assert.Regexp(t, `<div class="file-not-rendered-prompt">This notebook uses an older format.*</div>`, output.String())
})
}
func TestJoinSource(t *testing.T) {
tests := []struct {
name string
input any
expected string
}{
{
name: "String input",
input: "hello world",
expected: "hello world",
},
{
name: "Array input",
input: []any{"line1\n", "line2\n", "line3"},
expected: "line1\nline2\nline3",
},
{
name: "Empty array",
input: []any{},
expected: "",
},
{
name: "Single element array",
input: []any{"single"},
expected: "single",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := joinSource(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIntegrationAndSanitization(t *testing.T) {
// A mock malicious Jupyter notebook containing an XSS injection attempt
// inside a text/html output cell (e.g., pretending to be a poisoned Pandas DataFrame).
maliciousNotebook := `{
"nbformat": 4,
"nbformat_minor": 2,
"metadata": {},
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"source": ["a=1"],
"outputs": [
{
"output_type": "execute_result",
"execution_count": 1,
"data": {
"text/html": [
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>"
]
},
"metadata": {}
}
]
}
]
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
ctx.RenderOptions.MarkupType = "jupyter-render"
err := markup.Render(ctx, strings.NewReader(maliciousNotebook), &output)
assert.NoError(t, err)
const expected = `
<div class="jupyter-notebook">
<div class="notebook-cell cell-type-code">
<div class="cell-line">
<div class="cell-left cell-prompt">In [1]:</div>
<div class="cell-right cell-input">
<pre><code class="chroma language-python">
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>
</code></pre>
</div>
</div>
<div class="cell-line">
<div class="cell-left cell-prompt">Out [1]:</div>
<div class="cell-right cell-output">
<div class="cell-output-html">
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div>
</div>
</div>
</div>
</div>
</div>`
assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(output.String()))
}

View File

@@ -10,6 +10,8 @@ import (
"strings"
"gitea.dev/modules/util"
"golang.org/x/mod/semver"
)
const (
@@ -20,6 +22,7 @@ const (
var (
ErrInvalidStructure = util.NewInvalidArgumentErrorf("package has invalid structure")
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
ErrGoModFileTooLarge = util.NewInvalidArgumentErrorf("go.mod file is too large")
)
@@ -54,6 +57,13 @@ func ParsePackage(r io.ReaderAt, size int64) (*Package, error) {
Name: strings.TrimSuffix(nameAndVersion, "@"+parts[1]),
Version: versionParts[0],
}
// the version is taken verbatim from the zip path and later written
// one per line into the @v/list proxy response, so it has to be a
// valid module version (no newlines or other stray characters)
if !semver.IsValid(p.Version) {
return nil, ErrInvalidVersion
}
}
if len(versionParts) > 1 {

View File

@@ -59,6 +59,16 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, "module gitea.com/go-gitea/gitea", p.GoMod)
})
t.Run("InvalidVersion", func(t *testing.T) {
data := createArchive(map[string][]byte{
packageName + "@v1.0.0\nv99.0.0/go.mod": []byte("module " + packageName),
})
p, err := ParsePackage(data, int64(data.Len()))
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidVersion)
})
t.Run("Valid", func(t *testing.T) {
data := createArchive(map[string][]byte{
packageName + "@" + packageVersion + "/subdir/go.mod": []byte("invalid"),

View File

@@ -63,20 +63,24 @@ func (s *Sitemap) Add(u URL) {
// WriteTo writes the sitemap to a response
func (s *Sitemap) WriteTo(w io.Writer) (int64, error) {
if l := len(s.URLs); l > urlsLimit {
return 0, fmt.Errorf("The sitemap contains %d URLs, but only %d are allowed", l, urlsLimit)
return 0, fmt.Errorf("sitemap contains %d URLs, but only %d are allowed", l, urlsLimit)
}
if l := len(s.Sitemaps); l > urlsLimit {
return 0, fmt.Errorf("The sitemap contains %d sub-sitemaps, but only %d are allowed", l, urlsLimit)
return 0, fmt.Errorf("sitemap contains %d sub-sitemaps, but only %d are allowed", l, urlsLimit)
}
buf := bytes.NewBufferString(xml.Header)
if err := xml.NewEncoder(buf).Encode(s); err != nil {
encoder := xml.NewEncoder(buf)
defer encoder.Close()
if err := encoder.Encode(s); err != nil {
return 0, err
}
_ = encoder.Flush()
if err := buf.WriteByte('\n'); err != nil {
return 0, err
}
// FIXME: such limit is not right, the content has been written, it would have already caused OOM
if buf.Len() > sitemapFileLimit {
return 0, fmt.Errorf("The sitemap has %d bytes, but only %d are allowed", buf.Len(), sitemapFileLimit)
return 0, fmt.Errorf("sitemap has %d bytes, but only %d are allowed", buf.Len(), sitemapFileLimit)
}
return buf.WriteTo(w)
}

View File

@@ -61,14 +61,14 @@ func TestNewSitemap(t *testing.T) {
{
name: "too many urls",
urls: make([]URL, 50001),
wantErr: "The sitemap contains 50001 URLs, but only 50000 are allowed",
wantErr: "sitemap contains 50001 URLs, but only 50000 are allowed",
},
{
name: "too big file",
urls: []URL{
{URL: strings.Repeat("b", 50*1024*1024+1)},
},
wantErr: "The sitemap has 52428932 bytes, but only 52428800 are allowed",
wantErr: "sitemap has 52428932 bytes, but only 52428800 are allowed",
},
}
for _, tt := range tests {
@@ -137,14 +137,14 @@ func TestNewSitemapIndex(t *testing.T) {
{
name: "too many sitemaps",
urls: make([]URL, 50001),
wantErr: "The sitemap contains 50001 sub-sitemaps, but only 50000 are allowed",
wantErr: "sitemap contains 50001 sub-sitemaps, but only 50000 are allowed",
},
{
name: "too big file",
urls: []URL{
{URL: strings.Repeat("b", 50*1024*1024+1)},
},
wantErr: "The sitemap has 52428952 bytes, but only 52428800 are allowed",
wantErr: "sitemap has 52428952 bytes, but only 52428800 are allowed",
},
}
for _, tt := range tests {

View File

@@ -4,6 +4,20 @@
package structs
// TeamVisibility controls who can list a team within its organization.
// - "public": visible to any signed-in user (still bounded by org visibility)
// - "limited": visible to any member of the parent organization
// - "private": visible only to team members and org owners
//
// swagger:enum TeamVisibility
type TeamVisibility string
const (
TeamVisibilityPublic TeamVisibility = "public"
TeamVisibilityLimited TeamVisibility = "limited"
TeamVisibilityPrivate TeamVisibility = "private"
)
// Team represents a team in an organization
type Team struct {
// The unique identifier of the team
@@ -24,6 +38,11 @@ type Team struct {
UnitsMap map[string]string `json:"units_map"`
// Whether the team can create repositories in the organization
CanCreateOrgRepo bool `json:"can_create_org_repo"`
// Team visibility within the organization. "private" teams are only
// listable by members and org owners; "limited" teams are listable by
// any organization member; "public" teams are listable by any signed-in
// user.
Visibility TeamVisibility `json:"visibility"`
}
// CreateTeamOption options for creating a team
@@ -42,6 +61,8 @@ type CreateTeamOption struct {
UnitsMap map[string]string `json:"units_map"`
// Whether the team can create repositories in the organization
CanCreateOrgRepo bool `json:"can_create_org_repo"`
// Team visibility within the organization. Defaults to "private".
Visibility TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
}
// EditTeamOption options for editing a team
@@ -60,4 +81,7 @@ type EditTeamOption struct {
UnitsMap map[string]string `json:"units_map"`
// Whether the team can create repositories in the organization
CanCreateOrgRepo *bool `json:"can_create_org_repo"`
// Team visibility within the organization. When omitted, visibility is
// left unchanged.
Visibility *TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
}

31
modules/structs/token.go Normal file
View File

@@ -0,0 +1,31 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
import "time"
// CurrentAccessToken represents the metadata of the currently authenticated token.
// swagger:model CurrentAccessToken
type CurrentAccessToken struct {
// The unique identifier of the access token
ID int64 `json:"id"`
// The name of the access token
Name string `json:"name"`
// The scopes granted to this access token
Scopes []string `json:"scopes"`
// The timestamp when the token was created
CreatedAt time.Time `json:"created_at"`
// The timestamp when the token was last used
LastUsedAt time.Time `json:"last_used_at"`
// The owner of the access token
User *UserMeta `json:"user"`
}
// UserMeta represents minimal user information for the token owner.
type UserMeta struct {
// The unique identifier of the user
ID int64 `json:"id"`
// The username of the user
Login string `json:"login"`
}

View File

@@ -12,12 +12,16 @@ import (
"net/http"
"net/http/httptest"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
"golang.org/x/net/html"
)
// RedirectURL returns the redirect URL of a http response.
@@ -182,3 +186,48 @@ func ExternalServiceHTTP(t TestingT, envVarName, def string) string {
}
return val
}
var normalizeHTMLSpacesRegexp = sync.OnceValue(func() (ret struct {
afterRt, beforeLt *regexp.Regexp
},
) {
ret.afterRt = regexp.MustCompile(`>\s*`)
ret.beforeLt = regexp.MustCompile(`\s*<`)
return ret
})
func NormalizeHTMLSpaces(s string) string {
vars := normalizeHTMLSpacesRegexp()
s = vars.afterRt.ReplaceAllString(s, ">\n")
s = vars.beforeLt.ReplaceAllString(s, "\n<")
return strings.TrimSpace(s)
}
func NormalizeHTMLAttributes(t TestingT, s string) string {
nodes, err := html.Parse(strings.NewReader(s))
if err != nil {
t.Errorf("failed to parse expected HTML: %v", err)
return ""
}
var normalize func(n *html.Node)
normalize = func(n *html.Node) {
slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
return cmp
}
if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
return cmp
}
return strings.Compare(a.Val, b.Val)
})
for c := n.FirstChild; c != nil; c = c.NextSibling {
normalize(c)
}
}
var sb strings.Builder
if err = html.Render(&sb, nodes); err != nil {
t.Errorf("failed to render HTML: %v", err)
}
return sb.String()
}

View File

@@ -2865,6 +2865,14 @@
"org.teams.all_repositories_read_permission_desc": "This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories.",
"org.teams.all_repositories_write_permission_desc": "This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories.",
"org.teams.all_repositories_admin_permission_desc": "This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories.",
"org.teams.visibility": "Visibility",
"org.teams.visibility_private": "Private",
"org.teams.visibility_private_helper": "Visible only to team members and organization owners.",
"org.teams.visibility_limited": "Limited",
"org.teams.visibility_limited_helper": "Visible to all members of this organization.",
"org.teams.visibility_public": "Public",
"org.teams.visibility_public_helper": "Visible to any signed-in user.",
"org.teams.owners_visibility_fixed": "The Owners team visibility cannot be changed.",
"org.teams.invite.title": "You have been invited to join team <strong>%s</strong> in organization <strong>%s</strong>.",
"org.teams.invite.by": "Invited by %s",
"org.teams.invite.description": "Please click the button below to join the team.",

View File

@@ -1,6 +1,6 @@
{
"type": "module",
"packageManager": "pnpm@11.5.1",
"packageManager": "pnpm@11.5.3",
"engines": {
"node": ">= 22.18.0",
"pnpm": ">= 11.0.0"
@@ -17,10 +17,10 @@
"@codemirror/language": "6.12.3",
"@codemirror/language-data": "6.5.2",
"@codemirror/legacy-modes": "6.5.3",
"@codemirror/lint": "6.9.6",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@codemirror/view": "6.43.1",
"@deltablot/dropzone": "7.4.3",
"@github/markdown-toolbar-element": "2.2.3",
"@github/paste-markdown": "1.5.3",
@@ -28,7 +28,7 @@
"@lezer/highlight": "1.2.3",
"@mcaptcha/vanilla-glue": "0.1.0-rc2",
"@mermaid-js/layout-elk": "0.2.1",
"@primer/octicons": "19.28.0",
"@primer/octicons": "19.28.1",
"@replit/codemirror-indentation-markers": "6.5.3",
"@replit/codemirror-lang-nix": "6.0.1",
"@replit/codemirror-lang-svelte": "6.0.0",
@@ -80,18 +80,18 @@
"@stylistic/eslint-plugin": "5.10.0",
"@stylistic/stylelint-plugin": "5.2.0",
"@types/codemirror": "5.60.17",
"@types/jquery": "4.0.0",
"@types/jquery": "4.0.1",
"@types/js-yaml": "4.0.9",
"@types/katex": "0.16.8",
"@types/node": "25.9.1",
"@types/node": "25.9.2",
"@types/pdfobject": "2.2.5",
"@types/sortablejs": "1.15.9",
"@types/swagger-ui-dist": "3.30.6",
"@types/throttle-debounce": "5.0.2",
"@types/toastify-js": "1.12.4",
"@typescript-eslint/parser": "8.60.1",
"@typescript-eslint/parser": "8.61.0",
"@vitejs/plugin-vue": "6.0.7",
"@vitest/eslint-plugin": "1.6.19",
"@vitest/eslint-plugin": "1.6.20",
"eslint": "10.4.1",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-array-func": "5.1.1",
@@ -106,22 +106,22 @@
"eslint-plugin-vue-scoped-css": "3.1.1",
"eslint-plugin-wc": "3.1.0",
"globals": "17.6.0",
"happy-dom": "20.10.1",
"happy-dom": "20.10.2",
"jiti": "2.7.0",
"markdownlint-cli": "0.48.0",
"material-icon-theme": "5.35.0",
"postcss-html": "1.8.1",
"spectral-cli-bundle": "1.0.8",
"stylelint": "17.12.0",
"stylelint": "17.13.0",
"stylelint-config-recommended": "18.0.0",
"stylelint-declaration-block-no-ignored-properties": "3.0.0",
"stylelint-declaration-strict-value": "1.11.1",
"stylelint-value-no-unknown-custom-properties": "6.1.1",
"svgo": "4.0.1",
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"updates": "17.17.3",
"typescript-eslint": "8.61.0",
"updates": "17.18.0",
"vitest": "4.1.8",
"vue-tsc": "3.3.3"
"vue-tsc": "3.3.4"
}
}

504
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-stack-add" width="16" height="16" aria-hidden="true"><path d="M7.122.392a1.75 1.75 0 0 1 1.756 0l5.003 2.902c.83.481.83 1.68 0 2.162L8.878 8.358a1.75 1.75 0 0 1-1.756 0L2.119 5.456a1.25 1.25 0 0 1 0-2.162ZM8.125 1.69a.25.25 0 0 0-.25 0L3.244 4.375 7.875 7.06a.25.25 0 0 0 .25 0l4.63-2.685ZM1.602 7.789a.75.75 0 0 1 1.024-.272l5.249 3.044a.749.749 0 1 1-.753 1.296L1.874 8.813a.75.75 0 0 1-.272-1.024m0 3.5a.75.75 0 0 1 1.024-.272l5.249 3.044a.749.749 0 1 1-.753 1.296l-5.248-3.044a.75.75 0 0 1-.272-1.024M11.75 15.25v-2h-2a.75.75 0 0 1 0-1.5h2v-2a.75.75 0 0 1 1.5 0v2h2a.75.75 0 0 1 0 1.5h-2v2a.75.75 0 0 1-1.5 0"/></svg>

After

Width:  |  Height:  |  Size: 700 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" class="svg octicon-stack-check" width="16" height="16" aria-hidden="true"><path fill="#010409" d="M7.122.392a1.75 1.75 0 0 1 1.756 0l5.003 2.902c.83.481.83 1.68 0 2.162L8.878 8.358a1.75 1.75 0 0 1-1.756 0L2.12 5.456a1.25 1.25 0 0 1 0-2.162zM8.125 1.69a.25.25 0 0 0-.25 0l-4.63 2.685 4.63 2.685a.25.25 0 0 0 .25 0l4.63-2.685zM1.602 7.79a.75.75 0 0 1 1.024-.273l5.249 3.044a.75.75 0 0 1-.753 1.297L1.874 8.814a.75.75 0 0 1-.272-1.025M1.602 11.29a.75.75 0 0 1 1.024-.273l5.249 3.044a.75.75 0 0 1-.753 1.297l-5.248-3.044a.75.75 0 0 1-.272-1.025M14.701 10.49a.75.75 0 1 1 1.098 1.02l-3.719 4a.75.75 0 0 1-1.075.024l-1.781-1.752a.751.751 0 0 1 1.052-1.069l1.23 1.21z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-stack-check" width="16" height="16" aria-hidden="true"><path d="M7.122.392a1.75 1.75 0 0 1 1.756 0l5.003 2.902c.83.481.83 1.68 0 2.162L8.878 8.358a1.75 1.75 0 0 1-1.756 0L2.12 5.456a1.25 1.25 0 0 1 0-2.162zM8.125 1.69a.25.25 0 0 0-.25 0l-4.63 2.685 4.63 2.685a.25.25 0 0 0 .25 0l4.63-2.685zM1.602 7.79a.75.75 0 0 1 1.024-.273l5.249 3.044a.75.75 0 0 1-.753 1.297L1.874 8.814a.75.75 0 0 1-.272-1.025M1.602 11.29a.75.75 0 0 1 1.024-.273l5.249 3.044a.75.75 0 0 1-.753 1.297l-5.248-3.044a.75.75 0 0 1-.272-1.025M14.701 10.49a.75.75 0 1 1 1.098 1.02l-3.719 4a.75.75 0 0 1-1.075.024l-1.781-1.752a.751.751 0 0 1 1.052-1.069l1.23 1.21z"/></svg>

Before

Width:  |  Height:  |  Size: 741 B

After

Width:  |  Height:  |  Size: 714 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" class="svg octicon-stack-remove" width="16" height="16" aria-hidden="true"><path fill="#010409" d="M14.72 10.22a.75.75 0 0 1 1.06 1.06L14.06 13l1.72 1.72a.75.75 0 1 1-1.06 1.06L13 14.06l-1.72 1.72a.75.75 0 0 1-1.06-1.06L11.938 13l-1.72-1.72a.75.75 0 0 1 1.06-1.06L13 11.94zM1.601 11.29a.75.75 0 0 1 1.025-.273l5.249 3.044a.75.75 0 0 1-.753 1.297l-5.248-3.045A.75.75 0 0 1 1.6 11.29M1.601 7.79a.75.75 0 0 1 1.025-.273l5.249 3.044a.75.75 0 0 1-.753 1.297L1.874 8.814A.75.75 0 0 1 1.6 7.789"/><path fill="#010409" fill-rule="evenodd" d="M7.122.393a1.75 1.75 0 0 1 1.755 0l5.003 2.901c.83.482.83 1.68 0 2.162L8.877 8.358a1.75 1.75 0 0 1-1.755 0L2.119 5.456a1.25 1.25 0 0 1 0-2.162zM8.125 1.69a.25.25 0 0 0-.25 0L3.244 4.375l4.63 2.686a.25.25 0 0 0 .25 0l4.63-2.686z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-stack-remove" width="16" height="16" aria-hidden="true"><path d="M14.72 10.22a.75.75 0 0 1 1.06 1.06L14.06 13l1.72 1.72a.75.75 0 1 1-1.06 1.06L13 14.06l-1.72 1.72a.75.75 0 0 1-1.06-1.06L11.938 13l-1.72-1.72a.75.75 0 0 1 1.06-1.06L13 11.94zM1.601 11.29a.75.75 0 0 1 1.025-.273l5.249 3.044a.75.75 0 0 1-.753 1.297l-5.248-3.045A.75.75 0 0 1 1.6 11.29M1.601 7.79a.75.75 0 0 1 1.025-.273l5.249 3.044a.75.75 0 0 1-.753 1.297L1.874 8.814A.75.75 0 0 1 1.6 7.789"/><path fill-rule="evenodd" d="M7.122.393a1.75 1.75 0 0 1 1.755 0l5.003 2.901c.83.482.83 1.68 0 2.162L8.877 8.358a1.75 1.75 0 0 1-1.755 0L2.119 5.456a1.25 1.25 0 0 1 0-2.162zM8.125 1.69a.25.25 0 0 0-.25 0L3.244 4.375l4.63 2.686a.25.25 0 0 0 .25 0l4.63-2.686z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 820 B

View File

@@ -5,7 +5,7 @@ requires-python = ">=3.10"
[dependency-groups]
dev = [
"djlint==1.36.4",
"djlint==1.39.0",
"yamllint==1.38.0",
"zizmor==1.25.2",
]

View File

@@ -88,6 +88,7 @@ import (
"gitea.dev/routers/api/v1/packages"
"gitea.dev/routers/api/v1/repo"
"gitea.dev/routers/api/v1/settings"
"gitea.dev/routers/api/v1/token"
"gitea.dev/routers/api/v1/user"
"gitea.dev/routers/common"
"gitea.dev/services/actions"
@@ -504,41 +505,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
}
}
// reqTeamMembership user should be an team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
if ctx.IsUserSiteAdmin() {
return 0, true, true
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("teamAccess: unprepared context")
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
return 0, false, false
}
orgID = ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
} else if isOwner {
return orgID, true, true
}
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
}
return orgID, isTeamMember, true
}
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
}
// reqTeamReadAccess allows callers who can list the team to read its metadata.
// Non-members are admitted by the team's visibility tier and parent org visibility.
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
func reqTeamReadAccess() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if ctx.IsUserSiteAdmin() {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
if ctx.Org.Organization == nil {
setting.PanicInDevOrTesting("reqTeamReadAccess: organization not loaded")
ctx.APIErrorInternal(errors.New("reqTeamReadAccess: organization not loaded"))
return
}
orgID := ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
visible, err := ctx.Org.Team.CanNonMemberReadMeta(ctx, ctx.Org.Organization.AsUser(), ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if isOwner {
return
}
if !visible {
// Not admitted by visibility: 403 for org members, 404 otherwise.
denyNonTeamMember(ctx, orgID)
}
}
}
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
ctx.APIErrorInternal(err)
return
} else if !isTeamMember {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
// reqTeamMembership user should be a team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return
}
denyNonTeamMember(ctx, orgID)
}
}
@@ -648,6 +687,17 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
}
return
}
if ctx.Org.Organization == nil {
ctx.Org.Organization, err = organization.GetOrgByID(ctx, ctx.Org.Team.OrgID)
if err != nil {
if organization.IsErrOrgNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
}
}
}
}
@@ -976,6 +1026,11 @@ func Routes() *web.Router {
})
})
// Token introspection and deletion endpoint
m.Combo("/token").
Get(reqToken(), token.GetCurrentToken).
Delete(reqToken(), token.DeleteCurrentToken)
// Notifications (requires 'notifications' scope)
// The notifications API is not available for public-only tokens because a user's notifications mix
// public and private repository events in the same mailbox.
@@ -1697,25 +1752,31 @@ func Routes() *web.Router {
}, reqToken(), reqOrgOwnership())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
m.Group("/teams/{teamid}", func() {
m.Combo("").Get(reqToken(), org.GetTeam).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
m.Combo("").Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam)
m.Group("", func() {
m.Get("", org.GetTeam)
m.Group("/members", func() {
m.Get("", reqOrgMembership(), org.GetTeamMembers)
m.Combo("/{username}").Get(reqOrgMembership(), org.GetTeamMember)
})
m.Group("/repos", func() {
m.Get("", org.GetTeamRepos)
m.Combo("/{org}/{reponame}").Get(org.GetTeamRepo)
})
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, reqTeamReadAccess())
m.Group("/members", func() {
m.Get("", reqToken(), org.GetTeamMembers)
m.Combo("/{username}").
Get(reqToken(), org.GetTeamMember).
Put(reqToken(), reqOrgOwnership(), org.AddTeamMember).
Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember)
})
m.Group("/repos", func() {
m.Get("", reqToken(), org.GetTeamRepos)
m.Combo("/{org}/{reponame}").
Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository).
Get(reqToken(), org.GetTeamRepo)
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository)
})
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), checkTokenPublicOnly())
m.Group("/admin", func() {
m.Group("/cron", func() {

View File

@@ -55,10 +55,15 @@ func ListTeams(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
listOptions := utils.GetListOptions(ctx)
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
opts := &organization.SearchTeamOptions{
ListOptions: listOptions,
OrgID: ctx.Org.Organization.ID,
})
}
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
teams, count, err := organization.SearchTeam(ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -218,6 +223,7 @@ func CreateTeam(ctx *context.APIContext) {
IncludesAllRepositories: form.IncludesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo,
AccessMode: teamPermission,
Visibility: organization.NormalizeTeamVisibility(string(form.Visibility)),
}
if team.AccessMode < perm.AccessModeAdmin {
@@ -295,6 +301,10 @@ func EditTeam(ctx *context.APIContext) {
team.Description = *form.Description
}
if form.Visibility != nil && !team.IsOwnerTeam() {
team.Visibility = organization.NormalizeTeamVisibility(string(*form.Visibility))
}
isAuthChanged := false
isIncludeAllChanged := false
if !team.IsOwnerTeam() && len(form.Permission) != 0 {
@@ -387,15 +397,6 @@ func GetTeamMembers(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if !isMember && !ctx.Doer.IsAdmin {
ctx.APIErrorNotFound()
return
}
listOptions := utils.GetListOptions(ctx)
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
ListOptions: listOptions,
@@ -574,14 +575,20 @@ func GetTeamRepos(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
repos := make([]*api.Repository, len(teamRepos))
for i, repo := range teamRepos {
repos := make([]*api.Repository, 0, len(teamRepos))
for _, repo := range teamRepos {
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
repos[i] = convert.ToRepo(ctx, repo, permission)
// A team's repo list is reachable by non-team-members through the team's
// visibility tier, so never expose repos (incl. their names) the doer
// cannot access.
if !permission.HasAnyUnitAccessOrPublicAccess() {
continue
}
repos = append(repos, convert.ToRepo(ctx, repo, permission))
}
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
ctx.SetTotalCountHeader(int64(team.NumRepos))
@@ -633,6 +640,12 @@ func GetTeamRepo(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
// The team may be reachable by a non-team-member via its visibility tier;
// don't confirm the existence of a repo the doer cannot access.
if !permission.HasAnyUnitAccessOrPublicAccess() {
ctx.APIErrorNotFound()
return
}
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
}
@@ -806,9 +819,9 @@ func SearchTeam(ctx *context.APIContext) {
ListOptions: listOptions,
}
// Only admin is allowed to search for all teams
if !ctx.Doer.IsAdmin {
opts.UserID = ctx.Doer.ID
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
teams, maxResults, err := organization.SearchTeam(ctx, opts)

View File

@@ -11,6 +11,7 @@ import (
api "gitea.dev/modules/structs"
"gitea.dev/services/context"
"gitea.dev/services/convert"
git_service "gitea.dev/services/git"
)
// CompareDiff compare two branches or commits
@@ -18,8 +19,12 @@ func CompareDiff(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/compare/{basehead} repository repoCompareDiff
// ---
// summary: Get commit comparison information
// description: |
// By default returns JSON commit comparison information. The raw diff or patch can be
// requested with the `output` query parameter set to `diff` or `patch` respectively.
// produces:
// - application/json
// - text/plain
// parameters:
// - name: owner
// in: path
@@ -33,9 +38,16 @@ func CompareDiff(ctx *context.APIContext) {
// required: true
// - name: basehead
// in: path
// description: compare two branches or commits
// description: compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.
// type: string
// required: true
// - name: output
// in: query
// description: return the raw comparison as `diff` or `patch` instead of JSON
// type: string
// enum:
// - diff
// - patch
// responses:
// "200":
// "$ref": "#/responses/Compare"
@@ -57,6 +69,16 @@ func CompareDiff(ctx *context.APIContext) {
}
defer closer()
// ?output=diff|patch returns the raw output, otherwise the JSON comparison is returned.
switch ctx.FormString("output") {
case "diff":
downloadCompareDiffOrPatch(ctx, compareInfo, false)
return
case "patch":
downloadCompareDiffOrPatch(ctx, compareInfo, true)
return
}
verification := ctx.FormString("verification") == "" || ctx.FormBool("verification")
files := ctx.FormString("files") == "" || ctx.FormBool("files")
@@ -88,3 +110,20 @@ func CompareDiff(ctx *context.APIContext) {
Commits: apiCommits,
})
}
// downloadCompareDiffOrPatch writes a comparison's raw diff or patch to the response.
func downloadCompareDiffOrPatch(ctx *context.APIContext, compareInfo *git_service.CompareInfo, patch bool) {
ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
compareArg := compareInfo.BaseCommitID + compareInfo.CompareSeparator + compareInfo.HeadCommitID
var err error
if patch {
err = compareInfo.HeadGitRepo.GetPatch(compareArg, ctx.Resp)
} else {
err = compareInfo.HeadGitRepo.GetDiff(compareArg, ctx.Resp)
}
if err != nil {
ctx.APIErrorInternal(err)
return
}
}

View File

@@ -91,6 +91,7 @@ func ListTrackedTimes(ctx *context.APIContext) {
user, err := user_model.GetUserByName(ctx, qUser)
if user_model.IsErrUserNotExist(err) {
ctx.APIError(http.StatusNotFound, err.Error())
return
} else if err != nil {
ctx.APIErrorInternal(err)
return
@@ -499,6 +500,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) {
user, err := user_model.GetUserByName(ctx, qUser)
if user_model.IsErrUserNotExist(err) {
ctx.APIError(http.StatusNotFound, err.Error())
return
} else if err != nil {
ctx.APIErrorInternal(err)
return

View File

@@ -20,3 +20,10 @@ type swaggerResponseAccessToken struct {
// in:body
Body api.AccessToken `json:"body"`
}
// CurrentAccessToken represents the currently authenticated access token.
// swagger:response CurrentAccessToken
type swaggerResponseCurrentAccessToken struct {
// in:body
Body api.CurrentAccessToken `json:"body"`
}

View File

@@ -0,0 +1,88 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package token
import (
"errors"
"net/http"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
// GetCurrentToken returns metadata about the currently authenticated token.
func GetCurrentToken(ctx *context.APIContext) {
// swagger:operation GET /token miscellaneous getCurrentToken
// ---
// summary: Get the currently authenticated token
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/CurrentAccessToken"
accessToken, err := getToken(ctx)
if err != nil {
ctx.APIErrorAuto(err)
return
}
// Get user info
user, err := user_model.GetUserByID(ctx, accessToken.UID)
if err != nil {
ctx.APIErrorAuto(err)
return
}
ctx.JSON(http.StatusOK, &api.CurrentAccessToken{
ID: accessToken.ID,
Name: accessToken.Name,
Scopes: accessToken.Scope.StringSlice(),
CreatedAt: accessToken.CreatedUnix.AsTime(),
LastUsedAt: accessToken.UpdatedUnix.AsTime(),
User: &api.UserMeta{
ID: user.ID,
Login: user.Name,
},
})
}
// DeleteCurrentToken deletes the currently authenticated token.
func DeleteCurrentToken(ctx *context.APIContext) {
// swagger:operation DELETE /token miscellaneous deleteCurrentToken
// ---
// summary: Delete the currently authenticated token
// produces:
// - application/json
// responses:
// "204":
// description: token deleted
accessToken, err := getToken(ctx)
if err != nil {
ctx.APIErrorAuto(err)
return
}
// Delete the token
err = auth_model.DeleteAccessTokenByID(ctx, accessToken.ID, accessToken.UID)
if err != nil && !errors.Is(err, util.ErrNotExist) {
ctx.APIErrorAuto(err)
return
}
ctx.Status(http.StatusNoContent)
}
// getToken retrieves an access token from the API context's Authorization header and validates it against the database.
// Returns nil if the token is invalid and handles the response
func getToken(ctx *context.APIContext) (*auth_model.AccessToken, error) {
authHeader := ctx.Req.Header.Get("Authorization")
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BearerToken == nil {
return nil, util.NewNotExistErrorf("invalid access token")
}
return auth_model.GetAccessTokenBySHA(ctx, parsed.BearerToken.Token)
}

View File

@@ -191,17 +191,9 @@ func DeleteAccessToken(ctx *context.APIContext) {
return
}
}
if tokenID == 0 {
ctx.APIErrorInternal(nil)
return
}
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
ctx.APIErrorAuto(err)
return
}

View File

@@ -23,10 +23,7 @@ func Organizations(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.organizations")
ctx.Data["PageIsAdminOrganizations"] = true
if ctx.FormString("sort") == "" {
ctx.SetFormString("sort", UserSearchDefaultAdminSort)
}
sortOrder := ctx.FormString("sort", UserSearchDefaultAdminSort)
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization},
@@ -35,5 +32,6 @@ func Organizations(ctx *context.Context) {
PageSize: setting.UI.Admin.OrgPagingNum,
},
Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},
OrderBy: db.SearchOrderBy(sortOrder),
}, tplOrgs)
}

View File

@@ -55,11 +55,7 @@ func Users(ctx *context.Context) {
statusFilterMap[filterKey] = paramVal
}
sortType := ctx.FormString("sort")
if sortType == "" {
sortType = UserSearchDefaultAdminSort
ctx.SetFormString("sort", sortType)
}
sortType := ctx.FormString("sort", UserSearchDefaultAdminSort)
ctx.PageData["adminUserListSearchForm"] = map[string]any{
"StatusFilterMap": statusFilterMap,
"SortType": sortType,
@@ -78,6 +74,7 @@ func Users(ctx *context.Context) {
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones
OrderBy: db.SearchOrderBy(sortType),
}, tplUsers)
}

View File

@@ -38,17 +38,14 @@ func Organizations(ctx *context.Context) {
"alphabetically",
"reversealphabetically",
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
ctx.SetFormString("sort", sortOrder)
}
sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
sortOrder := ctx.FormString("sort", sortOrderDefault)
RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization},
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
Visible: visibleTypes,
OrderBy: db.SearchOrderBy(sortOrder),
SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers)

View File

@@ -55,11 +55,7 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t
)
// we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = setting.UI.ExploreDefaultSort
}
sortOrder := util.IfZero(string(opts.OrderBy), ctx.FormString("sort", setting.UI.ExploreDefaultSort))
ctx.Data["SortType"] = sortOrder
switch sortOrder {
@@ -145,18 +141,15 @@ func Users(ctx *context.Context) {
"alphabetically",
"reversealphabetically",
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
ctx.SetFormString("sort", sortOrder)
}
sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
sortOrder := ctx.FormString("sort", sortOrderDefault)
RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual},
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
IsActive: optional.Some(true),
Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},
OrderBy: db.SearchOrderBy(sortOrder),
SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers)

View File

@@ -101,7 +101,26 @@ func home(ctx *context.Context, viewRepositories bool) {
const orgOverviewTeamsLimit = 5
ctx.Data["OrgOverviewMembers"] = members
ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)]
// The overview widget shows only teams the viewer belongs to. ctx.Org.Teams
// may include visible-but-not-joined teams (via IncludeVisibilities for
// signed-in non-members), so re-query the viewer's own membership; owners
// keep the full list they are entitled to manage.
overviewTeams := ctx.Org.Teams
if !ctx.Org.IsOwner {
overviewTeams = nil
if ctx.Org.IsMember {
overviewTeams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
OrgID: org.ID,
UserID: ctx.Doer.ID,
ListOptions: db.ListOptions{Page: 1, PageSize: orgOverviewTeamsLimit},
})
if err != nil {
ctx.ServerError("SearchTeam", err)
return
}
}
}
ctx.Data["OrgOverviewTeams"] = overviewTeams[:min(len(overviewTeams), orgOverviewTeamsLimit)]
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0

View File

@@ -21,6 +21,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
@@ -80,6 +81,8 @@ func Teams(ctx *context.Context) {
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
Keyword: keyword,
IncludeDesc: true,
IncludeVisibilities: util.Iif(shouldSeeAllOrgTeams, nil,
org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)),
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
}
return org_model.SearchTeam(ctx, opts)
@@ -377,6 +380,7 @@ func NewTeamPost(ctx *context.Context) {
AccessMode: teamPermission,
IncludesAllRepositories: includesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo,
Visibility: org_model.NormalizeTeamVisibility(form.Visibility),
}
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
@@ -477,13 +481,22 @@ func SearchTeam(ctx *context.Context) {
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}
shouldSeeAll, err := context.UserShouldSeeAllOrgTeams(ctx)
if err != nil {
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
return
}
opts := &org_model.SearchTeamOptions{
// UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in
Keyword: ctx.FormTrim("q"),
OrgID: ctx.Org.Organization.ID,
IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"),
ListOptions: listOptions,
}
if !shouldSeeAll {
opts.UserID = ctx.Doer.ID
opts.IncludeVisibilities = org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)
}
teams, maxResults, err := org_model.SearchTeam(ctx, opts)
if err != nil {
@@ -556,8 +569,11 @@ func EditTeamPost(ctx *context.Context) {
t.IncludesAllRepositories = includesAllRepositories
}
t.CanCreateOrgRepo = form.CanCreateOrgRepo
t.Visibility = org_model.NormalizeTeamVisibility(form.Visibility)
} else {
t.CanCreateOrgRepo = true
// The owner team must remain listable to all org members.
t.Visibility = structs.VisibleTypeLimited
}
t.Description = form.Description

View File

@@ -201,12 +201,12 @@ func newComparePageInfo() *comparePageInfoType {
}
// parseCompareInfo parse compare info between two commit for preparing comparing references
func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context) error {
func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context, compareParam string) error {
baseRepo := ctx.Repo.Repository
fileOnly := ctx.FormBool("file-only")
// 1 Parse compare router param
compareReq := common.ParseCompareRouterParam(ctx.PathParam("*"))
compareReq := common.ParseCompareRouterParam(compareParam)
// remove the check when we support compare with carets
if compareReq.BaseOriRefSuffix != "" {
@@ -545,7 +545,7 @@ func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repositor
// CompareDiff show different from one commit to another commit
func CompareDiff(ctx *context.Context) {
comparePageInfo := newComparePageInfo()
err := comparePageInfo.parseCompareInfo(ctx)
err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*"))
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.NotFound(nil)
return
@@ -605,6 +605,45 @@ func CompareDiff(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplCompare)
}
// DownloadCompareDiff render a comparison's raw unified diff
func DownloadCompareDiff(ctx *context.Context) {
downloadCompareDiffOrPatch(ctx, false)
}
// DownloadComparePatch render a comparison as a git format-patch
func DownloadComparePatch(ctx *context.Context) {
downloadCompareDiffOrPatch(ctx, true)
}
func downloadCompareDiffOrPatch(ctx *context.Context, patch bool) {
// The route captures `basehead` separately so the `.diff`/`.patch` suffix is
// stripped from the catch-all `*` param parseCompareInfo would otherwise read.
cpi := newComparePageInfo()
if err := cpi.parseCompareInfo(ctx, ctx.PathParam("basehead")); err != nil {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.NotFound(nil)
} else {
ctx.ServerError("ParseCompareInfo", err)
}
return
}
ci := cpi.compareInfo
ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
compareArg := ci.BaseCommitID + ci.CompareSeparator + ci.HeadCommitID
var err error
if patch {
err = ci.HeadGitRepo.GetPatch(compareArg, ctx.Resp)
} else {
err = ci.HeadGitRepo.GetDiff(compareArg, ctx.Resp)
}
if err != nil {
ctx.ServerError("DownloadCompareDiffOrPatch", err)
return
}
}
func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Context) {
ci := cpi.compareInfo
if cpi.allowCreatePull {

View File

@@ -1310,7 +1310,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreateIssueForm)
repo := ctx.Repo.Repository
comparePageInfo := newComparePageInfo()
err := comparePageInfo.parseCompareInfo(ctx)
err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*"))
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()
return

View File

@@ -15,22 +15,16 @@ import (
func TestDeleteOpenIDReturnsNotFoundForOtherUsersAddress(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security")
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1")
contexttest.LoadUser(t, ctx, 2)
ctx.SetFormString("id", "1")
DeleteOpenID(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
func TestToggleOpenIDVisibilityReturnsNotFoundForOtherUsersAddress(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security")
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1")
contexttest.LoadUser(t, ctx, 2)
ctx.SetFormString("id", "1")
ToggleOpenIDVisibility(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}

View File

@@ -1269,9 +1269,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeViewNodes)
})
m.Get("/compare", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff)
m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists).
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
m.PathGroup("/compare/*", func(g *web.RouterPathGroup) {
g.MatchPath("GET", "/<basehead:*>.diff", repo.MustBeNotEmpty, repo.DownloadCompareDiff)
g.MatchPath("GET", "/<basehead:*>.patch", repo.MustBeNotEmpty, repo.DownloadComparePatch)
g.MatchPath("GET", "/<*:*>", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff)
g.MatchPath("POST", "/<*:*>", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
})
m.Get("/pulls/new/*", repo.PullsNewRedirect)
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}": repo code: find, compare, list

View File

@@ -5,6 +5,7 @@
package auth
import (
"errors"
"net/http"
actions_model "gitea.dev/models/actions"
@@ -104,8 +105,8 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = token.Scope
return u, nil
} else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
log.Error("GetAccessTokenBySha: %v", err)
} else if !errors.Is(err, util.ErrNotExist) {
log.Error("GetAccessTokenBySHA: %v", err)
}
// check task token

View File

@@ -128,7 +128,7 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
}
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
if err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
if errors.Is(err, util.ErrNotExist) {
// check task token
if task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA); err == nil {
log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)

View File

@@ -78,8 +78,3 @@ func (b *Base) FormOptionalBool(key string) optional.Option[bool] {
v = v || strings.EqualFold(s, "on")
return optional.Some(v)
}
func (b *Base) SetFormString(key, value string) {
_ = b.Req.FormValue(key) // force parse form
b.Req.Form.Set(key, value)
}

View File

@@ -179,20 +179,28 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
return
}
if ctx.Org.IsMember {
if shouldSeeAllTeams {
ctx.Org.Teams, err = org.LoadTeams(ctx)
if err != nil {
ctx.ServerError("LoadTeams", err)
return
}
} else {
ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
}
switch {
case shouldSeeAllTeams:
ctx.Org.Teams, err = org.LoadTeams(ctx)
if err != nil {
ctx.ServerError("LoadTeams", err)
return
}
case ctx.IsSigned:
// Signed-in non-members still see teams whose visibility tier
// includes them (public for any signed-in user, plus limited
// for org members), and any team they directly belong to.
ctx.Org.Teams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
OrgID: org.ID,
UserID: ctx.Doer.ID,
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, true),
})
if err != nil {
ctx.ServerError("SearchTeam", err)
return
}
}
if ctx.Org.IsMember {
ctx.Data["NumTeams"] = len(ctx.Org.Teams)
}
@@ -203,7 +211,6 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
if strings.EqualFold(team.LowerName, teamName) {
teamExists = true
ctx.Org.Team = team
ctx.Org.IsTeamMember = true
ctx.Data["Team"] = ctx.Org.Team
break
}
@@ -214,13 +221,24 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
return
}
// Membership in a visible team is not implied by its presence in
// ctx.Org.Teams; admins/org owners keep the privileged flag set
// earlier in this function.
if !ctx.Org.IsOwner {
ctx.Org.IsTeamMember, err = organization.IsTeamMember(ctx, org.ID, ctx.Org.Team.ID, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsTeamMember", err)
return
}
}
ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember
if opts.RequireTeamMember && !ctx.Org.IsTeamMember {
ctx.NotFound(err)
return
}
ctx.Org.IsTeamAdmin = ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess()
isTeamOwnerOrAdmin := ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess()
ctx.Org.IsTeamAdmin = ctx.Org.IsOwner || (ctx.Org.IsTeamMember && isTeamOwnerOrAdmin)
ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin
if opts.RequireTeamAdmin && !ctx.Org.IsTeamAdmin {
ctx.NotFound(err)

View File

@@ -836,6 +836,7 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([]
Permission: api.AccessLevelName(t.AccessMode.ToString()),
Units: t.GetUnitNames(),
UnitsMap: t.GetUnitsMap(),
Visibility: api.TeamVisibility(t.Visibility.String()),
}
if loadOrgs {

View File

@@ -70,6 +70,7 @@ type CreateTeamForm struct {
Permission string
RepoAccess string
CanCreateOrgRepo bool
Visibility string `binding:"OmitEmpty;In(public,limited,private)"`
}
// Validate validates the fields

View File

@@ -110,7 +110,7 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA
sess := db.GetEngine(ctx)
if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description",
"can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil {
"can_create_org_repo", "authorize", "includes_all_repositories", "visibility").Update(t); err != nil {
return fmt.Errorf("update: %w", err)
}

View File

@@ -12,10 +12,10 @@
{{template "base/alert" .}}
<div class="required field {{if .Err_TeamName}}error{{end}}">
<label for="team_name">{{ctx.Locale.Tr "org.team_name"}}</label>
{{if eq .Team.LowerName "owners"}}
{{if .Team.IsOwnerTeam}}
<input type="hidden" name="team_name" value="{{.Team.Name}}">
{{end}}
<input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if eq .Team.LowerName "owners"}}disabled{{end}} autofocus>
<input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if .Team.IsOwnerTeam}}disabled{{end}} autofocus>
<span class="help">{{ctx.Locale.Tr "org.team_name_helper"}}</span>
</div>
<div class="field {{if .Err_Description}}error{{end}}">
@@ -23,7 +23,47 @@
<input id="description" name="description" value="{{.Team.Description}}" maxlength="255">
<span class="help">{{ctx.Locale.Tr "org.team_desc_helper"}}</span>
</div>
{{if not (eq .Team.LowerName "owners")}}
{{if .Team.IsOwnerTeam}}
<div class="field">
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
<div class="tw-mb-1">
{{if .Team.IsPrivate}}
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
{{else if .Team.IsLimited}}
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
{{else if .Team.IsPublic}}
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
{{end}}
</div>
<span class="help">{{ctx.Locale.Tr "org.teams.owners_visibility_fixed"}}</span>
</div>
{{end}}
{{if not .Team.IsOwnerTeam}}
<div class="grouped field">
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
<br>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="visibility" value="private" {{if or .PageIsOrgTeamsNew .Team.IsPrivate}}checked{{end}}>
<label>{{ctx.Locale.Tr "org.teams.visibility_private"}}</label>
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}</span>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="visibility" value="limited" {{if .Team.IsLimited}}checked{{end}}>
<label>{{ctx.Locale.Tr "org.teams.visibility_limited"}}</label>
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}</span>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="visibility" value="public" {{if .Team.IsPublic}}checked{{end}}>
<label>{{ctx.Locale.Tr "org.teams.visibility_public"}}</label>
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}</span>
</div>
</div>
</div>
<div class="grouped field">
<label>{{ctx.Locale.Tr "org.team_access_desc"}}</label>
<br>
@@ -135,7 +175,7 @@
<button class="ui primary button">{{ctx.Locale.Tr "org.create_team"}}</button>
{{else}}
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button>
{{if not (eq .Team.LowerName "owners")}}
{{if not .Team.IsOwnerTeam}}
<button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
{{end}}
{{end}}

View File

@@ -1,6 +1,15 @@
<div class="ui six wide column">
<h4 class="ui top attached header flex-left-right">
<strong>{{.Team.Name}}</strong>
<div class="flex-text-inline">
<strong>{{.Team.Name}}</strong>
{{if .Team.IsPrivate}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
{{else if .Team.IsLimited}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
{{else if .Team.IsPublic}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
{{end}}
</div>
<div class="flex-text-block">
{{if .Team.IsMember ctx $.SignedUser.ID}}
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
@@ -26,7 +35,7 @@
<div class="ui attached segment">
{{/* TODO: old indent is kept to make diff changes minimal, can be reformatted in the future */}}
{{if eq .Team.LowerName "owners"}}
{{if .Team.IsOwnerTeam}}
<p>{{ctx.Locale.Tr "org.teams.owners_permission_desc"}}</p>
<p>{{ctx.Locale.Tr "org.teams.owners_permission_suggestion"}}</p>
{{else}}

View File

@@ -21,7 +21,16 @@
{{range $team := $.OrgListTeams}}
<div class="column team-item-box">
<div class="ui top attached header muted-links flex-left-right team-item-header">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
<div class="flex-text-inline">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
{{if .IsPrivate}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
{{else if .IsLimited}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
{{else if .IsPublic}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
{{end}}
</div>
<div class="flex-text-block tw-flex-wrap">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a>
·

View File

@@ -78,7 +78,10 @@
</a>
{{range .StatusInfoList}}
<a class="item{{if eq .Status $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}&branch={{$.CurBranch}}">
{{.DisplayedStatus}}
<span class="flex-text-inline tw-gap-2">
{{template "repo/icons/action_status" (dict "Status" .StatusName)}}
{{.DisplayedStatus}}
</span>
</a>
{{end}}
</div>

View File

@@ -28,7 +28,7 @@
</div>
</div>
</h4>
<div class="ui bottom attached table unstackable segment">
<div class="ui bottom attached segment file-view-container">
<div class="file-view code-view unicode-escaped">
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}

View File

@@ -11,7 +11,7 @@
<a class="ui primary tiny button" href="{{.LFSFilesLink}}/find?oid={{.LFSFile.Oid}}&size={{.LFSFile.Size}}">{{ctx.Locale.Tr "repo.settings.lfs_findcommits"}}</a>
</div>
</h4>
<div class="ui bottom attached table unstackable segment">
<div class="ui bottom attached segment file-view-container">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
<div class="file-view {{if .IsPlainText}}plain-text{{else if .IsTextFile}}code-view{{end}}">
{{if .IsFileTooLarge}}

View File

@@ -91,7 +91,7 @@
</div>
</h4>
<div class="ui bottom attached table unstackable segment">
<div class="ui bottom attached segment file-view-container">
{{if not .RenderAsMarkup}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{end}}

View File

@@ -8108,8 +8108,10 @@
},
"/repos/{owner}/{repo}/compare/{basehead}": {
"get": {
"description": "By default returns JSON commit comparison information. The raw diff or patch can be\nrequested with the `output` query parameter set to `diff` or `patch` respectively.\n",
"produces": [
"application/json"
"application/json",
"text/plain"
],
"tags": [
"repository"
@@ -8133,10 +8135,20 @@
},
{
"type": "string",
"description": "compare two branches or commits",
"description": "compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.",
"name": "basehead",
"in": "path",
"required": true
},
{
"enum": [
"diff",
"patch"
],
"type": "string",
"description": "return the raw comparison as `diff` or `patch` instead of JSON",
"name": "output",
"in": "query"
}
],
"responses": {
@@ -19202,6 +19214,38 @@
}
}
},
"/token": {
"get": {
"produces": [
"application/json"
],
"tags": [
"miscellaneous"
],
"summary": "Get the currently authenticated token",
"operationId": "getCurrentToken",
"responses": {
"200": {
"$ref": "#/responses/CurrentAccessToken"
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"miscellaneous"
],
"summary": "Delete the currently authenticated token",
"operationId": "deleteCurrentToken",
"responses": {
"204": {
"description": "token deleted"
}
}
}
},
"/topics/search": {
"get": {
"produces": [
@@ -24960,6 +25004,17 @@
},
"x-go-name": "UnitsMap",
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}"
},
"visibility": {
"description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"type": "string",
"enum": [
"public",
"limited",
"private"
],
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"x-go-name": "Visibility"
}
},
"x-go-package": "gitea.dev/modules/structs"
@@ -25116,6 +25171,47 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"CurrentAccessToken": {
"type": "object",
"title": "CurrentAccessToken represents the metadata of the currently authenticated token.",
"properties": {
"created_at": {
"description": "The timestamp when the token was created",
"type": "string",
"format": "date-time",
"x-go-name": "CreatedAt"
},
"id": {
"description": "The unique identifier of the access token",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"last_used_at": {
"description": "The timestamp when the token was last used",
"type": "string",
"format": "date-time",
"x-go-name": "LastUsedAt"
},
"name": {
"description": "The name of the access token",
"type": "string",
"x-go-name": "Name"
},
"scopes": {
"description": "The scopes granted to this access token",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Scopes"
},
"user": {
"$ref": "#/definitions/UserMeta"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"DeleteEmailOption": {
"description": "DeleteEmailOption options when deleting email addresses",
"type": "object",
@@ -26117,6 +26213,17 @@
"repo.releases": "none",
"repo.wiki": "admin"
}
},
"visibility": {
"description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"type": "string",
"enum": [
"public",
"limited",
"private"
],
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"x-go-name": "Visibility"
}
},
"x-go-package": "gitea.dev/modules/structs"
@@ -30023,6 +30130,17 @@
"repo.releases": "none",
"repo.wiki": "admin"
}
},
"visibility": {
"description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"type": "string",
"enum": [
"public",
"limited",
"private"
],
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"x-go-name": "Visibility"
}
},
"x-go-package": "gitea.dev/modules/structs"
@@ -30585,6 +30703,24 @@
},
"x-go-package": "gitea.dev/models/activities"
},
"UserMeta": {
"type": "object",
"title": "UserMeta represents minimal user information for the token owner.",
"properties": {
"id": {
"description": "The unique identifier of the user",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"login": {
"description": "The username of the user",
"type": "string",
"x-go-name": "Login"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"UserSettings": {
"description": "UserSettings represents user settings",
"type": "object",
@@ -31089,6 +31225,12 @@
}
}
},
"CurrentAccessToken": {
"description": "CurrentAccessToken represents the currently authenticated access token.",
"schema": {
"$ref": "#/definitions/CurrentAccessToken"
}
},
"DeployKey": {
"description": "DeployKey",
"schema": {

View File

@@ -399,6 +399,16 @@
},
"description": "CronList"
},
"CurrentAccessToken": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentAccessToken"
}
}
},
"description": "CurrentAccessToken represents the currently authenticated access token."
},
"DeployKey": {
"content": {
"application/json": {
@@ -4795,6 +4805,14 @@
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}",
"type": "object",
"x-go-name": "UnitsMap"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/TeamVisibility"
}
],
"description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
}
},
"required": [
@@ -4952,6 +4970,47 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"CurrentAccessToken": {
"properties": {
"created_at": {
"description": "The timestamp when the token was created",
"format": "date-time",
"type": "string",
"x-go-name": "CreatedAt"
},
"id": {
"description": "The unique identifier of the access token",
"format": "int64",
"type": "integer",
"x-go-name": "ID"
},
"last_used_at": {
"description": "The timestamp when the token was last used",
"format": "date-time",
"type": "string",
"x-go-name": "LastUsedAt"
},
"name": {
"description": "The name of the access token",
"type": "string",
"x-go-name": "Name"
},
"scopes": {
"description": "The scopes granted to this access token",
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "Scopes"
},
"user": {
"$ref": "#/components/schemas/UserMeta"
}
},
"title": "CurrentAccessToken represents the metadata of the currently authenticated token.",
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"DeleteEmailOption": {
"description": "DeleteEmailOption options when deleting email addresses",
"properties": {
@@ -5940,6 +5999,14 @@
},
"type": "object",
"x-go-name": "UnitsMap"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/TeamVisibility"
}
],
"description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
}
},
"required": [
@@ -9887,11 +9954,27 @@
},
"type": "object",
"x-go-name": "UnitsMap"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/TeamVisibility"
}
],
"description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"TeamVisibility": {
"enum": [
"public",
"limited",
"private"
],
"type": "string"
},
"TimeStamp": {
"description": "TimeStamp defines a timestamp",
"format": "int64",
@@ -10454,6 +10537,24 @@
"type": "object",
"x-go-package": "gitea.dev/models/activities"
},
"UserMeta": {
"properties": {
"id": {
"description": "The unique identifier of the user",
"format": "int64",
"type": "integer",
"x-go-name": "ID"
},
"login": {
"description": "The username of the user",
"type": "string",
"x-go-name": "Login"
}
},
"title": "UserMeta represents minimal user information for the token owner.",
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"UserSettings": {
"description": "UserSettings represents user settings",
"properties": {
@@ -19367,6 +19468,7 @@
},
"/repos/{owner}/{repo}/compare/{basehead}": {
"get": {
"description": "By default returns JSON commit comparison information. The raw diff or patch can be\nrequested with the `output` query parameter set to `diff` or `patch` respectively.\n",
"operationId": "repoCompareDiff",
"parameters": [
{
@@ -19388,13 +19490,25 @@
}
},
{
"description": "compare two branches or commits",
"description": "compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.",
"in": "path",
"name": "basehead",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "return the raw comparison as `diff` or `patch` instead of JSON",
"in": "query",
"name": "output",
"schema": {
"enum": [
"diff",
"patch"
],
"type": "string"
}
}
],
"responses": {
@@ -31385,6 +31499,32 @@
]
}
},
"/token": {
"delete": {
"operationId": "deleteCurrentToken",
"responses": {
"204": {
"description": "token deleted"
}
},
"summary": "Delete the currently authenticated token",
"tags": [
"miscellaneous"
]
},
"get": {
"operationId": "getCurrentToken",
"responses": {
"200": {
"$ref": "#/components/responses/CurrentAccessToken"
}
},
"summary": "Get the currently authenticated token",
"tags": [
"miscellaneous"
]
}
},
"/topics/search": {
"get": {
"operationId": "topicSearch",

View File

@@ -13,6 +13,7 @@ import (
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
@@ -61,6 +62,44 @@ func TestAPIGetTrackedTimes(t *testing.T) {
assert.Equal(t, int64(6), filterAPITimes[1].ID)
}
// TestAPIGetTrackedTimesNonExistentUserFilter ensures filtering by a user that
// does not exist returns a clean 404 instead of panicking (nil pointer dereference).
func TestAPIGetTrackedTimesNonExistentUserFilter(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
assert.NoError(t, issue2.LoadRepo(t.Context()))
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadRepository)
for _, tc := range []struct {
name string
url string
}{
{"repository level", fmt.Sprintf("/api/v1/repos/%s/%s/times?user=nonexistentuser", user2.Name, issue2.Repo.Name)},
{"issue level", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/times?user=nonexistentuser", user2.Name, issue2.Repo.Name, issue2.Index)},
} {
t.Run(tc.name, func(t *testing.T) {
req := NewRequest(t, "GET", tc.url).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusNotFound)
assert.True(t, json.Valid(resp.Body.Bytes()), "response body must be a single JSON value, got: %s", resp.Body.Bytes())
var apiError api.APIError
DecodeJSON(t, resp, &apiError)
assert.Contains(t, apiError.Message, "user does not exist")
})
}
t.Run("existing user", func(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/times?user=%s", user2.Name, issue2.Repo.Name, user2.Name).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, api.TrackedTimeList{})
})
}
func TestAPIDeleteTrackedTime(t *testing.T) {
defer tests.PrepareTestEnv(t)()

View File

@@ -6,6 +6,7 @@ package integration
import (
"net/http"
"net/url"
"strings"
"testing"
auth_model "gitea.dev/models/auth"
@@ -62,3 +63,113 @@ func TestAPICompareBranches(t *testing.T) {
})
})
}
func TestAPIDownloadCompareDiffOrPatch(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
t.Run("BranchToBranchDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
body := resp.Body.String()
assert.Contains(t, body, "diff --git ")
})
t.Run("BranchToBranchPatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=patch").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
body := resp.Body.String()
assert.True(t, strings.HasPrefix(body, "From "), "patch output should start with a format-patch header, got: %q", body[:min(40, len(body))])
})
t.Run("CommitToCommitDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/808038d2f71b0ab02099...c8e31bc7688741a5287f?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("BranchToCommitDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// 8babce96... is the head of remove-files-b; pairing it with add-csv guarantees a non-empty diff.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...8babce967f21b9dfa6987f943b91093dac58a4f0?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("TwoDotSeparator", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv..remove-files-b?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("SlashedBranchName", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// user2/repo1's `feature/1` branch contains a slash; the route must match it
// without URL-encoding. master and feature/1 happen to share a SHA in the fixture,
// so we only assert the route resolves (200 OK) rather than checking diff content.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/compare/master...feature/1?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
})
t.Run("UnknownOutputReturnsJSON", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Only "diff"/"patch" switch to raw output; any other value falls through to JSON.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=foo").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
apiResp := DecodeJSON(t, resp, &api.Compare{})
assert.Equal(t, 2, apiResp.TotalCommits)
})
t.Run("SingleRefImplicitBase", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// No `...`/`..` separator: parseCompareInfo defaults the base to the
// repo's PR target branch (master for repo20) and compares it against
// the given head.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("PrivateRepoAnonymous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// repo16 is private; an unauthenticated request must not leak its existence.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/compare/master...good-sign?output=diff")
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("CrossRepoFork", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
user13 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13})
repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
user13Sess := loginUser(t, "user13")
user13Token := getTokenForLoggedInUser(t, user13Sess, auth_model.AccessTokenScopeWriteRepository)
_, err := createFileInBranch(user13, repo11, createFileInBranchOptions{OldBranch: "master", NewBranch: "cross-repo-diff"}, map[string]string{"hello.txt": "hi\n"})
require.NoError(t, err)
req := NewRequest(t, "GET", "/api/v1/repos/user12/repo10/compare/master...user13:cross-repo-diff?output=diff").AddTokenAuth(user13Token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
})
}

View File

@@ -10,12 +10,14 @@ import (
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
"gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
api "gitea.dev/modules/structs"
"gitea.dev/services/convert"
"gitea.dev/tests"
@@ -303,3 +305,61 @@ func TestAPIGetTeamRepo(t *testing.T) {
AddTokenAuth(token5)
MakeRequest(t, req, http.StatusNotFound)
}
func TestAPITeamVisibilityAccess(t *testing.T) {
defer tests.PrepareTestEnv(t)()
insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team {
t.Helper()
team := &organization.Team{
OrgID: orgID,
LowerName: name,
Name: name,
AccessMode: perm.AccessModeRead,
Visibility: visibility,
}
assert.NoError(t, db.Insert(t.Context(), team))
return team
}
limitedTeam := insertTestTeam(t, 3, "limited-team", structs.VisibleTypeLimited)
// Org member who can read a limited team must not mutate its repos without membership.
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
token := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteOrganization)
req := NewRequestf(t, "PUT", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
publicTeam := insertTestTeam(t, 23, "public-team", structs.VisibleTypePublic)
// Public team in a private org must not be readable by outsiders.
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token = getUserToken(t, outsider.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d", publicTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// Member lookup must require org membership even for public teams.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/members/%s", publicTeam.ID, outsider.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// A limited team's repo list must not leak repos the viewer cannot access.
// repo3 is private; user28 is an org3 member (team12, no repo access) who can
// read the limited team but has no access to repo3.
assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: 3, TeamID: limitedTeam.ID, RepoID: 3}))
user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
token28 := getUserToken(t, user28.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", limitedTeam.ID).AddTokenAuth(token28)
resp := MakeRequest(t, req, http.StatusOK)
var repos []*api.Repository
DecodeJSON(t, resp, &repos)
for _, r := range repos {
assert.NotEqual(t, int64(3), r.ID, "must not leak inaccessible private repo3")
}
// The single-repo lookup must not confirm an inaccessible repo's existence.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).AddTokenAuth(token28)
MakeRequest(t, req, http.StatusNotFound)
}

View File

@@ -0,0 +1,98 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
// TestAPIGetCurrentToken tests getting metadata of the currently authenticated token
func TestAPIGetCurrentToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Success with all scopes", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-all", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
resp := MakeRequest(t, req, http.StatusOK)
currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{})
assert.Equal(t, accessToken.ID, currentToken.ID)
assert.Equal(t, accessToken.Name, currentToken.Name)
assert.Equal(t, user.ID, currentToken.User.ID)
assert.Equal(t, user.Name, currentToken.User.Login)
})
t.Run("Success with limited scopes", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-limited", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository})
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
resp := MakeRequest(t, req, http.StatusOK)
currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{})
assert.Equal(t, accessToken.ID, currentToken.ID)
assert.Equal(t, accessToken.Name, currentToken.Name)
assert.Equal(t, user.ID, currentToken.User.ID)
assert.Equal(t, user.Name, currentToken.User.Login)
})
t.Run("Bad token", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth("this does not exist")
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "GET", "/api/v1/token")
MakeRequest(t, req, http.StatusUnauthorized)
})
}
// TestAPITokenSelfService tests delete operations on token
func TestAPITokenSelfService(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Success then verify deleted", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-delete-current-token", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
// Delete the token via the endpoint
req := NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusNoContent)
// Verify the token is deleted
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID})
// Verify the token can no longer be used for GET
req = NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusUnauthorized)
// Verify the token can no longer be used for DELETE
req = NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusUnauthorized)
})
t.Run("Bad token", func(t *testing.T) {
req := NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth("this does not exist")
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "DELETE", "/api/v1/token")
MakeRequest(t, req, http.StatusUnauthorized)
})
}

View File

@@ -167,6 +167,53 @@ Hello from 2
assert.Equal(t, 0, htmlDoc.doc.Find(".pullrequest-form").Length())
}
func TestCompareDownloadDiffOrPatch(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
t.Run("BranchToBranchDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.diff")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("BranchToBranchPatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.patch")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.True(t, strings.HasPrefix(resp.Body.String(), "From "), "patch output should start with a format-patch header")
})
t.Run("SingleRefImplicitBase", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv.diff")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("InvalidBaseRef", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/does-not-exist...remove-files-b.diff")
session.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("PrivateRepoAnonymous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// repo16 is private; an unauthenticated request must not leak its existence.
req := NewRequest(t, "GET", "/user2/repo16/compare/master...good-sign.diff")
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestCompareCodeExpand(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})

View File

@@ -5,13 +5,12 @@ package integration
import (
"io"
"slices"
"strings"
"testing"
"gitea.dev/modules/test"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"golang.org/x/net/html"
)
// HTMLDoc struct
@@ -53,36 +52,10 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string
func assertHTMLEq(t testing.TB, expected, actual string) {
t.Helper()
if expected == actual {
if expected == actual { // fast path
return
}
exp, err := html.Parse(strings.NewReader(expected))
if !assert.NoError(t, err) {
return
}
act, err := html.Parse(strings.NewReader(actual))
if !assert.NoError(t, err) {
return
}
var normalize func(n *html.Node)
normalize = func(n *html.Node) {
slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
return cmp
}
if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
return cmp
}
return strings.Compare(a.Val, b.Val)
})
for c := n.FirstChild; c != nil; c = c.NextSibling {
normalize(c)
}
}
normalize(exp)
normalize(act)
var expNormalized, actNormalized strings.Builder
assert.NoError(t, html.Render(&expNormalized, exp))
assert.NoError(t, html.Render(&actNormalized, act))
assert.Equal(t, expNormalized.String(), actNormalized.String())
exp := test.NormalizeHTMLAttributes(t, expected)
act := test.NormalizeHTMLAttributes(t, actual)
assert.Equal(t, exp, act)
}

78
uv.lock generated
View File

@@ -39,7 +39,7 @@ wheels = [
[[package]]
name = "djlint"
version = "1.36.4"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -54,25 +54,63 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/a7/5ba1032d01ceba641b92b1c76c758a0a06959585c6d36608371526809a08/djlint-1.39.0.tar.gz", hash = "sha256:75e7e1a0c592121751c48360104b3c402f4d6406ea862ba76f8867b3eb51ba97", size = 55174, upload-time = "2026-06-05T19:22:37.296Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/71/6a3ce2b49a62e635b85dce30ccf3eb3a18fe79275d45535325a55a63d3a3/djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c", size = 354135, upload-time = "2024-12-24T13:05:49.732Z" },
{ url = "https://files.pythonhosted.org/packages/72/47/308412dc579e277c910774f41b380308d582862b16763425583e69e0fc14/djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292", size = 328501, upload-time = "2024-12-24T13:05:53.861Z" },
{ url = "https://files.pythonhosted.org/packages/9b/6f/428dc044d1e34363265b1301dc9b53253007acd858879d54b369d233aa96/djlint-1.36.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3164a048c7bb0baf042387b1e33f9bbbf99d90d1337bb4c3d66eb0f96f5400a1", size = 415849, upload-time = "2024-12-24T13:05:56.377Z" },
{ url = "https://files.pythonhosted.org/packages/d6/13/0d488e551d73ddf369552fc6f4c7702ea683e4bc1305bcf5c1d198fbdace/djlint-1.36.4-cp310-cp310-win_amd64.whl", hash = "sha256:3196d5277da5934962d67ad6c33a948ba77a7b6eadf064648bef6ee5f216b03c", size = 360969, upload-time = "2024-12-24T13:05:59.582Z" },
{ url = "https://files.pythonhosted.org/packages/04/68/18ecd1e4d54a523e1d077f01419d669116e5dede97f97f1eb8ddb918a872/djlint-1.36.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d68da0ed10ee9ca1e32e225cbb8e9b98bf7e6f8b48a8e4836117b6605b88cc7", size = 344261, upload-time = "2024-12-24T13:06:01.136Z" },
{ url = "https://files.pythonhosted.org/packages/1e/03/005cf5c66e57ca2d26249f8385bc64420b2a95fea81c5eb619c925199029/djlint-1.36.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0478d5392247f1e6ee29220bbdbf7fb4e1bc0e7e83d291fda6fb926c1787ba7", size = 319580, upload-time = "2024-12-24T13:06:03.824Z" },
{ url = "https://files.pythonhosted.org/packages/9f/88/aea3c81343a273a87362f30442abc13351dc8ada0b10e51daa285b4dddac/djlint-1.36.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:962f7b83aee166e499eff916d631c6dde7f1447d7610785a60ed2a75a5763483", size = 407070, upload-time = "2024-12-24T13:06:05.356Z" },
{ url = "https://files.pythonhosted.org/packages/60/77/0f767ac0b72e9a664bb8c92b8940f21bc1b1e806e5bd727584d40a4ca551/djlint-1.36.4-cp311-cp311-win_amd64.whl", hash = "sha256:53cbc450aa425c832f09bc453b8a94a039d147b096740df54a3547fada77ed08", size = 360775, upload-time = "2024-12-24T13:06:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/53/f5/9ae02b875604755d4d00cebf96b218b0faa3198edc630f56a139581aed87/djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b", size = 354886, upload-time = "2024-12-24T13:06:11.571Z" },
{ url = "https://files.pythonhosted.org/packages/97/51/284443ff2f2a278f61d4ae6ae55eaf820ad9f0fd386d781cdfe91f4de495/djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e", size = 323237, upload-time = "2024-12-24T13:06:13.057Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5e/791f4c5571f3f168ad26fa3757af8f7a05c623fde1134a9c4de814ee33b7/djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675", size = 411719, upload-time = "2024-12-24T13:06:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/1f/11/894425add6f84deffcc6e373f2ce250f2f7b01aa58c7f230016ebe7a0085/djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08", size = 362076, upload-time = "2024-12-24T13:06:17.517Z" },
{ url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" },
{ url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" },
{ url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" },
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
{ url = "https://files.pythonhosted.org/packages/9e/df/a81550590ab37a3d99880b5dc781616f1944bd3b3e353bf041ee1d5fee7d/djlint-1.39.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc806fbc58d69941b5280f31f6126e0545f8408e99264d3a0dce1de767c8dd79", size = 520521, upload-time = "2026-06-05T19:23:12.411Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c3/c40a148a23d19fbeb9d1028e159fe4c16981c538d73beb9c4f28f0dd0e94/djlint-1.39.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2621cfe40bd3cd439a028370b80ff8934fa6414f8cca1f27221957d1775b8fe", size = 496510, upload-time = "2026-06-05T19:22:44.882Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a9bf5e689146c98b4644dc85dc66b050d465cde5353ada06ad6fb3fd362/djlint-1.39.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4be236e58cac714bae3931970b4ae73425c200951803a8afd635f2a11a9463ac", size = 524712, upload-time = "2026-06-05T19:23:26.775Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fd/0d227699fb927136bece3df66638e0554f6eacb2bf9d3aea398402d97fe8/djlint-1.39.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f63e8cf847fcf748cadc7feb9acc265b89578fd043350c8776eb7b0825a0e5e9", size = 542959, upload-time = "2026-06-05T19:23:06.972Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fa/9badca5dc6d2bbae9cf81db959d887ea41f7333e9c8e87b0374175e85be4/djlint-1.39.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab3427fa50149d0f618de08a437d24931ffce3e0505615556ed78e502edcb4d9", size = 529748, upload-time = "2026-06-05T19:22:56.673Z" },
{ url = "https://files.pythonhosted.org/packages/19/26/7f68a5b835451ababcf373830ba4068d5083ff2b06d9d423f3cf73fbf26f/djlint-1.39.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5800abf3d506708094497d55fdfbaae7b522b551c273ca22abeb5d682c28875e", size = 552687, upload-time = "2026-06-05T19:22:32.665Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6c/3c3252d8e6904db7ff4458545b48ae98af14a364cbfee1a7738c73386dd6/djlint-1.39.0-cp310-cp310-win32.whl", hash = "sha256:e69532cd9c970871ec475509fe41d8b5a453a51cd4a82b1e4f175f3144fa1a63", size = 405814, upload-time = "2026-06-05T19:22:50.725Z" },
{ url = "https://files.pythonhosted.org/packages/eb/60/11fb512bd868161834f19fd088eb99e1c9a3cd024e0ba1fc3f28aa0b51d9/djlint-1.39.0-cp310-cp310-win_amd64.whl", hash = "sha256:b1ae7b0c3413adde6619dddf0ac58be55436489af482c45743caa9862282117b", size = 451264, upload-time = "2026-06-05T19:22:59.176Z" },
{ url = "https://files.pythonhosted.org/packages/ec/be/54d79236a7c29a373302ac5f0f3d92089003594dc02a40f58fc553e869fa/djlint-1.39.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a15b5164b75544045c28a9950d29fb3b4992fd02217dae2a0607085547bb900", size = 388868, upload-time = "2026-06-05T19:23:25.724Z" },
{ url = "https://files.pythonhosted.org/packages/47/f6/3044a6be9d4ac207b39f001be7e0f6a695d007cfb4b4e45d761712cb23f9/djlint-1.39.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b9e02481c6725c7ec01ed4603bec6c8e8e8ffb9cd60280c10dba98d8edced43", size = 509864, upload-time = "2026-06-05T19:23:27.987Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/30e67cfd1232d07ef3e6057e6017bfec6f08825aa08adc8cab5d3070cbe1/djlint-1.39.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c78334b22cf39d1f24c6e404eedab3f634f5eed70c8ce437762d47efdfa3a33e", size = 484321, upload-time = "2026-06-05T19:23:00.494Z" },
{ url = "https://files.pythonhosted.org/packages/bd/73/e1d5b0f3446123395c54a0084e85c4ceebc9d69b096c07ea11781df68db8/djlint-1.39.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08c67a870591e233604c5402163b44d4e872e801aa6a09fc082b4db33beb7049", size = 512772, upload-time = "2026-06-05T19:23:16.285Z" },
{ url = "https://files.pythonhosted.org/packages/89/8b/bb8a67ed58229511f0a137368c9e938e645d126946fbcaf98df8e1728e84/djlint-1.39.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcaf7ef93dfde168b6d61ea9caf35f294232e5b72d2b6d401d04f1d753758435", size = 533865, upload-time = "2026-06-05T19:23:31.624Z" },
{ url = "https://files.pythonhosted.org/packages/fc/87/694fc944f94703ff8530dac13461dfff07354307d413265036b4cb6c2ecc/djlint-1.39.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bf9a88b60a521a7a795102ce16086745f6d8e1783d2517e87e13efb5cd3057d0", size = 520819, upload-time = "2026-06-05T19:23:03.786Z" },
{ url = "https://files.pythonhosted.org/packages/d2/db/5fcec4ed089d9d1d3a2967511cda5758a21153c346c7a645ac635e085d09/djlint-1.39.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:65d4008fcba8a3fb1550a37bee2271e13fd55f37e569122de6507e6c3a77bc10", size = 543638, upload-time = "2026-06-05T19:22:34.962Z" },
{ url = "https://files.pythonhosted.org/packages/a9/bb/9cb11cb40314573006b52a48870b51041c65fb3e33fe5e30b08dffe1bf6a/djlint-1.39.0-cp311-cp311-win32.whl", hash = "sha256:759a54d9fa426cb74b5ac02e55c3e6c22c8ce63401a2846cba302dad2888190a", size = 404853, upload-time = "2026-06-05T19:23:08.067Z" },
{ url = "https://files.pythonhosted.org/packages/8b/36/f79c30f9b83186a68e9977065fcbdac075547497fe253708ec73a517899a/djlint-1.39.0-cp311-cp311-win_amd64.whl", hash = "sha256:09fbaf395d88b8b372c284c25464d9bbc0f41fbb98444f3fc227773806c90fa1", size = 451186, upload-time = "2026-06-05T19:23:17.364Z" },
{ url = "https://files.pythonhosted.org/packages/41/1f/f05d094b2d2c192b2f32c12918a4dc0362723716a60254fd8cd3de95ef5c/djlint-1.39.0-cp311-cp311-win_arm64.whl", hash = "sha256:db91ccdbb475150038b0d272df6e8e1d8d4acc658990fa3484de4d5e46532f32", size = 387598, upload-time = "2026-06-05T19:23:30.422Z" },
{ url = "https://files.pythonhosted.org/packages/56/ef/dac918a5a78fe90f141f05d648db86e2033e39af8210b8e6d34a6c4c2c2a/djlint-1.39.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e28a346d52ecccd580c577045c03711321c0ca6a0d224267a00f186695dbc1d", size = 520028, upload-time = "2026-06-05T19:22:42.688Z" },
{ url = "https://files.pythonhosted.org/packages/d9/dd/b1b2c34e43ac3fbf172cf0bed692a813284e1eecd335bdea8634318a6304/djlint-1.39.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:df5417ba6511a1655b50f1393ea6c1c8015bc20a5cbb27297b57ae78fe2d17ac", size = 491722, upload-time = "2026-06-05T19:23:14.683Z" },
{ url = "https://files.pythonhosted.org/packages/06/14/f010c3eb471f2c96a226af2eeaab634f032e1323e677fdd63cdcd981f173/djlint-1.39.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3f45b96a84998e02049b3d555599da3c3e3254354c3401b52505a79aefbf59", size = 515560, upload-time = "2026-06-05T19:22:49.674Z" },
{ url = "https://files.pythonhosted.org/packages/fb/8b/6cd7a60a494d156da84a07a92a77206f4ece42d24ed025b544b7d22eb98e/djlint-1.39.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa0acde91ce23f733e2b133b4db627a9dbd13f9cccceb0d55c6bcaa6556befc8", size = 539935, upload-time = "2026-06-05T19:23:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/ce/74/0b1353cbe9992d29cef42535b89726b92243c38e48683ee92bdde6dd65d2/djlint-1.39.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f23eeb266efb075b738a45ab908594bbdaaee7ce20ab4dbc61c17501f70db16", size = 522470, upload-time = "2026-06-05T19:23:18.423Z" },
{ url = "https://files.pythonhosted.org/packages/8b/17/27995ca81db8af36b8469941e232adeab010e0a08c75b9622e03f755f5b5/djlint-1.39.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:adc646935fa1e3f6fec01a7eae471a1b41dddc6f2847a4402d3dc9d7483d5337", size = 548865, upload-time = "2026-06-05T19:22:45.92Z" },
{ url = "https://files.pythonhosted.org/packages/cf/60/c1ed2d49a54b1e28fcba790be28bb3a1fa7555cd9ea288c56e522118eb6b/djlint-1.39.0-cp312-cp312-win32.whl", hash = "sha256:5841c0cf72ded43e08bd97c9743116332c1b13a36d98cb7cc353c44ab86cf3b9", size = 407021, upload-time = "2026-06-05T19:23:01.642Z" },
{ url = "https://files.pythonhosted.org/packages/03/4b/3327b925dbb5244a06c76fec06e5abea9f53b9d78d254a4e1264124afc5a/djlint-1.39.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f0db716dd94681e7dedf695563689249f7c470873f3f74b2765b7743783435e", size = 453876, upload-time = "2026-06-05T19:22:38.037Z" },
{ url = "https://files.pythonhosted.org/packages/da/dc/b690d59b6457fd2aab2cbcb66d93b9e95f0c3d04de81d5ef9a4b5cc9c545/djlint-1.39.0-cp312-cp312-win_arm64.whl", hash = "sha256:412f6319d9888548068af681c05722c9acfbe760d5b29d44832e1a40eb116be2", size = 388980, upload-time = "2026-06-05T19:22:39.228Z" },
{ url = "https://files.pythonhosted.org/packages/4e/d0/6055cebb538718e46b3874d3a1c0c768aaf744a1354f342b1932985c882b/djlint-1.39.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb2948211eb369bd28175f2007cc924bff7e2403ec1f42f22f6d4381c32bad31", size = 517087, upload-time = "2026-06-05T19:22:40.617Z" },
{ url = "https://files.pythonhosted.org/packages/39/be/726afcd62b9ce6382d2c10a9122a45daf4a47b6e2af4a7536c82b8b5f4fc/djlint-1.39.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1476f077af638ba21813cc17d8e7d31b1d5473e707d98c659e6ac2bdf5210e6", size = 489869, upload-time = "2026-06-05T19:22:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a0/f26dc11c62111f6d80550e9188b2d207691f0664ed3b7dbd62ed5d418e32/djlint-1.39.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19dbef7852fabe445ce4ea2b05da888df0513e1798c4ae7cd8f0c68cf0bc8cbb", size = 513551, upload-time = "2026-06-05T19:23:13.49Z" },
{ url = "https://files.pythonhosted.org/packages/26/5a/2ffe28c44d27aa006314c1b352a0b6039ab05dd4b7b3dbac494315b912ab/djlint-1.39.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c8c7bba68633f6a4a211dd35ded9337ec52a7a2991afc816f928f741296c1b3", size = 537832, upload-time = "2026-06-05T19:22:30.67Z" },
{ url = "https://files.pythonhosted.org/packages/53/46/2cb7966a7a93b4758a380500c9a18fa22688b071dba5b52106107b48de4e/djlint-1.39.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5564bc51531332ba67bc8d952825ac2a42a7ec1618413a4da15bf957257c0d6", size = 520497, upload-time = "2026-06-05T19:23:19.497Z" },
{ url = "https://files.pythonhosted.org/packages/81/d0/b32648761b1529b030897b931998a6dabe6a15473c4724e1080c2ca737ae/djlint-1.39.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b836e79f690d83aa429cfa3240045e086f9e0764afbc88654004f455e2a9835a", size = 547304, upload-time = "2026-06-05T19:23:21.742Z" },
{ url = "https://files.pythonhosted.org/packages/26/6d/c0e7c61fdeee741ee7eec85a14dd40c8d2e1ee9efeb96a8a7302a8daef47/djlint-1.39.0-cp313-cp313-win32.whl", hash = "sha256:f18c148fc6cfb32dd8a0af7c80067f02d3faa83f5aea16a7c7fd5111d303ee69", size = 406746, upload-time = "2026-06-05T19:22:57.969Z" },
{ url = "https://files.pythonhosted.org/packages/74/c5/7ea676211bbb85665b2f82f2cc64925a4f54d866d57887ab943e97016fcf/djlint-1.39.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c38a8e90f8a73adf08b6852ee34bf3c734873f2ff1df58e56206308272cb275", size = 453441, upload-time = "2026-06-05T19:22:41.662Z" },
{ url = "https://files.pythonhosted.org/packages/04/49/3056c368937e98d6cb7d1ac662e64e93bc9b5ddf5a2afcd01839c0095a51/djlint-1.39.0-cp313-cp313-win_arm64.whl", hash = "sha256:e95095623cf5d6e84161c9a08e81f29ea5f7f1c804107ccf7cd2fe27a750a3bc", size = 388639, upload-time = "2026-06-05T19:22:53.201Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c2/76fa9ffa5b88784a2704b64f08d902bc8071a99bdd79a983f56b3e2dfcdf/djlint-1.39.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a092b0beb93d9a6fe5e1e28934e4f933c483ce791aae9aec47e3f07a29511a61", size = 515957, upload-time = "2026-06-05T19:23:09.12Z" },
{ url = "https://files.pythonhosted.org/packages/d5/44/638b92e40ad5b473df6728c3c6c7ebd9d50823d4cf8dd5bdf22073bd1d57/djlint-1.39.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ca3cd2c1ca610ad6e6357abba51e8153dc19f1d34764bcf453084199a4732a2", size = 488676, upload-time = "2026-06-05T19:22:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/27/b6/50e91d06554b74dc558a6af6349643c0165ff6dcc5142908ae2db012acca/djlint-1.39.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0011c2b78fa26752e3373129965dcbe80253af7fd2807e394fdfd4ea6281d99", size = 517217, upload-time = "2026-06-05T19:22:48.533Z" },
{ url = "https://files.pythonhosted.org/packages/77/2d/f9f900ae26b44b3b79090667148eeb016464cfe70d0211e2afe0fda9ab4c/djlint-1.39.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:683ec039c2864670f1806fc96e4650f3f7e310222acb5d602608aeb24ca352e9", size = 537472, upload-time = "2026-06-05T19:22:51.868Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ad/28ef34f629e728042341c397261fc2593a2eec489e44a7863cf646edc628/djlint-1.39.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:326a5ec019b084eb2d837f39d0bea6727806867e9d1e26d3f4bf0cd6bc67bf8f", size = 523546, upload-time = "2026-06-05T19:23:29.143Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6a/7ce68fdf319d9abda560fe3509d60abefe25ef118ae21d03399b1dfc84e7/djlint-1.39.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e655ac4e4346b3f5a61b53a9351104d33e4a7376f1c22acf4fadf1183f90128a", size = 546627, upload-time = "2026-06-05T19:22:31.67Z" },
{ url = "https://files.pythonhosted.org/packages/04/89/3e5bfaeb7b39a078a9a8d4fc7331e60f12f0e5c1251bc6c622be8c592ad4/djlint-1.39.0-cp314-cp314-win32.whl", hash = "sha256:0b5e30ab98c4de74698211ce6a60a502307d176015bf98269f74a39d862fc694", size = 412745, upload-time = "2026-06-05T19:22:35.955Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bd/b891316176513c233507dbf2f82747552e401079e3f917c46fbf84c5ef05/djlint-1.39.0-cp314-cp314-win_amd64.whl", hash = "sha256:9d4927b1bf65445e3c8dda8d1b96ab3019dbce1eaa88850760df78962bf2724e", size = 462295, upload-time = "2026-06-05T19:23:05.893Z" },
{ url = "https://files.pythonhosted.org/packages/06/44/ba3bf57ee70e969407e96d7accfb13d00c776674dbce95f8b07e1c7f731f/djlint-1.39.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b6a684f5cd8fc71ad55cd3c1acffa0cd4108bc63ad1524f9ca1d76b1b354e47", size = 396557, upload-time = "2026-06-05T19:22:54.276Z" },
{ url = "https://files.pythonhosted.org/packages/26/c0/bdb3eb96bd8e5d65546fe63063b787e302b981ec2f1436b1a0027404c311/djlint-1.39.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:afa4ba49d6b67f3c0145d78448c292e75d5822e76c189ef681399ead8492c599", size = 561022, upload-time = "2026-06-05T19:23:23.09Z" },
{ url = "https://files.pythonhosted.org/packages/96/98/e35b87ebc8f2a6985aed5ea7b85145d9e6e5d5b67fc3b612396a84604791/djlint-1.39.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1fee96af514bd1cb6b62d1107bb177d4d2f49361e5e9cd14f56f9650cdc2b5ad", size = 534450, upload-time = "2026-06-05T19:22:33.683Z" },
{ url = "https://files.pythonhosted.org/packages/87/f4/3ff2615cc2826c91ec3c7c26e8abedb35b3a546a068bc70ef385b2079c17/djlint-1.39.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ef06848e1ed5d987bb1aaf950ffe3a87b14e5937d9d42dbb1d0469ebe7a74dc", size = 552149, upload-time = "2026-06-05T19:22:27.861Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fc/6fea3ea0075d06d1d5444a7ad72bf51c612795339e95d4b281599c61b9ee/djlint-1.39.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffcbca30ad41bc054c7c7ed5341ea651b034a60d4eff0aa2ab0bb8cb40f2b9b0", size = 570693, upload-time = "2026-06-05T19:22:55.293Z" },
{ url = "https://files.pythonhosted.org/packages/1e/6a/af8a4012652a33208b3e0ca04c23446711fa5ecf8936809c04c6213c47b8/djlint-1.39.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8aace5a239e5f317b030a5c05d22d55edac5142366ffa1a15e5e5c8675044e44", size = 557296, upload-time = "2026-06-05T19:23:24.545Z" },
{ url = "https://files.pythonhosted.org/packages/6e/13/bf86a4f5d140ab6052a3aca8742cb446ec851946c7dcb625eb18a2564893/djlint-1.39.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9912c361968a3c881fd3eaff5a5dc56a0a409a7904355d998d430ff294550744", size = 579052, upload-time = "2026-06-05T19:23:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/5d2850606e321f8d6e56fe74fcb283c12493d179279bb52f347d0338aa6e/djlint-1.39.0-cp314-cp314t-win32.whl", hash = "sha256:12d3175f48317ec692da693a15ce7b939b3114f16b8d644bb037784bcef0bd52", size = 457432, upload-time = "2026-06-05T19:23:04.728Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9f/6dc179c101d30c1aa4269e0cada79667c043d15392e515fb7e4e36e8a8df/djlint-1.39.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3077dc9a4b3bb2724cd0231f008d309fe4ef4048af06b7edd1adba723356248", size = 513546, upload-time = "2026-06-05T19:23:11.375Z" },
{ url = "https://files.pythonhosted.org/packages/d9/0d/e3acb7da4ce3df5d699412b9442b885286df7e45647c205d65e593d02711/djlint-1.39.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f7228e01d5ceaf74fb5270d7bdfbd30dffe65e88216a70824765bca6acb2a4fb", size = 412286, upload-time = "2026-06-05T19:22:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/bd/45/50bddcbcee9566c213f14db5b154ade285c4842b88cdcdcc8d536d515147/djlint-1.39.0-py3-none-any.whl", hash = "sha256:3ef41f7bbf7761978e86e24ebdaf58704b17d847e9d0b5d9cb9f761ce976cff0", size = 60750, upload-time = "2026-06-05T19:23:02.846Z" },
]
[[package]]
@@ -100,7 +138,7 @@ dev = [
[package.metadata.requires-dev]
dev = [
{ name = "djlint", specifier = "==1.36.4" },
{ name = "djlint", specifier = "==1.39.0" },
{ name = "yamllint", specifier = "==1.38.0" },
{ name = "zizmor", specifier = "==1.25.2" },
]

View File

@@ -52,6 +52,7 @@
@import "./markup/content.css";
@import "./markup/codeblock.css";
@import "./markup/codepreview.css";
@import "./markup/jupyter.css";
@import "./font_i18n.css";
@import "./base.css";

View File

@@ -0,0 +1,93 @@
.markup.jupyter-render {
padding: 0;
}
.markup .jupyter-notebook {
padding: 20px;
background: var(--color-body);
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
font-family: var(--fonts-monospace);
display: flex;
flex-direction: column;
gap: 2em;
}
/* cell code */
.markup .jupyter-notebook .cell-line {
display: flex;
width: 100%;
gap: 0.5em;
}
.markup .jupyter-notebook .cell-left {
width: 100px;
flex-shrink: 0;
}
.markup .jupyter-notebook .cell-right {
flex: 1;
}
.markup .jupyter-notebook .cell-prompt {
padding: 10px 0;
color: var(--color-text-light-2);
font-size: 13px;
}
.markup .jupyter-notebook .cell-left.cell-prompt {
padding-left: 10px;
text-align: right;
white-space: nowrap;
user-select: none;
}
.markup .jupyter-notebook .cell-right.cell-prompt {
padding-right: 10px;
}
.markup .jupyter-notebook .cell-input,
.markup .jupyter-notebook .cell-output {
overflow-x: auto;
}
.markup .jupyter-notebook .cell-input pre,
.markup .jupyter-notebook .cell-output pre {
padding: 10px 16px;
font-size: 13px;
min-height: 40px;
margin: 0;
}
.markup .jupyter-notebook .cell-input pre {
background-color: var(--color-code-bg);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.markup .jupyter-notebook .cell-output {
display: flex;
flex-direction: column;
gap: 1em;
}
.markup .jupyter-notebook .cell-type-code {
display: flex;
flex-direction: column;
gap: 1em;
}
.markup .jupyter-notebook .cell-output-unsupported {
color: var(--color-text-light-2);
font-style: italic;
font-size: 13px;
}
.markup .jupyter-notebook .cell-output-error {
color: var(--color-red);
}
/* cell markdown */
.markup .jupyter-notebook .cell-right .embedded-markdown {
padding: 0 16px; /* match cell code right padding */
}

View File

@@ -1,3 +1,9 @@
.file-view-container {
padding: 0 !important; /* the file-view itself provides padding */
width: 100% !important; /* override fomantic's "100% + 2px" */
max-width: 100% !important;
}
.file-view tr.active .lines-num,
.file-view tr.active .lines-escape,
.file-view tr.active .lines-code {

View File

@@ -2,32 +2,33 @@
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown.
-->
<script lang="ts" setup>
import {computed} from 'vue';
import {SvgIcon} from '../svg.ts';
import {getActionStatusIcon, type ActionStatusIconVariant} from '../modules/action-status-icon.ts';
const props = withDefaults(defineProps<{
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'cancelling' | 'unknown',
size?: number,
className?: string,
localeStatus?: string,
iconVariant?: 'circle-fill' | '',
iconVariant?: ActionStatusIconVariant,
}>(), {
size: 16,
className: '',
localeStatus: undefined,
iconVariant: '',
});
const circleFill = props.iconVariant === 'circle-fill';
const icon = computed(() => getActionStatusIcon(props.status, props.iconVariant));
const iconClass = computed(() => {
const classes = [icon.value.colorClass, props.className];
if (props.status === 'running') classes.push('rotate-clockwise');
return classes.filter(Boolean).join(' ');
});
</script>
<template>
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
<SvgIcon :name="circleFill ? 'octicon-check-circle-fill' : 'octicon-check'" class="tw-text-green" :size="size" :class="className" v-if="status === 'success'"/>
<SvgIcon name="octicon-skip" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'skipped'"/>
<SvgIcon name="octicon-stop" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
<SvgIcon name="octicon-circle" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'waiting'"/>
<SvgIcon name="octicon-blocked" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
<SvgIcon name="gitea-running" class="tw-text-yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
<SvgIcon name="octicon-stop" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'cancelling'"/>
<SvgIcon :name="circleFill ? 'octicon-x-circle-fill' : 'octicon-x'" class="tw-text-red" :size="size" :class="className" v-else/><!-- failure, unknown -->
<SvgIcon :name="icon.name" :class="iconClass" :size="size"/>
</span>
</template>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {computed, ref, toRefs} from 'vue';
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
import {POST, DELETE} from '../modules/fetch.ts';
import ActionRunSummaryView from './ActionRunSummaryView.vue';
import ActionRunJobView from './ActionRunJobView.vue';
@@ -118,6 +119,14 @@ async function deleteArtifact(name: string) {
await DELETE(buildArtifactLink(name));
await store.forceReloadCurrentRun();
}
watch(() => run.value.status, (status) => {
syncActionRunFavicon(status);
});
onBeforeUnmount(() => {
resetActionFavicon();
});
</script>
<template>
<!-- make the view container full width to make users easier to read logs -->

View File

@@ -0,0 +1,9 @@
import {getActionStatusIcon} from './action-status-icon.ts';
test('getActionStatusIcon', () => {
expect(getActionStatusIcon('success')).toEqual({name: 'octicon-check', colorClass: 'tw-text-green'});
expect(getActionStatusIcon('success', 'circle-fill')).toEqual({name: 'octicon-check-circle-fill', colorClass: 'tw-text-green'});
expect(getActionStatusIcon('running')).toEqual({name: 'gitea-running', colorClass: 'tw-text-yellow'});
expect(getActionStatusIcon('failure', 'circle-fill')).toEqual({name: 'octicon-x-circle-fill', colorClass: 'tw-text-red'});
expect(getActionStatusIcon('cancelled')).toEqual({name: 'octicon-stop', colorClass: 'tw-text-text-light'});
});

View File

@@ -0,0 +1,37 @@
import type {SvgName} from '../svg.ts';
import type {ActionsStatus} from './gitea-actions.ts';
export type ActionStatusIconVariant = 'circle-fill' | '';
export type ActionStatusIconSpec = {
name: SvgName,
colorClass: string,
};
// Keep in sync with templates/repo/icons/action_status.tmpl and ActionStatusIcon.vue.
export function getActionStatusIcon(status: ActionsStatus, iconVariant: ActionStatusIconVariant = ''): ActionStatusIconSpec {
const circleFill = iconVariant === 'circle-fill';
switch (status) {
case 'success':
return {name: circleFill ? 'octicon-check-circle-fill' : 'octicon-check', colorClass: 'tw-text-green'};
case 'skipped':
return {name: 'octicon-skip', colorClass: 'tw-text-text-light'};
case 'cancelled':
return {name: 'octicon-stop', colorClass: 'tw-text-text-light'};
case 'waiting':
return {name: 'octicon-circle', colorClass: 'tw-text-text-light'};
case 'blocked':
return {name: 'octicon-blocked', colorClass: 'tw-text-yellow'};
case 'running':
return {name: 'gitea-running', colorClass: 'tw-text-yellow'};
case 'cancelling':
return {name: 'octicon-stop', colorClass: 'tw-text-yellow'};
case 'failure':
case 'unknown':
return {name: circleFill ? 'octicon-x-circle-fill' : 'octicon-x', colorClass: 'tw-text-red'};
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}

View File

@@ -0,0 +1,29 @@
import {buildStatusFaviconSvg, resetActionFavicon, syncActionRunFavicon} from './favicon-status.ts';
test('buildStatusFaviconSvg uses action status icons', () => {
const success = buildStatusFaviconSvg('success');
expect(success).toContain('viewBox="0 0 640 640"');
expect(success).toContain('fill:#609926');
expect(success).toContain('data-actions-status-name="success"');
const running = buildStatusFaviconSvg('running');
expect(running).toContain('data-actions-status-name="running"');
const failure = buildStatusFaviconSvg('failure');
expect(failure).toContain('data-actions-status-name="failure"');
});
test('syncActionRunFavicon updates favicon links', () => {
document.head.innerHTML = `
<link rel="icon" href="/assets/img/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="/assets/img/favicon.png" type="image/png">
`;
const links = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
syncActionRunFavicon('running');
for (const link of links) {
expect(link.href).toMatch(/^data:image\/svg\+xml,/);
expect(decodeURIComponent(link.href)).toContain('data-actions-status-name="running"');
}
resetActionFavicon();
expect(links[0].href).toContain('favicon.svg');
});

View File

@@ -0,0 +1,90 @@
import {getActionStatusIcon} from './action-status-icon.ts';
import type {ActionsStatus} from './gitea-actions.ts';
import {svgParseOuterInner} from '../svg.ts';
import {html, htmlRaw} from '../utils/html.ts';
const {svgOuter, svgInnerHtml: giteaFaviconInner} = svgParseOuterInner('gitea-favicon');
const faviconViewBox = svgOuter.getAttribute('viewBox')!;
const [, , faviconViewBoxWidth, faviconViewBoxHeight] = faviconViewBox.split(/\s+/).map(Number);
// the status badge is rendered in the bottom-right corner, following GitHub Actions favicon proportions
const badgeIconSize = 16;
const badgeSizeRatio = 340 / 640;
const badgeMargin = 6;
const badgeDrawSize = faviconViewBoxWidth * badgeSizeRatio;
const badgeX = faviconViewBoxWidth - badgeDrawSize - badgeMargin;
const badgeY = faviconViewBoxHeight - badgeDrawSize - badgeMargin;
const badgeScale = badgeDrawSize / badgeIconSize;
// white ring behind the badge so it stands out from the logo, like GitHub's favicon
const badgeCenter = badgeDrawSize / 2;
const badgeRingRadius = badgeCenter + badgeDrawSize * 0.08;
let currentStatus: ActionsStatus | null = null;
const defaultFaviconHrefs = new Map<HTMLLinkElement, string>();
const faviconDataUrlCache = new Map<ActionsStatus, string>();
let colorProbe: HTMLElement | null = null;
function rememberDefaultFaviconHrefs() {
if (defaultFaviconHrefs.size > 0) return;
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')) {
defaultFaviconHrefs.set(link, link.href);
}
}
function resolveTailwindTextColor(colorClass: string): string {
if (!colorProbe) {
colorProbe = document.createElement('span');
colorProbe.style.display = 'none';
document.body.append(colorProbe);
}
colorProbe.className = colorClass;
return getComputedStyle(colorProbe).color || '#000000';
}
function buildStatusIconMarkup(status: ActionsStatus): string {
const {name, colorClass} = getActionStatusIcon(status, 'circle-fill');
const color = resolveTailwindTextColor(colorClass);
const {svgInnerHtml} = svgParseOuterInner(name);
const coloredInner = svgInnerHtml.replaceAll('currentColor', color);
const ring = html`<circle cx="${badgeX + badgeCenter}" cy="${badgeY + badgeCenter}" r="${badgeRingRadius}" fill="#ffffff"/>`;
const badge = html`<g data-actions-status-name="${status}" transform="translate(${badgeX}, ${badgeY}) scale(${badgeScale})" fill="${color}" color="${color}">${htmlRaw(coloredInner)}</g>`;
return html`${htmlRaw(ring)}${htmlRaw(badge)}`;
}
export function buildStatusFaviconSvg(status: ActionsStatus): string {
return html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${faviconViewBox}">${htmlRaw(giteaFaviconInner)}${htmlRaw(buildStatusIconMarkup(status))}</svg>`;
}
function buildStatusFaviconDataUrl(status: ActionsStatus): string {
const cached = faviconDataUrlCache.get(status);
if (cached) return cached;
const dataUrl = `data:image/svg+xml,${encodeURIComponent(buildStatusFaviconSvg(status))}`;
faviconDataUrlCache.set(status, dataUrl);
return dataUrl;
}
function setFaviconHref(href: string) {
rememberDefaultFaviconHrefs();
for (const link of defaultFaviconHrefs.keys()) {
if (link.isConnected) link.href = href;
}
}
export function syncActionRunFavicon(status: ActionsStatus | ''): void {
if (status === '') {
resetActionFavicon();
return;
}
if (status === currentStatus) return;
setFaviconHref(buildStatusFaviconDataUrl(status));
currentStatus = status;
}
export function resetActionFavicon(): void {
if (currentStatus === null) return;
rememberDefaultFaviconHrefs();
for (const [link, href] of defaultFaviconHrefs) {
if (link.isConnected) link.href = href;
}
currentStatus = null;
}

View File

@@ -5,6 +5,7 @@ import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-che
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
import giteaExclamation from '../../public/assets/img/svg/gitea-exclamation.svg';
import giteaFavicon from '../../public/assets/img/favicon.svg';
import giteaRunning from '../../public/assets/img/svg/gitea-running.svg';
import octiconArchive from '../../public/assets/img/svg/octicon-archive.svg';
import octiconArrowLeft from '../../public/assets/img/svg/octicon-arrow-left.svg';
@@ -93,6 +94,7 @@ const svgs = {
'gitea-double-chevron-right': giteaDoubleChevronRight,
'gitea-empty-checkbox': giteaEmptyCheckbox,
'gitea-exclamation': giteaExclamation,
'gitea-favicon': giteaFavicon,
'gitea-running': giteaRunning,
'octicon-archive': octiconArchive,
'octicon-arrow-left': octiconArrowLeft,