30 Commits

Author SHA1 Message Date
Oleks d421025147 fix(project_events): use process-lifetime ctx for async publish
The publish goroutine inherited the request context via
context.WithoutCancel. That context carries a request-scoped DB
session returned to the pool when the HTTP handler completes, so
GetProjectByID + access checks in the goroutine raced with session
teardown and intermittently returned empty recipient sets (uids=[]),
silently dropping every SSE board event. Root the detached context in
graceful.ShutdownContext() (global engine, process lifetime).

(cherry picked from commit bfc10289e6)
2026-05-16 00:10:10 +03:00
Oleks a02c4fb2ad chore(project): satisfy eslint unicorn rules in board SSE handlers 2026-05-15 22:10:49 +03:00
Oleks 6d09f611ea chore(project): satisfy gci formatting and nilnil lint 2026-05-15 22:08:25 +03:00
Oleks 47f3e4137e feat(project): session-tag propagation for own-tab event suppression
Adds a router middleware that extracts the X-Session-Tag header from
each request and decorates the request context via
project_events.WithSessionTag. Service- and model-layer publishers
then read it back via project_events.SessionTagFromContext and
attach it to outgoing CardMoved / CardLinked / CardUnlinked events.

The originating browser tab compares the incoming session_tag to
its own and skips the echo, avoiding double-application of the
optimistic local update. Other tabs see no tag match and apply the
event normally.

Wired into both the web router chain (before Contexter so the base
context inherits the tag) and the API router chain (before
APIContexter for the same reason).
2026-05-15 22:02:28 +03:00
Oleks 3c094d66fa feat(project): SSE subscriber + DOM patches on board page
The project board view now opens a SharedWorker EventSource
subscription scoped to project-board.{id} and patches the DOM in
response to incoming events:

- card.moved: relocates the card to the destination column and
  refreshes both column count badges; falls back to a column refetch
  when the card isn't currently rendered (filtered out / new).
- card.linked: refetches the destination column's issue list and
  updates the count badge.
- card.unlinked: removes the card and updates the badge.
- column.created: page reload (rare event, simplest path).
- column.updated: in-place title + color/contrast updates.
- column.deleted: removes the column element.
- column.reordered: re-attaches columns in the new sort order.
- project.updated: updates the header title + description text.
- project.deleted: navigates up to the projects listing.

The board template now exposes data-project-id, data-project-scope
(repo/user/org), data-project-owner, and data-project-repo so the
subscriber can build the right column-issues refetch URL.

Each mutation request the page makes also carries a
crypto.randomUUID-generated X-Session-Tag header; the receiving
handler compares it against the incoming payload's session_tag to
suppress own-tab echoes (the optimistic local update is already
authoritative).
2026-05-15 22:02:19 +03:00
Oleks 3fd0aa751d feat(project): publish board events from service+model choke points
Wrap the model-layer column/project/issue mutation funcs in service-layer
helpers (CreateColumn, EditColumn, DeleteColumn, ReorderColumns,
DeleteProject, AssignOrRemoveProjects) that publish the matching SSE
event after the underlying call succeeds.

Routers (web + REST) are migrated to call these service helpers so the
publish side-effects fire uniformly across repo, user, and org scopes.

DeleteColumn snapshots the column's issues before deletion and emits
one CardMoved per affected issue (alongside the ColumnDeleted event)
so the receiving tab can patch the DOM without a full reload.

Move-issue publishing fires after the txn commits so we never emit
events for moves that get rolled back.
2026-05-15 21:53:35 +03:00
Oleks 3c831efc0c feat(project): add SSE event bus and publish helpers
New services/project_events package marshals typed payloads to JSON,
wraps them in SSE events named project-board.{project_id}, and fans
them out via the eventsource manager to every connected user that
has read access to the project. Each Publish* helper runs the
broadcast in a goroutine so request handlers stay responsive.

Includes WithSessionTag / SessionTagFromContext for propagating an
X-Session-Tag value down to the publisher (so the originating browser
tab can suppress its own echo).

Unit tests cover event-name format, payload JSON shape, session-tag
propagation, the connected-uids access filter, and the broadcast
fan-out path.
2026-05-15 21:45:28 +03:00
Oleks a8d8d138cb feat(eventsource): add ConnectedUIDs accessor
Expose a snapshot of currently registered uids so fan-out broadcasters
can pre-filter recipients before calling SendMessage.
2026-05-15 21:45:19 +03:00
oleks 580fe2df32 Merge pull request 'feat(api): user-scope and org-scope project board endpoints' (#2) from user-org-project-api into main
Reviewed-on: #2
2026-05-15 15:53:11 +03:00
Oleks 909bff1a52 test(api): add MoveProjectIssue integration test for repo project scope
The repo-scope POST .../projects/{id}/issues/{issue_id}/move handler had no
test coverage. Adds testAPIMoveProjectIssue with happy-path move, 422 on
non-existent target column, and 404 when the issue isn't in the project.
2026-05-15 15:47:36 +03:00
Oleks 8ae6245c19 feat(api): add user-scope and org-scope project board endpoints
Adds the missing REST surface that mirrors the existing repo-scope project
API onto user and organization namespaces, so projects at /{owner}/-/projects/
become programmatically manageable (linking issues, listing column membership,
moving cards between columns) without browser-session auth.

Routes registered under /api/v1/users/{username}/projects/... and
/api/v1/orgs/{org}/projects/..., gated by AccessTokenScopeCategoryIssue.
User-scope writes require owner==doer or site admin; org-scope writes
require org membership. Handlers copy the repo-scope shape rather than
refactoring the existing repo handlers, keeping shipping code untouched.

Integration tests cover CRUD, columns, issue add/remove/move, listing per
column, and the permission matrix (owner/non-member/admin) for both scopes.
2026-05-15 15:47:29 +03:00
Oleks 086dd1858e ci: gate upstream release workflows on go-gitea/gitea
release-nightly / nightly-binary (push) Cancelled after 0s
release-nightly / nightly-container (push) Cancelled after 0s
The 3 release workflows (nightly, RC, version) target
namespace-profile-gitea-* runners that exist only on upstream's
Namespace.so CI cloud. On forks they queue forever waiting for
labels that never appear. Gate each job on github.repository, matching
the existing gate on cron-*.yml. Fork-only diff.
2026-05-13 11:34:53 +03:00
Oleks 013e844724 fix(api): filter list-column-issues by project_board_id
release-nightly / nightly-binary (push) Cancelled after 0s
release-nightly / nightly-container (push) Cancelled after 0s
cache-seeder / gobuild (push) Cancelled after 0s
cache-seeder / lint (lint-backend, bindata, lint-backend) (push) Cancelled after 0s
ListProjectColumnIssues was returning every issue in the project regardless
of which column was requested. The handler set ProjectIDs but had no way to
constrain to a specific column, and applyProjectCondition only joined
project_issue on project_id.

Add IssuesOptions.ProjectColumnID and extend the single-project branch of
applyProjectCondition to include project_board_id in the JOIN when set.

Strengthen the integration test: previously it placed two issues in the
project (default column) and asserted a column listing returned 2, which
masked the bug. Now create two distinct columns, move one issue into each,
and verify each column's listing returns exactly the issue it owns.

Reported during e2e validation on git.oleks.space against the production
fork (1.26.0-unstable-2026-05-11). Worth back-porting to upstream PR #37518.
2026-05-13 10:59:59 +03:00
Oleks 5a886307fd chore(ddev): add build-gitea-frontend command
`make backend` embeds public/ via go:embed at compile time, so any frontend
asset change requires:
  ddev build-gitea-frontend  # vite into public/assets/
  ddev build-gitea           # re-embed public/ into the binary
  ddev restart               # pick up new binary
2026-05-13 10:59:59 +03:00
Oleks d4d44cf283 chore(ddev): local dev environment for testing the Projects API
`ddev start` provisions:
- web container: Debian + Go 1.26.3 + node 24, builds gitea via `ddev build-gitea`
- db: postgres 17
- router: https://gitea.ddev.site → gitea on container port 3000

First-run steps:
  ddev start
  ddev build-gitea
  ddev restart
  ddev exec './gitea --config .ddev/gitea/app.ini admin user create \
    --admin --username ddev --password ddev1234567890 \
    --email ddev@example.com --must-change-password=false'

.ddev/.gitignore uses a whitelist: only the 6 files we authored are tracked;
DDEV's generated content (compose files, provider templates, snapshots,
gocache, gitea runtime data) is ignored.
2026-05-13 10:59:59 +03:00
Oleks 8734be114a style: auto-format from pre-push hooks 2026-05-13 10:59:59 +03:00
Oleks 921b32984b chore(devshell): add test shell without static glibc
The default shell links cgo statically against glibc.static for the
production gitea binary. Test binaries built in that shell segfault
inside cgo NSS calls (getaddrinfo/getpwuid_r) because the static glibc
NSS modules need matching dynamic libs at runtime.

Split the shell:
- `nix develop`           -> production build shell (static, unchanged)
- `nix develop .#test`    -> test/dev shell, no static glibc

Verified: TestAPIProjects passes in the `test` shell.
2026-05-13 10:59:59 +03:00
Oleks 77e3801a9c chore(devshell): add golangci-lint, govulncheck, and tea to nix shell 2026-05-13 10:59:59 +03:00
Oleks 1011241a67 feat(api): add REST API for repository project boards
Cherry-pick of upstream PR go-gitea/gitea#37518 onto feat/projects-api.
The PR is itself a rebase of #36831 onto main, adapted for the
multi-projects-per-issue model added in #36784.

Endpoints (all under /repos/{owner}/{repo}/projects...):
  GET    .                                 list projects
  POST   .                                 create project
  GET    /{id}                              get project
  PATCH  /{id}                              update project
  DELETE /{id}                              delete project
  GET    /{id}/columns                      list columns
  POST   /{id}/columns                      create column
  PATCH  /columns/{id}                      update column
  DELETE /columns/{id}                      delete column
  GET    /columns/{id}/issues               list issues in column
  POST   /columns/{id}/issues/{issue_id}    add/move issue to column
  DELETE /columns/{id}/issues/{issue_id}    remove issue from column
  POST   /columns/{id}/issues/{issue_id}/move  move between columns

Source: https://github.com/go-gitea/gitea/pull/37518
2026-05-13 10:59:58 +03:00
Nicolas 187daac598 fix: Sort action run jobs by JobID and Name with matrix examples (#37046)
Fix the sorting of jobs out of a matrix

## Before
<img width="415" height="487" alt="grafik"
src="https://github.com/user-attachments/assets/b628adb9-9158-4106-89f1-d8ecaa98f17d"
/>


## After

<img width="423" height="365" alt="grafik"
src="https://github.com/user-attachments/assets/d26223d5-96da-4bdc-bbfe-389101d28cc8"
/>

---------

Signed-off-by: Nicolas <bircni@icloud.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-05-13 07:30:22 +00:00
wxiaoguang 3738809219 fix: catch and fix more lint problems (#37674)
Changes are done by "make lint-go-fix"
2026-05-13 09:00:41 +02:00
silverwind ffd5e0698b docs(agents): update AGENTS.md (#37684)
Add two rules to `AGENTS.md` for recurring issues.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-13 08:27:22 +02:00
silverwind 79f7062d9e fix(actions): run TransferLogs on UpdateLog{Rows:[], NoMore:true} (#37631)
`UpdateLog` short-circuits on `len(Rows)==0` before honoring `NoMore`,
so a final empty `UpdateLog{NoMore:true}` never runs `TransferLogs`. The
task's `dbfs_data` rows are then never moved to log storage and never
deleted.

Fix: let `NoMore=true` with no new rows fall through to `TransferLogs`.
Bail when the runner has outrun the server (`Index > ack`) even with
`NoMore`, since archiving a log with a gap is worse than retrying.
Always call `WriteLogs` so `offset==0` bootstraps an empty DBFS file in
the no-output case (otherwise `TransferLogs` would fail at `dbfs.Open`).

Fixes: https://github.com/go-gitea/gitea/issues/37623
Ref: https://gitea.com/gitea/runner/pulls/952
Ref: https://gitea.com/gitea/runner/pulls/950
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-13 05:18:07 +00:00
GiteaBot f01953e764 [skip ci] Updated translations via Crowdin 2026-05-13 01:09:44 +00:00
Giteabot 6a27066269 fix(deps): update dependency mermaid to v11.15.0 [security], add e2e test (#37662)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [mermaid](https://redirect.github.com/mermaid-js/mermaid) | [`11.14.0`
→ `11.15.0`](https://renovatebot.com/diffs/npm/mermaid/11.14.0/11.15.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/mermaid/11.15.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mermaid/11.14.0/11.15.0?slim=true)
|

---

### Mermaid: Improper sanitization of `classDefs` in diagrams leads to
CSS injection
[CVE-2026-41148](https://nvd.nist.gov/vuln/detail/CVE-2026-41148) /
[GHSA-xcj9-5m2h-648r](https://redirect.github.com/advisories/GHSA-xcj9-5m2h-648r)

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

#### Details
##### Details

The state diagram and any other diagram type that routes user-controlled
style strings through createCssStyles parser for Mermaid v11.14.0 and
earlier captures `classDef` values with an unrestricted regex:

```jison
// packages/mermaid/src/diagrams/state/parser/stateDiagram.jison:83
<CLASSDEFID>[^\n]*   { this.popState(); return 'CLASSDEF_STYLEOPTS' }
```

The value passes unsanitized through `addStyleClass()` ->
`createCssStyles()` -> `style.innerHTML` (mermaidAPI.ts:418). A `}` in
the value closes the generated CSS selector, and everything after
becomes a new CSS rule on the page.

##### PoC

```
stateDiagram-v2 
      classDef x }*{ background-image: url("http://media.giphy.com/media/SggILpMXO7Xt6/giphy.gif")}
```

Live demo:

<https://mermaid.live/edit#pako:eNpFjzFvgzAQhf-KdVNbEcBgMHhtlkqtOnSJKi8ONsYKBmRMlRTx3-skanvTfbp7996t0IxSAYPZC6_2Rmgn7O4rQ00v5nmvWnRG29OKjqI5aTcug9wZK7RiaHH9A4fO-4kliVXSiFibqbvEzWjvnHxo_fI6vR3e6cGXyX2qTcvhcYMItDMSmHeLisAqZ8UVYeUDQhx8p6ziwEIrhTtx4MNVM4nhcxztrywE0h2wVvRzoGWS_z_8rahBKvcckntgmN5OAFvhDIzUNCZZQXCR5nVaZkUEF2BVFpOcEkoxxhUuyRbB980yjStapKHqoKFlhvPtB7BFZEU>

##### Patches

This has been patched in:

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[e9b0f34d8d82a6260077764ee45e1d7d90957a0f](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[8fead23c59166b7bab6a39eac81acebee2859102](https://redirect.github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102))

##### Workarounds

Setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will prevent this, by rendering the mermaid diagram in a sandboxed
`<iframe>`.

##### Impact

Enables page defacement, user tracking via `url()` callbacks, and DOM
attribute exfiltration via CSS `:has()` selectors.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-xcj9-5m2h-648r](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-xcj9-5m2h-648r)
-
[https://github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102](https://redirect.github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102)
-
[https://github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://mermaid.js.org/config/schema-docs/config.html#securitylevel](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
-
[https://github.com/advisories/GHSA-xcj9-5m2h-648r](https://redirect.github.com/advisories/GHSA-xcj9-5m2h-648r)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-xcj9-5m2h-648r)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid: Improper sanitization of `classDef` in state diagrams leads
to HTML injection
[CVE-2026-41149](https://nvd.nist.gov/vuln/detail/CVE-2026-41149) /
[GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/advisories/GHSA-ghcm-xqfw-q4vr)

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

#### Details
##### Impact

Under the default configuration, Mermaid state diagram's `classDef`
allow DOM injection that escapes the SVG, although `<script>` tags are
removed, preventing XSS.

##### Proof-of-concept

```
stateDiagram-v2
  classDef xss fill:red</style></svg><style>*{x:x;y:y;overflow:visible!important;contain:none!important;transform:none!important;filter:none!important;clip-path:none!important}</style><div style="x:x;y:y;color:red;font:5em/1 monospace;display:grid;place-items:center;z-index:2147483647;width:100vw;height:100vh;position:fixed;top:0;left:0;background:black">HACKED</div><svg><style>a:b
  [*] --> A:::xss
```

##### Patches

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[37ff937f1da2e19f882fd1db01235db4d01f4056](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3](https://redirect.github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3))

##### Workarounds

If you can not update to a patched version, setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will prevent this, by rendering the mermaid diagram in a sandboxed
`<iframe>`.

##### Credits

Thanks to @&#8203;zsxsoft from @&#8203;KeenSecurityLab for reporting
this vulnerability.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-ghcm-xqfw-q4vr)
-
[https://github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056)
-
[https://github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3](https://redirect.github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://mermaid.js.org/config/schema-docs/config.html#securitylevel](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
-
[https://github.com/advisories/GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/advisories/GHSA-ghcm-xqfw-q4vr)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-ghcm-xqfw-q4vr)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid: Improper sanitization of configuration leads to CSS
injection
[CVE-2026-41159](https://nvd.nist.gov/vuln/detail/CVE-2026-41159) /
[GHSA-87f9-hvmw-gh4p](https://redirect.github.com/advisories/GHSA-87f9-hvmw-gh4p)

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

#### Details
##### Impact

Mermaid's default configuration allows injecting CSS that applies
outside of the Mermaid diagram via the `fontFamily`, `themeCSS`, and
`altFontFamily` configuration options.

Live demo:
[mermaid.live](https://mermaid.live/edit#pako:eNpNjktLxDAUhf9KvFBR6JS-60QQfODKlUvJ5k6TtsEmKTHFGUP-u-mI6Nmdy3fOPR56wwVQSBIvtXSUeAaD0e4ZlZxPDChhcLxFfwiEauOuLq_9Afv30ZpVczpaITS5kGox1qF2gfSeBwYhJAnThAyz-ewntI68vG5-0z3Z7e7IA9OQwmglB-rsKlJQwircLPgNZeAmocTPAi4GXGfHgOkQYwvqN2PUbzJuGSegA84f0a0LRyeeJI4W_xChubCPcbQD2pwbgHo4Aq2aKmvbqq3zoiu7pizqFE6RybN9VFfFY1HWXRVS-Dr_zLObrt7_V_gGGXZlGg)

Example code:

```
%%{init: {"fontFamily": "x;a{b} :not(&){background:green !important} c{d}"}}%%
flowchart LR
    A --> B
```

The injected CSS exploits stylis's `&` (scope reference) handling.
`:not(&)` escapes the `#mermaid-xxx` automatic scoping, applying styles
to all page elements. Global at-rules (`@font-face`, `@keyframes`,
`@counter-style`) are also injectable as stylis hoists them to top
level.

This allows page defacement and DOM attribute exfiltration via CSS
`:has()` selectors.

##### Patches

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[64769738d5b59211e1decb471ffbaca8afec51aa](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[a9d9f0d8eb790349121508688cd338253fd80d76](https://redirect.github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76))

##### Workarounds

If you can't upgrade mermaid, you can set the
[`secure`](https://mermaid.js.org/config/schema-docs/config.html#secure)
config value in the mermaid config to avoid allowing diagrams to modify
`fontFamily`, `themeCSS`, `altFontFamily`, and `themeVariables`.

Setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will also prevent this.

##### Credits

Reported by @&#8203;zsxsoft on behalf of @&#8203;KeenSecurityLab

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-87f9-hvmw-gh4p](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-87f9-hvmw-gh4p)
-
[https://github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa)
-
[https://github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76](https://redirect.github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://github.com/advisories/GHSA-87f9-hvmw-gh4p](https://redirect.github.com/advisories/GHSA-87f9-hvmw-gh4p)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-87f9-hvmw-gh4p)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid Gantt Charts are vulnerable to an Infinite Loop DoS
[CVE-2026-41150](https://nvd.nist.gov/vuln/detail/CVE-2026-41150) /
[GHSA-6m6c-36f7-fhxh](https://redirect.github.com/advisories/GHSA-6m6c-36f7-fhxh)

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

#### Details
##### Impact

Mermaid v11.14.0 and earlier are vulnerable to a denial-of-service
attack when rendering gantt charts, if they use the [`excludes`
attribute](https://mermaid.js.org/syntax/gantt.html?#excludes) to
exclude all dates.

Example:

```
gantt
  excludes monday,tuesday,wednesday,thursday,friday,saturday,sunday
  DoS :2025-01-01, 1d
```

`mermaid.parse` is unaffected, unless you then call the
`ganttDb.getTasks()` (which is called when rendering a diagram).

##### Patches

This has been patched in:

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[faafb5d49106dd32c367f3882505f2dd625aa30e](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[a59ea56174712ee5430dfd5bc877cb5151f501a6](https://redirect.github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6))

##### Workarounds

There are no workarounds available without updating to a newer version
of mermaid.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-6m6c-36f7-fhxh](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-6m6c-36f7-fhxh)
-
[https://github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6](https://redirect.github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6)
-
[https://github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://github.com/advisories/GHSA-6m6c-36f7-fhxh](https://redirect.github.com/advisories/GHSA-6m6c-36f7-fhxh)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-6m6c-36f7-fhxh)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid Gantt Charts are vulnerable to an Infinite Loop DoS
[CVE-2026-41150](https://nvd.nist.gov/vuln/detail/CVE-2026-41150) /
[GHSA-6m6c-36f7-fhxh](https://redirect.github.com/advisories/GHSA-6m6c-36f7-fhxh)

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

#### Details
##### Impact

Mermaid v11.14.0 and earlier are vulnerable to a denial-of-service
attack when rendering gantt charts, if they use the [`excludes`
attribute](https://mermaid.js.org/syntax/gantt.html?#excludes) to
exclude all dates.

Example:

```
gantt
  excludes monday,tuesday,wednesday,thursday,friday,saturday,sunday
  DoS :2025-01-01, 1d
```

`mermaid.parse` is unaffected, unless you then call the
`ganttDb.getTasks()` (which is called when rendering a diagram).

##### Patches

This has been patched in:

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[faafb5d49106dd32c367f3882505f2dd625aa30e](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[a59ea56174712ee5430dfd5bc877cb5151f501a6](https://redirect.github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6))

##### Workarounds

There are no workarounds available without updating to a newer version
of mermaid.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-6m6c-36f7-fhxh](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-6m6c-36f7-fhxh)
-
[https://github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6](https://redirect.github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6)
-
[https://github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e)
-
[https://github.com/mermaid-js/mermaid](https://redirect.github.com/mermaid-js/mermaid)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-6m6c-36f7-fhxh) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid: Improper sanitization of configuration leads to CSS
injection
[CVE-2026-41159](https://nvd.nist.gov/vuln/detail/CVE-2026-41159) /
[GHSA-87f9-hvmw-gh4p](https://redirect.github.com/advisories/GHSA-87f9-hvmw-gh4p)

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

#### Details
##### Impact

Mermaid's default configuration allows injecting CSS that applies
outside of the Mermaid diagram via the `fontFamily`, `themeCSS`, and
`altFontFamily` configuration options.

Live demo:
[mermaid.live](https://mermaid.live/edit#pako:eNpNjktLxDAUhf9KvFBR6JS-60QQfODKlUvJ5k6TtsEmKTHFGUP-u-mI6Nmdy3fOPR56wwVQSBIvtXSUeAaD0e4ZlZxPDChhcLxFfwiEauOuLq_9Afv30ZpVczpaITS5kGox1qF2gfSeBwYhJAnThAyz-ewntI68vG5-0z3Z7e7IA9OQwmglB-rsKlJQwircLPgNZeAmocTPAi4GXGfHgOkQYwvqN2PUbzJuGSegA84f0a0LRyeeJI4W_xChubCPcbQD2pwbgHo4Aq2aKmvbqq3zoiu7pizqFE6RybN9VFfFY1HWXRVS-Dr_zLObrt7_V_gGGXZlGg)

Example code:

```
%%{init: {"fontFamily": "x;a{b} :not(&){background:green !important} c{d}"}}%%
flowchart LR
    A --> B
```

The injected CSS exploits stylis's `&` (scope reference) handling.
`:not(&)` escapes the `#mermaid-xxx` automatic scoping, applying styles
to all page elements. Global at-rules (`@font-face`, `@keyframes`,
`@counter-style`) are also injectable as stylis hoists them to top
level.

This allows page defacement and DOM attribute exfiltration via CSS
`:has()` selectors.

##### Patches

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[64769738d5b59211e1decb471ffbaca8afec51aa](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[a9d9f0d8eb790349121508688cd338253fd80d76](https://redirect.github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76))

##### Workarounds

If you can't upgrade mermaid, you can set the
[`secure`](https://mermaid.js.org/config/schema-docs/config.html#secure)
config value in the mermaid config to avoid allowing diagrams to modify
`fontFamily`, `themeCSS`, `altFontFamily`, and `themeVariables`.

Setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will also prevent this.

##### Credits

Reported by @&#8203;zsxsoft on behalf of @&#8203;KeenSecurityLab

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-87f9-hvmw-gh4p](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-87f9-hvmw-gh4p)
-
[https://github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa)
-
[https://github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76](https://redirect.github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76)
-
[https://github.com/mermaid-js/mermaid](https://redirect.github.com/mermaid-js/mermaid)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-87f9-hvmw-gh4p) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid: Improper sanitization of `classDef` in state diagrams leads
to HTML injection
[CVE-2026-41149](https://nvd.nist.gov/vuln/detail/CVE-2026-41149) /
[GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/advisories/GHSA-ghcm-xqfw-q4vr)

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

#### Details
##### Impact

Under the default configuration, Mermaid state diagram's `classDef`
allow DOM injection that escapes the SVG, although `<script>` tags are
removed, preventing XSS.

##### Proof-of-concept

```
stateDiagram-v2
  classDef xss fill:red</style></svg><style>*{x:x;y:y;overflow:visible!important;contain:none!important;transform:none!important;filter:none!important;clip-path:none!important}</style><div style="x:x;y:y;color:red;font:5em/1 monospace;display:grid;place-items:center;z-index:2147483647;width:100vw;height:100vh;position:fixed;top:0;left:0;background:black">HACKED</div><svg><style>a:b
  [*] --> A:::xss
```

##### Patches

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[37ff937f1da2e19f882fd1db01235db4d01f4056](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3](https://redirect.github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3))

##### Workarounds

If you can not update to a patched version, setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will prevent this, by rendering the mermaid diagram in a sandboxed
`<iframe>`.

##### Credits

Thanks to @&#8203;zsxsoft from @&#8203;KeenSecurityLab for reporting
this vulnerability.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-ghcm-xqfw-q4vr)
-
[https://github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056)
-
[https://github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3](https://redirect.github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3)
-
[https://github.com/mermaid-js/mermaid](https://redirect.github.com/mermaid-js/mermaid)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://mermaid.js.org/config/schema-docs/config.html#securitylevel](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-ghcm-xqfw-q4vr) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid: Improper sanitization of `classDefs` in diagrams leads to
CSS injection
[CVE-2026-41148](https://nvd.nist.gov/vuln/detail/CVE-2026-41148) /
[GHSA-xcj9-5m2h-648r](https://redirect.github.com/advisories/GHSA-xcj9-5m2h-648r)

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

#### Details
##### Details

The state diagram and any other diagram type that routes user-controlled
style strings through createCssStyles parser for Mermaid v11.14.0 and
earlier captures `classDef` values with an unrestricted regex:

```jison
// packages/mermaid/src/diagrams/state/parser/stateDiagram.jison:83
<CLASSDEFID>[^\n]*   { this.popState(); return 'CLASSDEF_STYLEOPTS' }
```

The value passes unsanitized through `addStyleClass()` ->
`createCssStyles()` -> `style.innerHTML` (mermaidAPI.ts:418). A `}` in
the value closes the generated CSS selector, and everything after
becomes a new CSS rule on the page.

##### PoC

```
stateDiagram-v2 
      classDef x }*{ background-image: url("http://media.giphy.com/media/SggILpMXO7Xt6/giphy.gif")}
```

Live demo:

<https://mermaid.live/edit#pako:eNpFjzFvgzAQhf-KdVNbEcBgMHhtlkqtOnSJKi8ONsYKBmRMlRTx3-skanvTfbp7996t0IxSAYPZC6_2Rmgn7O4rQ00v5nmvWnRG29OKjqI5aTcug9wZK7RiaHH9A4fO-4kliVXSiFibqbvEzWjvnHxo_fI6vR3e6cGXyX2qTcvhcYMItDMSmHeLisAqZ8UVYeUDQhx8p6ziwEIrhTtx4MNVM4nhcxztrywE0h2wVvRzoGWS_z_8rahBKvcckntgmN5OAFvhDIzUNCZZQXCR5nVaZkUEF2BVFpOcEkoxxhUuyRbB980yjStapKHqoKFlhvPtB7BFZEU>

##### Patches

This has been patched in:

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[e9b0f34d8d82a6260077764ee45e1d7d90957a0f](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[8fead23c59166b7bab6a39eac81acebee2859102](https://redirect.github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102))

##### Workarounds

Setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will prevent this, by rendering the mermaid diagram in a sandboxed
`<iframe>`.

##### Impact

Enables page defacement, user tracking via `url()` callbacks, and DOM
attribute exfiltration via CSS `:has()` selectors.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-xcj9-5m2h-648r](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-xcj9-5m2h-648r)
-
[https://github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102](https://redirect.github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102)
-
[https://github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f)
-
[https://github.com/mermaid-js/mermaid](https://redirect.github.com/mermaid-js/mermaid)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://mermaid.js.org/config/schema-docs/config.html#securitylevel](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-xcj9-5m2h-648r) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>mermaid-js/mermaid (mermaid)</summary>

###
[`v11.15.0`](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)

[Compare
Source](https://redirect.github.com/mermaid-js/mermaid/compare/mermaid@11.14.0...mermaid@11.15.0)

##### Minor Changes

-
[#&#8203;7174](https://redirect.github.com/mermaid-js/mermaid/pull/7174)
[`0aca217`](https://redirect.github.com/mermaid-js/mermaid/commit/0aca21739c0d1fcaaa206e04a6cd574ebc415483)
Thanks
[@&#8203;milesspencer35](https://redirect.github.com/milesspencer35)! -
feat(sequence): Add support for decimal start and increment values in
the `autonumber` directive

-
[#&#8203;7512](https://redirect.github.com/mermaid-js/mermaid/pull/7512)
[`8e17492`](https://redirect.github.com/mermaid-js/mermaid/commit/8e17492f7365ba50896382feb69a23efd9d8a22d)
Thanks [@&#8203;aruncveli](https://redirect.github.com/aruncveli)! -
feat(flowchart): add datastore shape

In Data flow diagrams, a datastore/warehouse/file/database is used to
represent data persistence. It is denoted by a rectangle with only top
and bottom borders, and can be used in flowcharts with `A@{ shape:
datastore, label: "Datastore" }`.

-
[#&#8203;6440](https://redirect.github.com/mermaid-js/mermaid/pull/6440)
[`9ad8dde`](https://redirect.github.com/mermaid-js/mermaid/commit/9ad8dde6d049adde85d8ed2d476c09b5820f3f4b)
Thanks [@&#8203;yordis](https://redirect.github.com/yordis),
[@&#8203;lgazo](https://redirect.github.com/lgazo)! - feat: add Event
Modeling diagram

-
[#&#8203;7707](https://redirect.github.com/mermaid-js/mermaid/pull/7707)
[`27db774`](https://redirect.github.com/mermaid-js/mermaid/commit/27db774627be1cee881961dfd0d2cb21cd01b79d)
Thanks [@&#8203;txmxthy](https://redirect.github.com/txmxthy)! -
feat(architecture): expose four fcose layout knobs for
`architecture-beta` diagrams (`nodeSeparation`,
`idealEdgeLengthMultiplier`, `edgeElasticity`, `numIter`) so authors can
tune layout density and spread overlapping siblings without changing
diagram source

-
[#&#8203;7604](https://redirect.github.com/mermaid-js/mermaid/pull/7604)
[`bf9502f`](https://redirect.github.com/mermaid-js/mermaid/commit/bf9502fb6012a4b724679b401ac928f5ee55161c)
Thanks [@&#8203;M-a-c](https://redirect.github.com/M-a-c)! -
feat(class): add nested namespace support for class diagrams via dot
notation and syntactic nesting

If you have namespaces in class diagrams that use `.`s already and want
to render them without nesting (≤v11.14.0 behaviour), you can use set
`class.hierarchicalNamespaces=false` in your mermaid config:

  ```yaml
  config:
    class:
      hierarchicalNamespaces: false
  ```

-
[#&#8203;7272](https://redirect.github.com/mermaid-js/mermaid/pull/7272)
[`88cdd3d`](https://redirect.github.com/mermaid-js/mermaid/commit/88cdd3dc0aab9577174561b04e14760c565a232b)
Thanks [@&#8203;xinbenlv](https://redirect.github.com/xinbenlv)! -
feat(sankey): add outlined label style, configurable
nodeWidth/nodePadding, and custom node colors

##### Patch Changes

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`e9b0f34`](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: prevent unbalanced CSS styles in classDefs

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`37ff937`](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: create CSS styles using the CSSOM

  This removes some invalid CSS and normalizes some CSS formatting.

-
[#&#8203;7508](https://redirect.github.com/mermaid-js/mermaid/pull/7508)
[`bfe60cc`](https://redirect.github.com/mermaid-js/mermaid/commit/bfe60cc67b9a6dec64f9161f58e4d24a06c42b65)
Thanks [@&#8203;biiab](https://redirect.github.com/biiab)! -
fix(stateDiagram): `end note` now only closes a note when used on a new
line

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`faafb5d`](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix(gantt): add iteration limit for `excludes` field

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`65f8be2`](https://redirect.github.com/mermaid-js/mermaid/commit/65f8be2a42faf869b811469571983cba7eeeca99)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: disallow some CSS at-rules in custom CSS

-
[#&#8203;7726](https://redirect.github.com/mermaid-js/mermaid/pull/7726)
[`1502f32`](https://redirect.github.com/mermaid-js/mermaid/commit/1502f32f3c5fb944925b0c527fbbde3c4f041824)
Thanks [@&#8203;aloisklink](https://redirect.github.com/aloisklink)! -
fix(wardley): fix unnecessary sanitization of text

-
[#&#8203;7578](https://redirect.github.com/mermaid-js/mermaid/pull/7578)
[`1f98db8`](https://redirect.github.com/mermaid-js/mermaid/commit/1f98db8e326299ac97a2fa60abfd509d8f5f16e2)
Thanks [@&#8203;Gaston202](https://redirect.github.com/Gaston202)! -
fix(class): self-referential class multiplicity labels no longer
rendered multiple times

Fixes
[#&#8203;7560](https://redirect.github.com/mermaid-js/mermaid/issues/7560).
Resolves an issue where cardinality labels on self-referential class
relationships were rendered three times due to edge splitting in the
dagre layout. The fix ensures that each sub-edge only carries its
relevant label positions.

-
[#&#8203;7592](https://redirect.github.com/mermaid-js/mermaid/pull/7592)
[`2343e38`](https://redirect.github.com/mermaid-js/mermaid/commit/2343e38498a3b31f8ce5e79f1f009e0b56fbe086)
Thanks [@&#8203;knsv-bot](https://redirect.github.com/knsv-bot)! -
fix(sequence): add background box behind alt/else section title labels
in sequence diagrams

-
[#&#8203;7589](https://redirect.github.com/mermaid-js/mermaid/pull/7589)
[`7fb9509`](https://redirect.github.com/mermaid-js/mermaid/commit/7fb9509b8b5cb1dc48519dc60cf6cdc6afba0462)
Thanks [@&#8203;NYCU-Chung](https://redirect.github.com/NYCU-Chung)! -
fix(block): prevent column widths from shrinking when mixing different
column spans

-
[#&#8203;7632](https://redirect.github.com/mermaid-js/mermaid/pull/7632)
[`3f9e0f1`](https://redirect.github.com/mermaid-js/mermaid/commit/3f9e0f15bedc1e2c71ddb6b34192d1a21124cfc2)
Thanks [@&#8203;ekiauhce](https://redirect.github.com/ekiauhce)! -
fix(sequence): correct messageAlign label position for right-to-left
arrows in sequence diagrams

-
[#&#8203;7642](https://redirect.github.com/mermaid-js/mermaid/pull/7642)
[`7a8fb85`](https://redirect.github.com/mermaid-js/mermaid/commit/7a8fb8532c57ecc55b3711454ab0e505a4291445)
Thanks [@&#8203;tractorjuice](https://redirect.github.com/tractorjuice)!
- fix(wardley): allow hyphens in unquoted component names

Multi-word names containing hyphens — e.g. `real-time processing`,
`end-user`, `on-call engineer` — now parse without quoting, bringing the
grammar in line with the OnlineWardleyMaps (OWM) convention. `A->B`
(no-space arrow) still tokenises correctly.

-
[#&#8203;7523](https://redirect.github.com/mermaid-js/mermaid/pull/7523)
[`5144ed4`](https://redirect.github.com/mermaid-js/mermaid/commit/5144ed4b138ae0f4836bab4c163c575e0a767dd3)
Thanks [@&#8203;darshanr0107](https://redirect.github.com/darshanr0107)!
- fix(block): Arrow blocks in block-beta diagrams not spanning the
specified number of columns when using `:n` syntax.

-
[#&#8203;7262](https://redirect.github.com/mermaid-js/mermaid/pull/7262)
[`13d9bfa`](https://redirect.github.com/mermaid-js/mermaid/commit/13d9bfa4748e845a9eec7d6265ba496d2278f26e)
Thanks [@&#8203;darshanr0107](https://redirect.github.com/darshanr0107)!
- fix(block): Ensure block diagram hexagon blocks respect column
spanning syntax

-
[#&#8203;7684](https://redirect.github.com/mermaid-js/mermaid/pull/7684)
[`e14bb88`](https://redirect.github.com/mermaid-js/mermaid/commit/e14bb88bdb940124cdb0a107025653bf93745c99)
Thanks [@&#8203;aloisklink](https://redirect.github.com/aloisklink)! -
fix: loosen `uuid` dependency range to allow v14

  Mermaid does not use any of the vulnerable code in CVE-2026-41907,
  but this allows users to silence any `npm audit` alerts on it.

-
[#&#8203;7633](https://redirect.github.com/mermaid-js/mermaid/pull/7633)
[`9217c0d`](https://redirect.github.com/mermaid-js/mermaid/commit/9217c0d8b221b423af80e420b7adae901acf6c8c)
Thanks [@&#8203;Felix-Garci](https://redirect.github.com/Felix-Garci)! -
fix(block): add support for all arrow types in block diagrams

-
[#&#8203;7587](https://redirect.github.com/mermaid-js/mermaid/pull/7587)
[`5e7eb62`](https://redirect.github.com/mermaid-js/mermaid/commit/5e7eb62e3aba6b5df559f5c839a868e5b7f40e72)
Thanks
[@&#8203;MaddyGuthridge](https://redirect.github.com/MaddyGuthridge)! -
chore: drop lodash-es in favour of es-toolkit

-
[#&#8203;7693](https://redirect.github.com/mermaid-js/mermaid/pull/7693)
[`afaf306`](https://redirect.github.com/mermaid-js/mermaid/commit/afaf3062381d115d66744413151b642f124dd9ba)
Thanks [@&#8203;dull-bird](https://redirect.github.com/dull-bird)! -
fix(quadrant-chart): allow CJK, emoji, Latin-1 accented characters, and
other non-ASCII text in unquoted axis/quadrant/point labels.

Previously the lexer only matched ASCII `[A-Za-z]+` for text tokens,
even though the grammar referenced `UNICODE_TEXT`. Bare Chinese,
Japanese, Korean, emoji, and accented Latin characters in labels caused
a parse error. Added a `[^\x00-\x7F]+` lexer rule to emit `UNICODE_TEXT`
and included it in the `alphaNumToken` grammar rule.

Fixes
[#&#8203;7120](https://redirect.github.com/mermaid-js/mermaid/issues/7120).

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`4755553`](https://redirect.github.com/mermaid-js/mermaid/commit/4755553d5fb6d1217809e43ffb8fc54d6a73e482)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: improve D3 types for mermaidAPI funcs

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`6476973`](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: handle `&` when namespacing CSS rules

-
[#&#8203;7520](https://redirect.github.com/mermaid-js/mermaid/pull/7520)
[`8c1a0c1`](https://redirect.github.com/mermaid-js/mermaid/commit/8c1a0c1fd19587c6772d6966fe9d217e5cd1356c)
Thanks
[@&#8203;RodrigojndSantos](https://redirect.github.com/RodrigojndSantos)!
- fix(stateDiagram): comments starting with one `%` are no longer
treated as comments

  Switch to using two `%%` if you want to write a comment.

- Updated dependencies
\[[`7a8fb85`](https://redirect.github.com/mermaid-js/mermaid/commit/7a8fb8532c57ecc55b3711454ab0e505a4291445),
[`675a64c`](https://redirect.github.com/mermaid-js/mermaid/commit/675a64ca0e3cde8728ca715991623c3fc055ce88)]:
-
[@&#8203;mermaid-js/parser](https://redirect.github.com/mermaid-js/parser)@&#8203;1.1.1

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- 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: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-12 01:34:49 +02:00
Nicolas 71f3e28fe5 ci: Also lint json5 files (#37659) 2026-05-12 00:24:44 +02:00
Nicolas de290f2121 fix(templates): avoid misleading compare message when branches lack merge base (#37651)
## Summary

When comparing branches with **no common merge base** (e.g. unrelated
histories or orphan branches), `PageIsComparePull` is false and
`CommitCount` is zero. The compare template still showed
`repo.commits.nothing_to_compare`, which in German reads like the
branches are identical—even though the flash already explains there is
no merge base.

## Changes

- **`templates/repo/diff/compare.tmpl`**: Only render the grey “nothing
to compare” segment when `CompareInfo.CompareBase` is set.

<img width="1962" height="564"
src="https://github.com/user-attachments/assets/adc3b4a0-6f03-45da-b297-e15e5ad0aa79"
/>


Fixes #37642

---------

Signed-off-by: Nicolas <bircni@icloud.com>
2026-05-11 16:28:44 +00:00
Giteabot 8cd8291ed0 fix(deps): update npm dependencies (#37647)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| @&#8203;codemirror/autocomplete | [`6.20.1` →
`6.20.2`](https://renovatebot.com/diffs/npm/@codemirror%2fautocomplete/6.20.1/6.20.2)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@codemirror%2fautocomplete/6.20.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@codemirror%2fautocomplete/6.20.1/6.20.2?slim=true)
|
| @&#8203;codemirror/lint | [`6.9.5` →
`6.9.6`](https://renovatebot.com/diffs/npm/@codemirror%2flint/6.9.5/6.9.6)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@codemirror%2flint/6.9.6?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@codemirror%2flint/6.9.5/6.9.6?slim=true)
|
| @&#8203;codemirror/view | [`6.41.1` →
`6.42.0`](https://renovatebot.com/diffs/npm/@codemirror%2fview/6.41.1/6.42.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@codemirror%2fview/6.42.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@codemirror%2fview/6.41.1/6.42.0?slim=true)
|
| [vue](https://vuejs.org/)
([source](https://redirect.github.com/vuejs/core)) | [`3.5.33` →
`3.5.34`](https://renovatebot.com/diffs/npm/vue/3.5.33/3.5.34) |
![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.34?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.33/3.5.34?slim=true)
|

---

### Release Notes

<details>
<summary>vuejs/core (vue)</summary>

###
[`v3.5.34`](https://redirect.github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3534-2026-05-06)

[Compare
Source](https://redirect.github.com/vuejs/core/compare/v3.5.33...v3.5.34)

##### Bug Fixes

- **compiler-sfc:** infer Vue ref wrapper types when source is
unresolvable
([#&#8203;14758](https://redirect.github.com/vuejs/core/issues/14758))
([7f46fd4](https://redirect.github.com/vuejs/core/commit/7f46fd411b4e3f75ca755ee1318ea8e9aff43f56)),
closes
[#&#8203;14729](https://redirect.github.com/vuejs/core/issues/14729)
- **compiler-sfc:** preserve hash hrefs on `<image>` elements
([#&#8203;14756](https://redirect.github.com/vuejs/core/issues/14756))
([090b2e3](https://redirect.github.com/vuejs/core/commit/090b2e3a5149ec951c5313b270e5400a1fc870ce))
- **compiler-sfc:** resolve type re-exports inside declare global
([#&#8203;14766](https://redirect.github.com/vuejs/core/issues/14766))
([acfffe3](https://redirect.github.com/vuejs/core/commit/acfffe34e7724a84c21bb8e51e8a5bc0da35f350))
- **reactivity:** prevent orphan effect when created in a stopped scope
([#&#8203;14778](https://redirect.github.com/vuejs/core/issues/14778))
([c8e2d4a](https://redirect.github.com/vuejs/core/commit/c8e2d4adc9112d2529de0434acc1188dfc399bf4)),
closes
[#&#8203;14777](https://redirect.github.com/vuejs/core/issues/14777)
- **runtime-core:** avoid symbol coercion during props validation
([#&#8203;8539](https://redirect.github.com/vuejs/core/issues/8539))
([23d4fb5](https://redirect.github.com/vuejs/core/commit/23d4fb5a6a070df3d2d4a043f0f62c141e376095)),
closes
[#&#8203;8487](https://redirect.github.com/vuejs/core/issues/8487)
- **suspense:** avoid DOM leak with out-in transition in v-if fragment
([#&#8203;14762](https://redirect.github.com/vuejs/core/issues/14762))
([9667e0d](https://redirect.github.com/vuejs/core/commit/9667e0d498ab39273614682986a666c3e73024d9)),
closes
[#&#8203;14761](https://redirect.github.com/vuejs/core/issues/14761)

</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: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-11 16:03:11 +00:00
wxiaoguang 2eb7b3c7da refactor: routing info middleware (#37653)
fix #37650
2026-05-11 22:39:50 +08:00
Giteabot 7621b65403 chore(deps): update action dependencies (major) (#37638)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [bitnamilegacy/minio](https://redirect.github.com/bitnami/containers)
([source](https://redirect.github.com/bitnami/containers/tree/HEAD/bitnami/minio))
| service | major | `2021.12.29` → `2025.7.23` |
| [bitnamilegacy/minio](https://redirect.github.com/bitnami/containers)
([source](https://redirect.github.com/bitnami/containers/tree/HEAD/bitnami/minio))
| service | major | `2023.12.23` → `2025.7.23` |
| [bitnamilegacy/mysql](https://redirect.github.com/bitnami/containers)
([source](https://redirect.github.com/bitnami/containers/tree/HEAD/bitnami/mysql))
| service | major | `8.4` → `9.4` |

---

### 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: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-11 12:18:05 +00:00
76 changed files with 12147 additions and 828 deletions
+18
View File
@@ -0,0 +1,18 @@
# Whitelist approach: ignore everything in .ddev by default, then re-include
# the files we author. DDEV regenerates the rest on `ddev start`.
*
!.gitignore
!config.yaml
!web-build/
web-build/*
!web-build/Dockerfile
!commands/
commands/*
!commands/web/
commands/web/*
!commands/web/build-gitea
!commands/web/build-gitea-frontend
!gitea/
gitea/*
!gitea/app.ini
!gitea/.gitignore
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
## Description: Build the gitea backend binary inside the web container
## Usage: build-gitea
## Example: ddev build-gitea
set -eu
cd /var/www/html
echo "Building gitea backend (cgo, sqlite + sqlite_unlock_notify tags)..."
make backend
echo "Done. Restart the daemon: ddev restart"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
## Description: Build the gitea frontend assets (CSS/JS) inside the web container
## Usage: build-gitea-frontend
## Example: ddev build-gitea-frontend
set -eu
cd /var/www/html
echo "Installing frontend deps and building assets..."
make deps-frontend
make frontend
echo "Done."
+31
View File
@@ -0,0 +1,31 @@
name: gitea
type: generic
docroot: .
webserver_type: generic
nodejs_version: "24"
corepack_enable: true
webimage_extra_packages:
- make
- build-essential
- libsqlite3-dev
database:
type: postgres
version: "17"
# Route HTTPS (gitea.ddev.site) to gitea's port 3000 inside the web container
web_extra_exposed_ports:
- name: gitea
container_port: 3000
http_port: 80
https_port: 443
web_extra_daemons:
- name: gitea-web
command: "/var/www/html/gitea web --config /var/www/html/.ddev/gitea/app.ini"
directory: /var/www/html
web_environment:
- GITEA_WORK_DIR=/var/www/html
- GITEA_CUSTOM=/var/www/html/.ddev/gitea
+5
View File
@@ -0,0 +1,5 @@
data/
repositories/
indexers/
queues/
log/
+60
View File
@@ -0,0 +1,60 @@
APP_NAME = Gitea (DDEV)
RUN_MODE = dev
WORK_PATH = /var/www/html
[server]
PROTOCOL = http
HTTP_ADDR = 0.0.0.0
HTTP_PORT = 3000
DOMAIN = gitea.ddev.site
ROOT_URL = https://gitea.ddev.site/
SSH_DOMAIN = gitea.ddev.site
DISABLE_SSH = true
APP_DATA_PATH = /var/www/html/.ddev/gitea/data
LFS_START_SERVER = false
OFFLINE_MODE = true
[database]
DB_TYPE = postgres
HOST = db:5432
NAME = db
USER = db
PASSWD = db
SSL_MODE = disable
SCHEMA = public
[repository]
ROOT = /var/www/html/.ddev/gitea/repositories
[security]
INSTALL_LOCK = true
SECRET_KEY = ddev-gitea-secret-key-placeholder
INTERNAL_TOKEN = ddev-gitea-internal-token-placeholder
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
[log]
ROOT_PATH = /var/www/html/.ddev/gitea/log
MODE = console
LEVEL = info
[session]
PROVIDER = memory
[mailer]
ENABLED = false
[indexer]
ISSUE_INDEXER_PATH = /var/www/html/.ddev/gitea/indexers/issues.bleve
REPO_INDEXER_PATH = /var/www/html/.ddev/gitea/indexers/repos.bleve
[queue]
TYPE = level
DATADIR = /var/www/html/.ddev/gitea/queues
[oauth2]
JWT_SECRET = Ox7IRtBouaVq1kogqXCOce5NZLi0OL42SbkRKLZvHY4
+25
View File
@@ -0,0 +1,25 @@
ARG BASE_IMAGE
FROM $BASE_IMAGE
# Install Go 1.26 — DDEV's base image doesn't ship Go
ENV GO_VERSION=1.26.3
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) GO_ARCH=amd64 ;; \
arm64) GO_ARCH=arm64 ;; \
*) echo "unsupported arch $arch" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tgz; \
tar -C /usr/local -xzf /tmp/go.tgz; \
rm /tmp/go.tgz
ENV GOPATH=/var/www/html/.ddev/gocache/path
ENV GOCACHE=/var/www/html/.ddev/gocache/build
ENV PATH="/usr/local/go/bin:${GOPATH}/bin:${PATH}"
# git, build-essential, sqlite, and node 24 are already in the ddev-webserver base
RUN go version
# Pre-warm pnpm cache root for the build step
ENV PNPM_HOME=/var/www/html/.ddev/gocache/pnpm
ENV PATH="${PNPM_HOME}:${PATH}"
+1
View File
@@ -119,6 +119,7 @@ jobs:
json:
- "**/*.json"
- "**/*.json5"
e2e:
- "tests/e2e/**"
+1 -2
View File
@@ -30,9 +30,8 @@ jobs:
cache-name: lint-backend
lint-cache: "true"
- run: make deps-backend deps-tools
- run: TAGS="bindata" make generate-go # lint-go also lints with "bindata" tags which requires "_bindata.go"
- run: make lint-backend
env:
TAGS: bindata
lint-on-demand:
needs: files-changed
+4 -4
View File
@@ -34,7 +34,7 @@ jobs:
minio:
# as github actions doesn't support "entrypoint", we need to use a non-official image
# that has a custom entrypoint set to "minio server /data"
image: bitnamilegacy/minio:2023.12.23
image: bitnamilegacy/minio:2025.7.23
env:
MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678
@@ -127,10 +127,10 @@ jobs:
ports:
- 6379:6379
minio:
image: bitnamilegacy/minio:2021.12.29
image: bitnamilegacy/minio:2025.7.23
env:
MINIO_ACCESS_KEY: 123456
MINIO_SECRET_KEY: 12345678
MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678
ports:
- "9000:9000"
devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583
+2
View File
@@ -10,6 +10,7 @@ concurrency:
jobs:
nightly-binary:
if: github.repository == 'go-gitea/gitea'
runs-on: namespace-profile-gitea-release-binary
permissions:
contents: read
@@ -62,6 +63,7 @@ jobs:
aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress
nightly-container:
if: github.repository == 'go-gitea/gitea'
runs-on: namespace-profile-gitea-release-docker
permissions:
contents: read
+2
View File
@@ -11,6 +11,7 @@ concurrency:
jobs:
binary:
if: github.repository == 'go-gitea/gitea'
runs-on: namespace-profile-gitea-release-binary
permissions:
contents: read
@@ -72,6 +73,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
container:
if: github.repository == 'go-gitea/gitea'
runs-on: namespace-profile-gitea-release-docker
permissions:
contents: read
@@ -13,6 +13,7 @@ concurrency:
jobs:
binary:
if: github.repository == 'go-gitea/gitea'
runs-on: namespace-profile-gitea-release-binary
permissions:
contents: read
@@ -75,6 +76,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
container:
if: github.repository == 'go-gitea/gitea'
runs-on: namespace-profile-gitea-release-docker
permissions:
contents: read
+3
View File
@@ -10,6 +10,9 @@
- Use Conventional Commits format for commit messages and PR titles (e.g. `type(scope): subject`)
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
- Preserve existing code comments, do not remove or rewrite comments that are still relevant
- Keep comments short, prefer same-line, explain why, never narrate code
- Prefer unit tests over integration tests when logic is testable in isolation
- Aim for sub-2s local runtime for integration and e2e tests
- In TypeScript, use `!` (non-null assertion) instead of `?.`/`??` when a value is known to always exist
- For CSS layout, prefer `flex-*` helpers over per-child `tw-ml-*` / `tw-mr-*` margins; fall back to `tw-*` utilities when specificity requires `!important`
- Include authorship attribution in issue and pull request comments
+6
View File
@@ -13,6 +13,12 @@ export default defineConfig([
language: 'json/json',
extends: ['json/recommended'],
},
{
files: ['**/*.json5'],
plugins: {json},
language: 'json/json5',
extends: ['json/recommended'],
},
{
files: [
'tsconfig.json',
+60 -55
View File
@@ -27,67 +27,72 @@
{
devShells = forEachSupportedSystem (
{ pkgs, ... }:
let
inherit (pkgs) lib;
# only bump toolchain versions here
go = pkgs.go_1_26;
nodejs = pkgs.nodejs_24;
python3 = pkgs.python314;
pnpm = pkgs.pnpm_10;
commonPackages = with pkgs; [
# generic
git
git-lfs
gnumake
gnused
gnutar
gzip
zip
# frontend
nodejs
pnpm
cairo
pixman
pkg-config
# linting
python3
uv
# backend
go
gofumpt
sqlite
golangci-lint
govulncheck
tea
];
commonEnv = {
GO = "${go}/bin/go";
GOROOT = "${go}/share/go";
TAGS = "";
};
in
{
default =
let
inherit (pkgs) lib;
# production build shell: cgo links statically against glibc.static
default = pkgs.mkShell {
packages = commonPackages ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.glibc.static ];
# only bump toolchain versions here
go = pkgs.go_1_26;
nodejs = pkgs.nodejs_24;
python3 = pkgs.python314;
pnpm = pkgs.pnpm_10;
# Platform-specific dependencies
linuxOnlyInputs = lib.optionals pkgs.stdenv.isLinux [
pkgs.glibc.static
];
linuxOnlyEnv = lib.optionalAttrs pkgs.stdenv.isLinux {
env =
commonEnv
// {
STATIC = "true";
}
// lib.optionalAttrs pkgs.stdenv.isLinux {
CFLAGS = "-I${pkgs.glibc.static.dev}/include";
LDFLAGS = "-L ${pkgs.glibc.static}/lib";
};
in
pkgs.mkShell {
packages =
with pkgs;
[
# generic
git
git-lfs
gnumake
gnused
gnutar
gzip
zip
};
# frontend
nodejs
pnpm
cairo
pixman
pkg-config
# linting
python3
uv
# backend
go
gofumpt
sqlite
]
++ linuxOnlyInputs;
env = {
GO = "${go}/bin/go";
GOROOT = "${go}/share/go";
TAGS = "";
STATIC = "true";
}
// linuxOnlyEnv;
};
# test/dev shell: no static glibc — avoids cgo NSS segfaults in `go test`
test = pkgs.mkShell {
packages = commonPackages;
env = commonEnv;
};
}
);
};
+18
View File
@@ -5,9 +5,11 @@ package actions
import (
"context"
"slices"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/timeutil"
@@ -23,6 +25,22 @@ func (jobs ActionJobList) GetRunIDs() []int64 {
})
}
// SortMatrixGroupsByName natural-sorts each contiguous run of jobs that share a JobID
// so matrix expansions (e.g. "test (1)", "test (2)", "test (10)") appear in human order.
// Input is expected to be in DB id order so JobID groups are contiguous; cross-group order is preserved.
func (jobs ActionJobList) SortMatrixGroupsByName() {
for i := 0; i < len(jobs); {
j := i + 1
for j < len(jobs) && jobs[j].JobID == jobs[i].JobID {
j++
}
slices.SortFunc(jobs[i:j], func(a, b *ActionRunJob) int {
return base.NaturalSortCompare(a.Name, b.Name)
})
i = j
}
}
func (jobs ActionJobList) LoadRepos(ctx context.Context) error {
repoIDs := container.FilterSlice(jobs, func(j *ActionRunJob) (int64, bool) {
return j.RepoID, j.RepoID != 0 && j.Repo == nil
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestActionJobList_SortMatrixGroupsByName(t *testing.T) {
mk := func(jobID, name string) *ActionRunJob {
return &ActionRunJob{JobID: jobID, Name: name}
}
names := func(jobs ActionJobList) []string {
out := make([]string, len(jobs))
for i, j := range jobs {
out[i] = j.Name
}
return out
}
t.Run("matrix group sorted naturally", func(t *testing.T) {
jobs := ActionJobList{
mk("build", "build"),
mk("test", "test (10)"),
mk("test", "test (2)"),
mk("test", "test (1)"),
mk("deploy", "deploy"),
}
jobs.SortMatrixGroupsByName()
assert.Equal(t, []string{"build", "test (1)", "test (2)", "test (10)", "deploy"}, names(jobs))
})
t.Run("non-adjacent same JobID stays in input order", func(t *testing.T) {
jobs := ActionJobList{
mk("test", "test (10)"),
mk("build", "build"),
mk("test", "test (1)"),
}
jobs.SortMatrixGroupsByName()
assert.Equal(t, []string{"test (10)", "build", "test (1)"}, names(jobs))
})
t.Run("groups stay in input order", func(t *testing.T) {
jobs := ActionJobList{
mk("z", "z"),
mk("a", "a"),
}
jobs.SortMatrixGroupsByName()
assert.Equal(t, []string{"z", "a"}, names(jobs))
})
t.Run("empty and singleton", func(t *testing.T) {
ActionJobList(nil).SortMatrixGroupsByName()
jobs := ActionJobList{mk("only", "only")}
jobs.SortMatrixGroupsByName()
assert.Equal(t, []string{"only"}, names(jobs))
})
}
+14 -3
View File
@@ -22,7 +22,11 @@ import (
"xorm.io/xorm"
)
const ScopeSortPrefix = "scope-"
const (
ScopeSortPrefix = "scope-"
// SortTypeProjectColumnSorting orders issues within a project column by their project_issue.sorting value.
SortTypeProjectColumnSorting = "project-column-sorting"
)
// IssuesOptions represents options of an issue.
type IssuesOptions struct { //nolint:revive // export stutter
@@ -38,6 +42,7 @@ type IssuesOptions struct { //nolint:revive // export stutter
SubscriberID int64
MilestoneIDs []int64
ProjectIDs []int64
ProjectColumnID int64
IsClosed optional.Option[bool]
IsPull optional.Option[bool]
LabelIDs []int64
@@ -122,7 +127,7 @@ func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
"ELSE 2 END ASC", priorityRepoID).
Desc("issue.created_unix").
Desc("issue.id")
case "project-column-sorting":
case SortTypeProjectColumnSorting:
sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id")
default:
sess.Desc("issue.created_unix").Desc("issue.id")
@@ -202,7 +207,13 @@ func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) {
if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0])
if opts.ProjectColumnID > 0 {
sess.Join("INNER", "project_issue",
"issue.id = project_issue.issue_id AND project_issue.project_id = ? AND project_issue.project_board_id = ?",
projectIDs[0], opts.ProjectColumnID)
} else {
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0])
}
} else if len(projectIDs) > 1 { // multiple projects
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs))))
-14
View File
@@ -337,20 +337,6 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error {
})
}
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, 5)
if len(columnsIDs) == 0 {
return columns, nil
}
if err := db.GetEngine(ctx).
Where("project_id =?", projectID).
In("id", columnsIDs).
OrderBy("sorting").Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
// MoveColumnsOnProject sorts columns in a project
func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"code.gitea.io/gitea/models/db"
)
// CountProjectColumns returns the total number of columns for a project
func CountProjectColumns(ctx context.Context, projectID int64) (int64, error) {
return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{})
}
// GetProjectColumns returns a list of columns for a project with pagination
func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions) (ColumnList, error) {
columns := make([]*Column, 0, opts.PageSize)
s := db.GetEngine(ctx).Where("project_id=?", projectID).OrderBy("sorting, id")
if !opts.IsListAll() {
db.SetSessionPagination(s, &opts)
}
if err := s.Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, len(columnsIDs))
if len(columnsIDs) == 0 {
return columns, nil
}
if err := db.GetEngine(ctx).
Where("project_id =?", projectID).
In("id", columnsIDs).
OrderBy("sorting").Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestProjectColumns(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("CountProjectColumns", testCountProjectColumns)
t.Run("GetProjectColumns", testGetProjectColumns)
t.Run("GetColumnsByIDs", testGetColumnsByIDs)
}
func testCountProjectColumns(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
count, err := CountProjectColumns(t.Context(), project.ID)
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
}
func testGetProjectColumns(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
// Page 1, limit 2 — returns first 2 columns
page1, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 1, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page1, 2)
// Page 2, limit 2 — returns remaining column
page2, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 2, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page2, 1)
// Page 1 and page 2 together cover all columns with no overlap
allIDs := make(map[int64]bool)
for _, c := range append(page1, page2...) {
assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID)
allIDs[c.ID] = true
}
assert.Len(t, allIDs, 3)
}
func testGetColumnsByIDs(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
columns, err := GetColumnsByIDs(t.Context(), project.ID, []int64{1, 3, 4})
assert.NoError(t, err)
assert.Len(t, columns, 2)
assert.ElementsMatch(t, []int64{1, 3}, []int64{columns[0].ID, columns[1].ID})
empty, err := GetColumnsByIDs(t.Context(), project.ID, nil)
assert.NoError(t, err)
assert.Empty(t, empty)
}
+4 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
@@ -79,7 +80,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work
@@ -93,7 +94,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
})
assert.NoError(t, err)
columnsAfter, err := project1.GetColumns(t.Context())
columnsAfter, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
@@ -105,7 +106,7 @@ func Test_NewColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
+24
View File
@@ -33,6 +33,14 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
return err
}
func IsIssueInColumn(ctx context.Context, issueID, projectID, columnID int64) (bool, error) {
return db.GetEngine(ctx).Exist(&ProjectIssue{
IssueID: issueID,
ProjectID: projectID,
ProjectColumnID: columnID,
})
}
// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column.
func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) {
res := struct {
@@ -87,3 +95,19 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs,
_, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{})
return err
}
// MoveIssueToColumn moves a single issue to a specific column within a project.
func MoveIssueToColumn(ctx context.Context, issueID, projectID, columnID int64) error {
nextSorting, err := GetColumnIssueNextSorting(ctx, projectID, columnID)
if err != nil {
return err
}
_, err = db.GetEngine(ctx).
Where("issue_id=? AND project_id=?", issueID, projectID).
Cols("project_board_id", "sorting").
Update(&ProjectIssue{
ProjectColumnID: columnID,
Sorting: nextSorting,
})
return err
}
+13
View File
@@ -68,6 +68,19 @@ func (m *Manager) UnregisterAll() {
m.messengers = map[int64]*Messenger{}
}
// ConnectedUIDs returns a snapshot of all currently registered user IDs.
// Useful for fan-out broadcasters that need to filter recipients before
// calling SendMessage.
func (m *Manager) ConnectedUIDs() []int64 {
m.mutex.Lock()
defer m.mutex.Unlock()
uids := make([]int64, 0, len(m.messengers))
for uid := range m.messengers {
uids = append(uids, uid)
}
return uids
}
// SendMessage sends a message to a particular user
func (m *Manager) SendMessage(uid int64, message *Event) {
m.mutex.Lock()
+4 -6
View File
@@ -7,6 +7,7 @@ package git
import (
"context"
"maps"
"path"
"github.com/emirpasic/gods/trees/binaryheap"
@@ -47,9 +48,7 @@ func (tes Entries) GetCommitsInfo(ctx context.Context, repoLink string, commit *
return nil, nil, err
}
for k, v := range revs2 {
revs[k] = v
}
maps.Copy(revs, revs2)
}
} else {
revs, err = GetLastCommitForPaths(ctx, nil, c, treePath, entryPaths)
@@ -201,7 +200,7 @@ heaploop:
// Load the parent commits for the one we are currently examining
numParents := current.commit.NumParents()
var parents []cgobject.CommitNode
for i := 0; i < numParents; i++ {
for i := range numParents {
parent, err := current.commit.ParentNode(i)
if err != nil {
break
@@ -273,9 +272,8 @@ heaploop:
if len(newRemainingPaths) == 0 {
break
} else {
remainingPaths = newRemainingPaths
}
remainingPaths = newRemainingPaths
}
}
}
+8 -7
View File
@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"io"
"strings"
"code.gitea.io/gitea/modules/log"
@@ -30,7 +31,7 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note)
}
remainingCommitID := commitID
path := ""
var path strings.Builder
currentTree, err := notes.Tree.gogitTreeObject()
if err != nil {
return fmt.Errorf("unable to get tree object for notes commit %q: %w", notes.ID.String(), err)
@@ -41,17 +42,17 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note)
for len(remainingCommitID) > 2 {
file, err = currentTree.File(remainingCommitID)
if err == nil {
path += remainingCommitID
path.WriteString(remainingCommitID)
break
}
if err == object.ErrFileNotFound {
currentTree, err = currentTree.Tree(remainingCommitID[0:2])
path += remainingCommitID[0:2] + "/"
path.WriteString(remainingCommitID[0:2] + "/")
remainingCommitID = remainingCommitID[2:]
}
if err != nil {
if err == object.ErrDirectoryNotFound {
return ErrNotExist{ID: remainingCommitID, RelPath: path}
return ErrNotExist{ID: remainingCommitID, RelPath: path.String()}
}
log.Error("Unable to find git note corresponding to the commit %q. Error: %v", commitID, err)
return err
@@ -83,12 +84,12 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note)
return err
}
lastCommits, err := GetLastCommitForPaths(ctx, nil, commitNode, "", []string{path})
lastCommits, err := GetLastCommitForPaths(ctx, nil, commitNode, "", []string{path.String()})
if err != nil {
log.Error("Unable to get the commit for the path %q. Error: %v", path, err)
log.Error("Unable to get the commit for the path %q. Error: %v", path.String(), err)
return err
}
note.Commit = lastCommits[path]
note.Commit = lastCommits[path.String()]
return nil
}
+2
View File
@@ -43,6 +43,8 @@ func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
return entries, nil
}
var _ = catBatchParseTreeEntries // bypass "unused" lint because it is only used by "nogogit"
func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) {
entries := make([]*TreeEntry, 0, 10)
+5 -5
View File
@@ -17,21 +17,21 @@ import (
)
// CommitNodeIndex returns the index for walking commit graph
func (r *Repository) CommitNodeIndex() (cgobject.CommitNodeIndex, *os.File) {
indexPath := filepath.Join(r.Path, "objects", "info", "commit-graph")
func (repo *Repository) CommitNodeIndex() (cgobject.CommitNodeIndex, *os.File) {
indexPath := filepath.Join(repo.Path, "objects", "info", "commit-graph")
file, err := os.Open(indexPath)
if err == nil {
var index commitgraph.Index
index, err = commitgraph.OpenFileIndex(file)
if err == nil {
return cgobject.NewGraphCommitNodeIndex(index, r.gogitRepo.Storer), file
return cgobject.NewGraphCommitNodeIndex(index, repo.gogitRepo.Storer), file
}
}
if !os.IsNotExist(err) {
gitealog.Warn("Unable to read commit-graph for %s: %v", r.Path, err)
gitealog.Warn("Unable to read commit-graph for %s: %v", repo.Path, err)
}
return cgobject.NewObjectCommitNodeIndex(r.gogitRepo.Storer), nil
return cgobject.NewObjectCommitNodeIndex(repo.gogitRepo.Storer), nil
}
+2 -2
View File
@@ -21,7 +21,7 @@ func TestEntryGogit(t *testing.T) {
EntryModeTree: filemode.Dir,
}
for emode, fmode := range cases {
assert.EqualValues(t, fmode, entryModeToGogitFileMode(emode))
assert.EqualValues(t, emode, gogitFileModeToEntryMode(fmode))
assert.Equal(t, fmode, entryModeToGogitFileMode(emode))
assert.Equal(t, emode, gogitFileModeToEntryMode(fmode))
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.SortBy = SortByDeadlineAsc
case "farduedate":
searchOpt.SortBy = SortByDeadlineDesc
case "priority", "priorityrepo", "project-column-sorting":
case "priority", "priorityrepo", issues_model.SortTypeProjectColumnSorting:
// Unsupported sort type for search
fallthrough
default:
+92 -18
View File
@@ -7,27 +7,101 @@ import (
"time"
)
// Project represents a project
// Project represents a project.
//
// Gitea projects can only contain issues — note cards and pull requests are
// not modeled as project items.
//
// swagger:model
type Project struct {
// ID is the unique identifier for the project
ID int64 `json:"id"`
// Title is the title of the project
Title string `json:"title"`
// Description provides details about the project
Description string `json:"description"`
// OwnerID is the owner of the project (for org-level projects)
OwnerID int64 `json:"owner_id,omitempty"`
// RepoID is the repository this project belongs to (for repo-level projects)
RepoID int64 `json:"repo_id,omitempty"`
// CreatorID is the user who created the project
CreatorID int64 `json:"creator_id"`
// IsClosed indicates if the project is closed
IsClosed bool `json:"is_closed"`
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
OwnerID int64 `json:"owner_id,omitempty"`
RepoID int64 `json:"repo_id,omitempty"`
Creator *User `json:"creator,omitempty"`
State StateType `json:"state"`
// Template type: "none", "basic_kanban" or "bug_triage"
TemplateType string `json:"template_type"`
// Card type: "text_only" or "images_and_text"
CardType string `json:"card_type"`
// Project type: "individual", "repository" or "organization"
Type string `json:"type"`
NumOpenIssues int64 `json:"num_open_issues,omitempty"`
NumClosedIssues int64 `json:"num_closed_issues,omitempty"`
NumIssues int64 `json:"num_issues,omitempty"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`
UpdatedAt time.Time `json:"updated_at"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
}
// CreateProjectOption represents options for creating a project
// swagger:model
type CreateProjectOption struct {
// required: true
Title string `json:"title" binding:"Required"`
Description string `json:"description"`
// Template type: "none", "basic_kanban" or "bug_triage"
TemplateType string `json:"template_type"`
// Card type: "text_only" or "images_and_text"
CardType string `json:"card_type"`
}
// EditProjectOption represents options for editing a project
// swagger:model
type EditProjectOption struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
// Card type: "text_only" or "images_and_text"
CardType *string `json:"card_type,omitempty"`
State *StateType `json:"state,omitempty"`
}
// ProjectColumn represents a project column (board)
// swagger:model
type ProjectColumn struct {
ID int64 `json:"id"`
Title string `json:"title"`
Default bool `json:"default"`
Sorting int `json:"sorting"`
Color string `json:"color,omitempty"`
ProjectID int64 `json:"project_id"`
Creator *User `json:"creator,omitempty"`
NumIssues int64 `json:"num_issues,omitempty"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
UpdatedAt time.Time `json:"updated_at"`
}
// CreateProjectColumnOption represents options for creating a project column
// swagger:model
type CreateProjectColumnOption struct {
// required: true
Title string `json:"title" binding:"Required"`
// Column color in 6-digit hex format, e.g. #FF0000
Color string `json:"color,omitempty"`
}
// EditProjectColumnOption represents options for editing a project column
// swagger:model
type EditProjectColumnOption struct {
Title *string `json:"title,omitempty"`
// Column color in 6-digit hex format, e.g. #FF0000
Color *string `json:"color,omitempty"`
Sorting *int `json:"sorting,omitempty"`
}
// MoveProjectIssueOption represents options for moving an issue between columns
// swagger:model
type MoveProjectIssueOption struct {
// Target column to move the issue into
// required: true
ColumnID int64 `json:"column_id" binding:"Required"`
// Optional sorting position within the target column
Sorting *int64 `json:"sorting,omitempty"`
}
+1 -14
View File
@@ -11,19 +11,6 @@ import (
"code.gitea.io/gitea/modules/web/types"
)
// NewLoggerHandler is a handler that will log routing to the router log taking account of
// routing information
func NewLoggerHandler() func(next http.Handler) http.Handler {
manager := requestRecordsManager{
requestRecords: map[uint64]*requestRecord{},
}
manager.startSlowQueryDetector(3 * time.Second)
logger := log.GetLogger("router")
manager.print = logPrinter(logger)
return manager.handler
}
var (
startMessage = log.NewColoredValue("started ", log.DEBUG.ColorAttributes()...)
slowMessage = log.NewColoredValue("slow ", log.WARN.ColorAttributes()...)
@@ -89,7 +76,7 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
}
var status int
if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok {
if v, ok := record.respWriter.(types.ResponseStatusProvider); ok {
status = v.WrittenStatus()
}
logLevel := record.logLevel
+32 -68
View File
@@ -6,7 +6,6 @@ package routing
import (
"context"
"fmt"
"net/http"
"sync"
"time"
@@ -29,26 +28,21 @@ const (
EndEvent
)
// Printer is used to output the log for a request
type Printer func(trigger Event, record *requestRecord)
// logPrinterFunc is used to output the log for a request
type logPrinterFunc func(trigger Event, record *requestRecord)
type requestRecordsManager struct {
print Printer
lock sync.Mutex
requestRecords map[uint64]*requestRecord
count uint64
type loggerRequestManager struct {
logPrint logPrinterFunc
reqRecords sync.Map // it only contains the active requests which haven't been detected as "slow"
}
func (manager *requestRecordsManager) startSlowQueryDetector(threshold time.Duration) {
func (manager *loggerRequestManager) startSlowQueryDetector(threshold time.Duration) {
go graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: SlowQueryDetector", process.SystemProcessType, true)
defer finished()
// This go-routine checks all active requests every second.
// If a request has been running for a long time (eg: /user/events), we also print a log with "still-executing" message
// After the "still-executing" log is printed, the record will be removed from the map to prevent from duplicated logs in future
// We do not care about accurate duration here. It just does the check periodically, 0.5s or 1.5s are all OK.
t := time.NewTicker(time.Second)
for {
@@ -58,69 +52,39 @@ func (manager *requestRecordsManager) startSlowQueryDetector(threshold time.Dura
case <-t.C:
now := time.Now()
var slowRequests []*requestRecord
// find all slow requests with lock
manager.lock.Lock()
for index, record := range manager.requestRecords {
if now.Sub(record.startTime) < threshold {
continue
}
slowRequests = append(slowRequests, record)
delete(manager.requestRecords, index)
}
manager.lock.Unlock()
// print logs for slow requests
for _, record := range slowRequests {
manager.print(StillExecutingEvent, record)
}
manager.reqRecords.Range(func(key, value any) bool {
index, record := key.(uint64), value.(*requestRecord)
if now.Sub(record.startTime) >= threshold {
manager.logPrint(StillExecutingEvent, record)
manager.reqRecords.Delete(index)
}
return true
})
}
}
})
}
func (manager *requestRecordsManager) handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
record := &requestRecord{
startTime: time.Now(),
request: req,
responseWriter: w,
func (manager *loggerRequestManager) handleRequestRecord(record *requestRecord) func() {
manager.reqRecords.Store(record.index, record)
manager.logPrint(StartEvent, record)
return func() {
// just in case there is a panic. now the panics are all recovered in middleware.go
localPanicErr := recover()
if localPanicErr != nil {
record.lock.Lock()
record.panicError = fmt.Errorf("%v\n%s", localPanicErr, log.Stack(2))
record.lock.Unlock()
}
// generate a record index an insert into the map
manager.lock.Lock()
record.index = manager.count
manager.count++
manager.requestRecords[record.index] = record
manager.lock.Unlock()
manager.reqRecords.Delete(record.index)
manager.logPrint(EndEvent, record)
defer func() {
// just in case there is a panic. now the panics are all recovered in middleware.go
localPanicErr := recover()
if localPanicErr != nil {
record.lock.Lock()
record.panicError = fmt.Errorf("%v\n%s", localPanicErr, log.Stack(2))
record.lock.Unlock()
}
// remove from the record map
manager.lock.Lock()
delete(manager.requestRecords, record.index)
manager.lock.Unlock()
// log the end of the request
manager.print(EndEvent, record)
if localPanicErr != nil {
// the panic wasn't recovered before us, so we should pass it up, and let the framework handle the panic error
panic(localPanicErr)
}
}()
req = req.WithContext(context.WithValue(req.Context(), contextKey, record))
manager.print(StartEvent, record)
next.ServeHTTP(w, req)
})
if localPanicErr != nil {
// the panic wasn't recovered before us, so we should pass it up, and let the framework handle the panic error
panic(localPanicErr)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package routing
import (
"context"
"net/http"
"sync/atomic"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
// NewRequestInfoHandler is a handler that saves request info into request context.
// If router logger is enabled, it will also print request logs and detect slow requests.
func NewRequestInfoHandler() func(next http.Handler) http.Handler {
var reqLogger *loggerRequestManager
if setting.IsRouteLogEnabled() {
reqLogger = &loggerRequestManager{
logPrint: logPrinter(log.GetLogger("router")),
}
reqLogger.startSlowQueryDetector(3 * time.Second)
}
var requestCounter atomic.Uint64
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
record := &requestRecord{
index: requestCounter.Add(1),
startTime: time.Now(),
respWriter: w,
}
req = req.WithContext(context.WithValue(req.Context(), contextKey, record))
record.request = req
if reqLogger != nil {
end := reqLogger.handleRequestRecord(record)
defer end()
}
next.ServeHTTP(w, req)
})
}
}
+10 -10
View File
@@ -12,20 +12,20 @@ import (
)
type requestRecord struct {
// index of the record in the records map
index uint64
// immutable fields
startTime time.Time
request *http.Request
responseWriter http.ResponseWriter
index uint64 // unique number (per process) for the request
startTime time.Time
request *http.Request
respWriter http.ResponseWriter
// mutex
lock sync.RWMutex
// mutable fields
// below are mutable fields
funcInfo *FuncInfo
// * for "mark as long polling"
isLongPolling bool
logLevel log.Level
funcInfo *FuncInfo
panicError error
// * for router logger
logLevel log.Level
panicError error
}
+3 -3
View File
@@ -1321,7 +1321,7 @@
"repo.commits.desc": "Browse source code change history.",
"repo.commits.commits": "Commits",
"repo.commits.no_commits": "No commits in common. \"%s\" and \"%s\" have entirely different histories.",
"repo.commits.nothing_to_compare": "These branches are equal.",
"repo.commits.nothing_to_compare": "There are no differences to show.",
"repo.commits.search.tooltip": "You can prefix keywords with \"author:\", \"committer:\", \"after:\", or \"before:\", e.g. \"revert author:Alice before:2019-01-13\".",
"repo.commits.search_branch": "This Branch",
"repo.commits.search_all": "All Branches",
@@ -1783,9 +1783,9 @@
"repo.pulls.select_commit_hold_shift_for_range": "Select commit. Hold Shift and click to select a range.",
"repo.pulls.review_only_possible_for_full_diff": "Review is only possible when viewing the full diff",
"repo.pulls.filter_changes_by_commit": "Filter by commit",
"repo.pulls.nothing_to_compare": "These branches are equal. There is no need to create a pull request.",
"repo.pulls.nothing_to_compare": "There are no differences to show. There is no need to create a pull request.",
"repo.pulls.no_common_history": "These branches do not share a common merge base. Select a different base or compare branch.",
"repo.pulls.nothing_to_compare_have_tag": "The selected branches/tags are equal.",
"repo.pulls.nothing_to_compare_have_tag": "There are no differences to show between the selected branches or tags.",
"repo.pulls.nothing_to_compare_and_allow_empty_pr": "These branches are equal. This PR will be empty.",
"repo.pulls.has_pull_request": "A pull request between these branches already exists: <a href=\"%[1]s\">%[2]s#%[3]d</a>",
"repo.pulls.create": "Create Pull Request",
+8 -4
View File
@@ -313,7 +313,6 @@
"install.admin_email": "이메일 주소",
"install.install_btn_confirm": "Gitea 설치하기",
"install.test_git_failed": "'git' 명령 테스트 실패: %v",
"install.sqlite3_not_available": "해당 버전에서는 SQLite3를 지원하지 않습니다. %s에서 공식 버전을 다운로드해주세요. ('gobuild' 버전이 아닙니다.)",
"install.invalid_db_setting": "데이터베이스 설정이 올바르지 않습니다: %v",
"install.invalid_db_table": "데이터베이스 테이블 '%s' 이 유효하지 않습니다: %v",
"install.invalid_repo_path": "리포지토리의 경로가 올바르지 않습니다: %v",
@@ -1385,6 +1384,7 @@
"repo.projects.column.delete": "열 삭제",
"repo.projects.column.deletion_desc": "프로젝트 열을 삭제하면 모든 관련 이슈가 기본 열로 이동합니다. 계속하시겠습니까?",
"repo.projects.column.color": "색",
"repo.projects.column": "열",
"repo.projects.open": "열기",
"repo.projects.close": "닫기",
"repo.projects.column.assigned_to": "다음에 할당",
@@ -1784,6 +1784,7 @@
"repo.pulls.review_only_possible_for_full_diff": "전체 diff를 볼 때만 검토가 가능합니다",
"repo.pulls.filter_changes_by_commit": "커밋으로 필터링",
"repo.pulls.nothing_to_compare": "이 브랜치들은 동일합니다. 풀 리퀘스트를 생성할 필요가 없습니다.",
"repo.pulls.no_common_history": "이 브랜치들은 공통된 머지 베이스를 공유하지 않습니다. 다른 베이스 브랜치 또는 비교 브랜치를 선택해 주세요.",
"repo.pulls.nothing_to_compare_have_tag": "선택된 브랜치/태그는 동일합니다.",
"repo.pulls.nothing_to_compare_and_allow_empty_pr": "이 브랜치들은 동일합니다. 이 PR은 비어 있을 것입니다.",
"repo.pulls.has_pull_request": "이 브랜치들 사이의 풀 리퀘스트가 이미 존재합니다: <a href=\"%[1]s\">%[2]s#%[3]d</a>",
@@ -1956,7 +1957,6 @@
"repo.signing.wont_sign.headsigned": "헤드 커밋이 서명되지 않아 머지가 서명되지 않을 것입니다.",
"repo.signing.wont_sign.commitssigned": "연관된 모든 커밋이 서명되지 않아 머지가 서명되지 않을 것입니다.",
"repo.signing.wont_sign.approved": "PR이 승인되지 않아 머지가 서명되지 않을 것입니다.",
"repo.signing.wont_sign.not_signed_in": "로그인되어 있지 않습니다.",
"repo.ext_wiki": "외부 위키 액세스",
"repo.ext_wiki.desc": "외부 위키에 연결하기.",
"repo.wiki": "위키",
@@ -3313,7 +3313,6 @@
"admin.config.cache_config": "캐시 설정",
"admin.config.cache_adapter": "캐시 어댑터",
"admin.config.cache_interval": "캐시 간격",
"admin.config.cache_conn": "캐시 연결",
"admin.config.cache_item_ttl": "캐시 아이템 TTL",
"admin.config.cache_test": "캐시 테스트",
"admin.config.cache_test_failed": "캐시 검사에 실패했습니다: %v.",
@@ -3328,7 +3327,6 @@
"admin.config.instance_web_banner.message_placeholder": "배너 메시지 (마크다운 지원)",
"admin.config.session_config": "세션 설정",
"admin.config.session_provider": "세션 공급자",
"admin.config.provider_config": "공급자 설정",
"admin.config.cookie_name": "쿠키 이름",
"admin.config.gc_interval_time": "GC 인터벌 시간",
"admin.config.session_life_time": "세션 수명",
@@ -3625,6 +3623,12 @@
"packages.vagrant.install": "Vagrant 박스를 추가하려면 다음 명령을 실행:",
"packages.settings.link": "이 패키지를 리포지토리에 연결",
"packages.settings.link.description": "패키지를 리포지토리에 연결하면 리포지토리의 패키지 목록에 표시됩니다.",
"packages.settings.link.notice1": "동일한 소유자의 저장소만 연결할 수 있습니다.",
"packages.settings.link.notice2": "저장소를 연결해도 패키지 공개 범위는 변경되지 않습니다.",
"packages.settings.link.notice3": "입력란을 비워 두면 링크가 삭제됩니다.",
"packages.settings.visibility": "패키지 공개범위",
"packages.settings.visibility.inherit": "패키지 공개 범위는 소유자로부터 상속되므로 여기에서 직접 변경할 수 없습니다. 이를 변경하려면 이 패키지를 소유한 사용자 또는 조직의 공개 설정에서 업데이트하세요.",
"packages.settings.visibility.button": "소유자 공개범위 변경",
"packages.settings.link.select": "리포지토리 선택",
"packages.settings.link.button": "리포지토리 링크 업데이트",
"packages.settings.link.success": "리포지토리 링크가 성공적으로 업데이트 되었습니다.",
+5 -5
View File
@@ -10,17 +10,17 @@
"@citation-js/plugin-bibtex": "0.7.21",
"@citation-js/plugin-csl": "0.7.22",
"@citation-js/plugin-software-formats": "0.6.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/language": "6.12.3",
"@codemirror/language-data": "6.5.2",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/lint": "6.9.5",
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.1",
"@codemirror/view": "6.42.0",
"@deltablot/dropzone": "7.4.3",
"@github/markdown-toolbar-element": "2.2.3",
"@github/paste-markdown": "1.5.3",
@@ -52,7 +52,7 @@
"jquery": "4.0.0",
"js-yaml": "4.1.1",
"katex": "0.16.45",
"mermaid": "11.14.0",
"mermaid": "11.15.0",
"online-3d-viewer": "0.18.0",
"pdfobject": "2.3.1",
"perfect-debounce": "2.1.0",
@@ -69,7 +69,7 @@
"vanilla-colorful": "0.7.2",
"vite": "8.0.10",
"vite-string-plugin": "2.0.4",
"vue": "3.5.33",
"vue": "3.5.34",
"vue-bar-graph": "2.2.0",
"vue-chartjs": "5.3.3"
},
+212 -475
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -62,6 +62,18 @@
"matchPackageNames": ["go.yaml.in/yaml/v4"],
"allowedVersions": "<4.0.0-rc.4", // rc.4 changes block scalar serialization, wait for stable release
},
{
"matchPackageNames": ["postgres"],
"allowedVersions": "/^14($|[.-])/", // pin to oldest supported major
},
{
"matchPackageNames": ["bitnamilegacy/mysql"],
"allowedVersions": "/^8\\.4($|[.-])/", // pin to oldest LTS
},
{
"matchPackageNames": ["mcr.microsoft.com/mssql/server"],
"allowedVersions": "/^2019($|[.-])/", // pin to oldest in extended support
},
{
"groupName": "go dependencies",
"matchManagers": ["gomod"],
@@ -88,6 +100,7 @@
{
"groupName": "npm dependencies",
"matchManagers": ["npm"],
"postUpdateOptions": ["pnpmDedupe"],
"postUpgradeTasks": {
"commands": ["make svg nolyfill"],
"fileFilters": ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "public/assets/img/svg/**"],
+13 -2
View File
@@ -261,7 +261,16 @@ func (s *Service) UpdateLog(
}
ack := task.LogLength
if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack {
// Trim rows the runner already had acked.
var rows []*runnerv1.LogRow
if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack {
rows = req.Msg.Rows[ack-req.Msg.Index:]
}
// Bail unless we have new rows or a NoMore to finalize. Even with
// NoMore, bail when the runner has outrun the server — archiving a
// log with a gap is worse than asking it to retry.
if len(rows) == 0 && (!req.Msg.NoMore || req.Msg.Index > ack) {
res.Msg.AckIndex = ack
return res, nil
}
@@ -270,7 +279,9 @@ func (s *Service) UpdateLog(
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
}
rows := req.Msg.Rows[ack-req.Msg.Index:]
// WriteLogs is called even with no rows: with offset==0 it bootstraps
// an empty DBFS file so TransferLogs below has something to read when
// the runner finalizes a task that produced no log output.
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
if err != nil {
return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err)
+70
View File
@@ -875,6 +875,7 @@ func Routes() *web.Router {
}))
}
m.AfterRouting(common.SessionTagMiddleware())
m.AfterRouting(context.APIContexter())
m.AfterRouting(checkDeprecatedAuthMethods)
@@ -1007,6 +1008,30 @@ func Routes() *web.Router {
}, context.UserAssignmentAPI(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
// Users (requires issue scope) — user-scope project boards
m.Group("/users/{username}", func() {
m.Group("/projects", func() {
m.Combo("").Get(user.ListUserProjects).
Post(reqToken(), bind(api.CreateProjectOption{}), user.CreateUserProject)
m.Group("/{id}", func() {
m.Combo("").Get(user.GetUserProject).
Patch(reqToken(), bind(api.EditProjectOption{}), user.EditUserProject).
Delete(reqToken(), user.DeleteUserProject)
m.Combo("/columns").Get(user.ListUserProjectColumns).
Post(reqToken(), bind(api.CreateProjectColumnOption{}), user.CreateUserProjectColumn)
m.Group("/columns/{column_id}", func() {
m.Combo("").
Patch(reqToken(), bind(api.EditProjectColumnOption{}), user.EditUserProjectColumn).
Delete(reqToken(), user.DeleteUserProjectColumn)
m.Get("/issues", user.ListUserProjectColumnIssues)
m.Post("/issues/{issue_id}", reqToken(), user.AddIssueToUserProjectColumn)
m.Delete("/issues/{issue_id}", reqToken(), user.RemoveIssueFromUserProjectColumn)
})
m.Post("/issues/{issue_id}/move", reqToken(), bind(api.MoveProjectIssueOption{}), user.MoveUserProjectIssue)
})
})
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), context.UserAssignmentAPI(), checkTokenPublicOnly())
// Users (requires user scope)
m.Group("/user", func() {
m.Get("", user.GetAuthenticatedUser)
@@ -1575,6 +1600,26 @@ func Routes() *web.Router {
Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone).
Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
})
m.Group("/projects", func() {
m.Combo("").Get(repo.ListProjects).
Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectOption{}), repo.CreateProject)
m.Group("/{id}", func() {
m.Combo("").Get(repo.GetProject).
Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectOption{}), repo.EditProject).
Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProject)
m.Combo("/columns").Get(repo.ListProjectColumns).
Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn)
m.Group("/columns/{column_id}", func() {
m.Combo("").
Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn).
Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn)
m.Get("/issues", repo.ListProjectColumnIssues)
m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn)
m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn)
})
m.Post("/issues/{issue_id}/move", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.MoveProjectIssueOption{}), repo.MoveProjectIssue)
})
}, reqRepoReader(unit.TypeProjects))
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
@@ -1667,6 +1712,31 @@ func Routes() *web.Router {
})
}, reqToken(), reqOrgOwnership())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
// Orgs (requires issue scope) — org-scope project boards
m.Group("/orgs/{org}", func() {
m.Group("/projects", func() {
m.Combo("").Get(org.ListOrgProjects).
Post(reqToken(), reqOrgMembership(), bind(api.CreateProjectOption{}), org.CreateOrgProject)
m.Group("/{id}", func() {
m.Combo("").Get(org.GetOrgProject).
Patch(reqToken(), reqOrgMembership(), bind(api.EditProjectOption{}), org.EditOrgProject).
Delete(reqToken(), reqOrgMembership(), org.DeleteOrgProject)
m.Combo("/columns").Get(org.ListOrgProjectColumns).
Post(reqToken(), reqOrgMembership(), bind(api.CreateProjectColumnOption{}), org.CreateOrgProjectColumn)
m.Group("/columns/{column_id}", func() {
m.Combo("").
Patch(reqToken(), reqOrgMembership(), bind(api.EditProjectColumnOption{}), org.EditOrgProjectColumn).
Delete(reqToken(), reqOrgMembership(), org.DeleteOrgProjectColumn)
m.Get("/issues", org.ListOrgProjectColumnIssues)
m.Post("/issues/{issue_id}", reqToken(), reqOrgMembership(), org.AddIssueToOrgProjectColumn)
m.Delete("/issues/{issue_id}", reqToken(), reqOrgMembership(), org.RemoveIssueFromOrgProjectColumn)
})
m.Post("/issues/{issue_id}/move", reqToken(), reqOrgMembership(), bind(api.MoveProjectIssueOption{}), org.MoveOrgProjectIssue)
})
})
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), orgAssignment(true), checkTokenPublicOnly())
m.Group("/teams/{teamid}", func() {
m.Combo("").Get(reqToken(), org.GetTeam).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
+930
View File
@@ -0,0 +1,930 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"errors"
"net/http"
"slices"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/optional"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
project_service "code.gitea.io/gitea/services/projects"
)
func getOrgProjectByID(ctx *context.APIContext) *project_model.Project {
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.Org.Organization.ID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil
}
return project
}
func getOrgProjectColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) {
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id"))
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil
}
project, err := project_model.GetProjectByIDAndOwner(ctx, column.ProjectID, ctx.Org.Organization.ID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil
}
if project.ID != ctx.PathParamInt64("id") {
ctx.APIErrorNotFound()
return nil, nil
}
return project, column
}
func rejectIfOrgProjectClosed(ctx *context.APIContext, project *project_model.Project) bool {
if project.IsClosed {
ctx.APIError(http.StatusForbidden, "project is closed")
return true
}
return false
}
func validateOrgColumnColor(ctx *context.APIContext, color string) bool {
if color == "" {
return true
}
if !project_model.ColumnColorPattern.MatchString(color) {
ctx.APIError(http.StatusUnprocessableEntity, "color must be a 6-digit hex string like #FF0000")
return false
}
return true
}
// ListOrgProjects lists all projects owned by an organization
func ListOrgProjects(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/projects organization orgListProjects
// ---
// summary: List projects owned by an organization
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: state
// in: query
// description: State of the project (open, closed, all)
// type: string
// enum: [open, closed, all]
// default: open
// - name: page
// in: query
// description: page number of results
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectList"
// "404":
// "$ref": "#/responses/notFound"
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state"))
listOptions := utils.GetListOptions(ctx)
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: listOptions,
OwnerID: ctx.Org.Organization.ID,
IsClosed: isClosed,
Type: project_model.TypeOrganization,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer))
}
// GetOrgProject gets a single org-scope project
func GetOrgProject(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/projects/{id} organization orgGetProject
// ---
// summary: Get an organization-scope project
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/Project"
// "404":
// "$ref": "#/responses/notFound"
project := getOrgProjectByID(ctx)
if ctx.Written() {
return
}
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
}
// CreateOrgProject creates a new org-scope project
func CreateOrgProject(ctx *context.APIContext) {
// swagger:operation POST /orgs/{org}/projects organization orgCreateProject
// ---
// summary: Create an organization-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectOption"
// responses:
// "201":
// "$ref": "#/responses/Project"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.CreateProjectOption)
templateType, err := convert.ProjectTemplateTypeFromString(form.TemplateType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
cardType, err := convert.ProjectCardTypeFromString(form.CardType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
p := &project_model.Project{
OwnerID: ctx.Org.Organization.ID,
Title: form.Title,
Description: form.Description,
CreatorID: ctx.Doer.ID,
TemplateType: templateType,
CardType: cardType,
Type: project_model.TypeOrganization,
}
if err := project_model.NewProject(ctx, p); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer))
}
// EditOrgProject updates an org-scope project
func EditOrgProject(ctx *context.APIContext) {
// swagger:operation PATCH /orgs/{org}/projects/{id} organization orgEditProject
// ---
// summary: Edit an organization-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectOption"
// responses:
// "200":
// "$ref": "#/responses/Project"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
project := getOrgProjectByID(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*api.EditProjectOption)
opts := project_service.UpdateProjectOptions{
Title: optional.FromPtr(form.Title),
Description: optional.FromPtr(form.Description),
}
if form.CardType != nil {
cardType, err := convert.ProjectCardTypeFromString(*form.CardType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
opts.CardType = optional.Some(cardType)
}
if form.State != nil {
switch *form.State {
case api.StateOpen:
opts.IsClosed = optional.Some(false)
case api.StateClosed:
opts.IsClosed = optional.Some(true)
default:
ctx.APIError(http.StatusUnprocessableEntity, "state must be 'open' or 'closed'")
return
}
}
if err := project_service.UpdateProject(ctx, project, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
}
// DeleteOrgProject deletes an org-scope project
func DeleteOrgProject(ctx *context.APIContext) {
// swagger:operation DELETE /orgs/{org}/projects/{id} organization orgDeleteProject
// ---
// summary: Delete an organization-scope project
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
project := getOrgProjectByID(ctx)
if ctx.Written() {
return
}
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListOrgProjectColumns lists all columns in an org-scope project
func ListOrgProjectColumns(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/projects/{id}/columns organization orgListProjectColumns
// ---
// summary: List columns in an organization-scope project
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectColumnList"
// "404":
// "$ref": "#/responses/notFound"
project := getOrgProjectByID(ctx)
if ctx.Written() {
return
}
total, err := project_model.CountProjectColumns(ctx, project.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
listOptions := utils.GetListOptions(ctx)
columns, err := project_model.GetProjectColumns(ctx, project.ID, listOptions)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
}
// CreateOrgProjectColumn creates a new column in an org-scope project
func CreateOrgProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /orgs/{org}/projects/{id}/columns organization orgCreateProjectColumn
// ---
// summary: Create a new column in an organization-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectColumnOption"
// responses:
// "201":
// "$ref": "#/responses/ProjectColumn"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
project := getOrgProjectByID(ctx)
if ctx.Written() {
return
}
if rejectIfOrgProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.CreateProjectColumnOption)
if !validateOrgColumnColor(ctx, form.Color) {
return
}
column := &project_model.Column{
Title: form.Title,
Color: form.Color,
ProjectID: project.ID,
CreatorID: ctx.Doer.ID,
}
if err := project_service.CreateColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer))
}
// EditOrgProjectColumn updates a column in an org-scope project
func EditOrgProjectColumn(ctx *context.APIContext) {
// swagger:operation PATCH /orgs/{org}/projects/{id}/columns/{column_id} organization orgEditProjectColumn
// ---
// summary: Edit a column in an organization-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectColumnOption"
// responses:
// "200":
// "$ref": "#/responses/ProjectColumn"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
project, column := getOrgProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfOrgProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.EditProjectColumnOption)
if form.Color != nil && !validateOrgColumnColor(ctx, *form.Color) {
return
}
if form.Title != nil {
column.Title = *form.Title
}
if form.Color != nil {
column.Color = *form.Color
}
if form.Sorting != nil {
if *form.Sorting < -128 || *form.Sorting > 127 {
ctx.APIError(http.StatusBadRequest, "sorting value out of range, must be between -128 and 127")
return
}
column.Sorting = int8(*form.Sorting)
}
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer))
}
// DeleteOrgProjectColumn deletes a column in an org-scope project
func DeleteOrgProjectColumn(ctx *context.APIContext) {
// swagger:operation DELETE /orgs/{org}/projects/{id}/columns/{column_id} organization orgDeleteProjectColumn
// ---
// summary: Delete a column in an organization-scope project
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
project, column := getOrgProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfOrgProjectClosed(ctx, project) {
return
}
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListOrgProjectColumnIssues lists all issues in an org-scope project column
func ListOrgProjectColumnIssues(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/projects/{id}/columns/{column_id}/issues organization orgListProjectColumnIssues
// ---
// summary: List issues in an organization-scope project column
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/IssueList"
// "404":
// "$ref": "#/responses/notFound"
_, column := getOrgProjectColumn(ctx)
if ctx.Written() {
return
}
listOptions := utils.GetListOptions(ctx)
// project_issue join already constrains to issues attached to this column;
// no Owner/Doer filter so issues from any repo (including private ones the
// caller may not directly access) are listed if they're on this org board.
// This matches how the org project web UI loads its kanban board.
issuesOpts := &issues_model.IssuesOptions{
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
SortType: issues_model.SortTypeProjectColumnSorting,
}
count, err := issues_model.CountIssues(ctx, issuesOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
issues, err := issues_model.Issues(ctx, issuesOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
}
// AddIssueToOrgProjectColumn adds an issue to an org-scope project column
func AddIssueToOrgProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id} organization orgAddIssueToProjectColumn
// ---
// summary: Add an issue to an organization-scope project column
// description: Gitea projects only contain issues — note cards and pull requests cannot be added.
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// responses:
// "201":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
assignIssueToOrgProjectColumn(ctx, true)
}
// RemoveIssueFromOrgProjectColumn removes an issue from an org-scope project column.
// This fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
func RemoveIssueFromOrgProjectColumn(ctx *context.APIContext) {
// swagger:operation DELETE /orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id} organization orgRemoveIssueFromProjectColumn
// ---
// summary: Remove an issue from an organization-scope project column
// description: Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
assignIssueToOrgProjectColumn(ctx, false)
}
func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) {
project, column := getOrgProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfOrgProjectClosed(ctx, project) {
return
}
// Org-scope projects can contain issues from any repo in the org; no repo-ID constraint.
issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
if err := issue.LoadProjects(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
currentProjectIDs := make([]int64, 0, len(issue.Projects))
for _, p := range issue.Projects {
currentProjectIDs = append(currentProjectIDs, p.ID)
}
if !add {
exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !exists {
ctx.APIErrorNotFound()
return
}
newProjectIDs := make([]int64, 0, len(currentProjectIDs))
for _, id := range currentProjectIDs {
if id != column.ProjectID {
newProjectIDs = append(newProjectIDs, id)
}
}
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
} else {
alreadyInProject := slices.Contains(currentProjectIDs, column.ProjectID)
if !alreadyInProject {
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
copy(newProjectIDs, currentProjectIDs)
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
}
if err := project_model.MoveIssueToColumn(ctx, issue.ID, column.ProjectID, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
}
if add {
ctx.Status(http.StatusCreated)
} else {
ctx.Status(http.StatusNoContent)
}
}
// MoveOrgProjectIssue moves an issue between columns of an org-scope project
func MoveOrgProjectIssue(ctx *context.APIContext) {
// swagger:operation POST /orgs/{org}/projects/{id}/issues/{issue_id}/move organization orgMoveProjectIssue
// ---
// summary: Move an issue between columns of an organization-scope project
// description: Atomically moves an existing project issue into a different column, optionally setting its sorting position.
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/MoveProjectIssueOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
project := getOrgProjectByID(ctx)
if ctx.Written() {
return
}
if rejectIfOrgProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.MoveProjectIssueOption)
column, err := project_model.GetColumn(ctx, form.ColumnID)
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIError(http.StatusUnprocessableEntity, "target column does not exist")
} else {
ctx.APIErrorInternal(err)
}
return
}
if column.ProjectID != project.ID {
ctx.APIError(http.StatusUnprocessableEntity, "target column does not belong to this project")
return
}
issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
var sorting int64
if form.Sorting != nil {
sorting = *form.Sorting
} else {
next, err := project_model.GetColumnIssueNextSorting(ctx, project.ID, column.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
sorting = next
}
if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{sorting: issue.ID}); err != nil {
if errors.Is(err, project_service.ErrIssueNotInProject) {
ctx.APIErrorNotFound()
return
}
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
+1
View File
@@ -1175,6 +1175,7 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac
ctx.APIErrorInternal(err)
return nil, nil
}
jobs.SortMatrixGroupsByName()
return run, jobs
}
+2 -1
View File
@@ -31,6 +31,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
issue_service "code.gitea.io/gitea/services/issue"
project_service "code.gitea.io/gitea/services/projects"
)
// buildSearchIssuesRepoIDs builds the list of repository IDs for issue search based on query parameters.
@@ -915,7 +916,7 @@ func EditIssue(ctx *context.APIContext) {
// Update projects if provided
if canWrite && form.Projects != nil {
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, *form.Projects); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, *form.Projects); err != nil {
if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusBadRequest, err)
} else {
File diff suppressed because it is too large Load Diff
+13
View File
@@ -233,4 +233,17 @@ type swaggerParameterBodies struct {
// in:body
LockIssueOption api.LockIssueOption
// in:body
CreateProjectOption api.CreateProjectOption
// in:body
EditProjectOption api.EditProjectOption
// in:body
CreateProjectColumnOption api.CreateProjectColumnOption
// in:body
EditProjectColumnOption api.EditProjectColumnOption
// in:body
MoveProjectIssueOption api.MoveProjectIssueOption
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package swagger
import (
api "code.gitea.io/gitea/modules/structs"
)
// Project
// swagger:response Project
type swaggerResponseProject struct {
// in:body
Body api.Project `json:"body"`
}
// ProjectList
// swagger:response ProjectList
type swaggerResponseProjectList struct {
// in:body
Body []api.Project `json:"body"`
}
// ProjectColumn
// swagger:response ProjectColumn
type swaggerResponseProjectColumn struct {
// in:body
Body api.ProjectColumn `json:"body"`
}
// ProjectColumnList
// swagger:response ProjectColumnList
type swaggerResponseProjectColumnList struct {
// in:body
Body []api.ProjectColumn `json:"body"`
}
+970
View File
@@ -0,0 +1,970 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"errors"
"net/http"
"slices"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/optional"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
project_service "code.gitea.io/gitea/services/projects"
)
// reqUserProjectWriter returns false and writes a 403 if the doer is neither
// the context user nor a site admin. Call at the top of every write handler.
func reqUserProjectWriter(ctx *context.APIContext) bool {
if ctx.Doer.ID != ctx.ContextUser.ID && !ctx.Doer.IsAdmin {
ctx.APIError(http.StatusForbidden, "only the owner or a site admin may modify user projects")
return false
}
return true
}
func getUserProjectByID(ctx *context.APIContext) *project_model.Project {
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil
}
return project
}
func getUserProjectColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) {
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id"))
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil
}
project, err := project_model.GetProjectByIDAndOwner(ctx, column.ProjectID, ctx.ContextUser.ID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil
}
if project.ID != ctx.PathParamInt64("id") {
ctx.APIErrorNotFound()
return nil, nil
}
return project, column
}
func rejectIfUserProjectClosed(ctx *context.APIContext, project *project_model.Project) bool {
if project.IsClosed {
ctx.APIError(http.StatusForbidden, "project is closed")
return true
}
return false
}
func validateUserColumnColor(ctx *context.APIContext, color string) bool {
if color == "" {
return true
}
if !project_model.ColumnColorPattern.MatchString(color) {
ctx.APIError(http.StatusUnprocessableEntity, "color must be a 6-digit hex string like #FF0000")
return false
}
return true
}
// ListUserProjects lists all projects owned by a user
func ListUserProjects(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects user userListProjects
// ---
// summary: List projects owned by a user
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the projects
// type: string
// required: true
// - name: state
// in: query
// description: State of the project (open, closed, all)
// type: string
// enum: [open, closed, all]
// default: open
// - name: page
// in: query
// description: page number of results
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectList"
// "404":
// "$ref": "#/responses/notFound"
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state"))
listOptions := utils.GetListOptions(ctx)
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: listOptions,
OwnerID: ctx.ContextUser.ID,
IsClosed: isClosed,
Type: project_model.TypeIndividual,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer))
}
// GetUserProject gets a single user-scope project
func GetUserProject(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects/{id} user userGetProject
// ---
// summary: Get a user-scope project
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/Project"
// "404":
// "$ref": "#/responses/notFound"
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
}
// CreateUserProject creates a new user-scope project
func CreateUserProject(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects user userCreateProject
// ---
// summary: Create a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectOption"
// responses:
// "201":
// "$ref": "#/responses/Project"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
form := web.GetForm(ctx).(*api.CreateProjectOption)
templateType, err := convert.ProjectTemplateTypeFromString(form.TemplateType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
cardType, err := convert.ProjectCardTypeFromString(form.CardType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
p := &project_model.Project{
OwnerID: ctx.ContextUser.ID,
Title: form.Title,
Description: form.Description,
CreatorID: ctx.Doer.ID,
TemplateType: templateType,
CardType: cardType,
Type: project_model.TypeIndividual,
}
if err := project_model.NewProject(ctx, p); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer))
}
// EditUserProject updates a user-scope project
func EditUserProject(ctx *context.APIContext) {
// swagger:operation PATCH /users/{username}/projects/{id} user userEditProject
// ---
// summary: Edit a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectOption"
// responses:
// "200":
// "$ref": "#/responses/Project"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*api.EditProjectOption)
opts := project_service.UpdateProjectOptions{
Title: optional.FromPtr(form.Title),
Description: optional.FromPtr(form.Description),
}
if form.CardType != nil {
cardType, err := convert.ProjectCardTypeFromString(*form.CardType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
opts.CardType = optional.Some(cardType)
}
if form.State != nil {
switch *form.State {
case api.StateOpen:
opts.IsClosed = optional.Some(false)
case api.StateClosed:
opts.IsClosed = optional.Some(true)
default:
ctx.APIError(http.StatusUnprocessableEntity, "state must be 'open' or 'closed'")
return
}
}
if err := project_service.UpdateProject(ctx, project, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
}
// DeleteUserProject deletes a user-scope project
func DeleteUserProject(ctx *context.APIContext) {
// swagger:operation DELETE /users/{username}/projects/{id} user userDeleteProject
// ---
// summary: Delete a user-scope project
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListUserProjectColumns lists all columns in a user-scope project
func ListUserProjectColumns(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects/{id}/columns user userListProjectColumns
// ---
// summary: List columns in a user-scope project
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectColumnList"
// "404":
// "$ref": "#/responses/notFound"
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
total, err := project_model.CountProjectColumns(ctx, project.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
listOptions := utils.GetListOptions(ctx)
columns, err := project_model.GetProjectColumns(ctx, project.ID, listOptions)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
}
// CreateUserProjectColumn creates a new column in a user-scope project
func CreateUserProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects/{id}/columns user userCreateProjectColumn
// ---
// summary: Create a new column in a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectColumnOption"
// responses:
// "201":
// "$ref": "#/responses/ProjectColumn"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.CreateProjectColumnOption)
if !validateUserColumnColor(ctx, form.Color) {
return
}
column := &project_model.Column{
Title: form.Title,
Color: form.Color,
ProjectID: project.ID,
CreatorID: ctx.Doer.ID,
}
if err := project_service.CreateColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer))
}
// EditUserProjectColumn updates a column in a user-scope project
func EditUserProjectColumn(ctx *context.APIContext) {
// swagger:operation PATCH /users/{username}/projects/{id}/columns/{column_id} user userEditProjectColumn
// ---
// summary: Edit a column in a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectColumnOption"
// responses:
// "200":
// "$ref": "#/responses/ProjectColumn"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.EditProjectColumnOption)
if form.Color != nil && !validateUserColumnColor(ctx, *form.Color) {
return
}
if form.Title != nil {
column.Title = *form.Title
}
if form.Color != nil {
column.Color = *form.Color
}
if form.Sorting != nil {
if *form.Sorting < -128 || *form.Sorting > 127 {
ctx.APIError(http.StatusBadRequest, "sorting value out of range, must be between -128 and 127")
return
}
column.Sorting = int8(*form.Sorting)
}
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer))
}
// DeleteUserProjectColumn deletes a column in a user-scope project
func DeleteUserProjectColumn(ctx *context.APIContext) {
// swagger:operation DELETE /users/{username}/projects/{id}/columns/{column_id} user userDeleteProjectColumn
// ---
// summary: Delete a column in a user-scope project
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
if !reqUserProjectWriter(ctx) {
return
}
project, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListUserProjectColumnIssues lists all issues in a user-scope project column
func ListUserProjectColumnIssues(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects/{id}/columns/{column_id}/issues user userListProjectColumnIssues
// ---
// summary: List issues in a user-scope project column
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/IssueList"
// "404":
// "$ref": "#/responses/notFound"
_, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
listOptions := utils.GetListOptions(ctx)
// project_issue join already constrains to issues attached to this column;
// no Owner/Doer filter — same approach as the org-scope handler.
issuesOpts := &issues_model.IssuesOptions{
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
SortType: issues_model.SortTypeProjectColumnSorting,
}
count, err := issues_model.CountIssues(ctx, issuesOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
issues, err := issues_model.Issues(ctx, issuesOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
}
// AddIssueToUserProjectColumn adds an issue to a user-scope project column
func AddIssueToUserProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id} user userAddIssueToProjectColumn
// ---
// summary: Add an issue to a user-scope project column
// description: Gitea projects only contain issues — note cards and pull requests cannot be added.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// responses:
// "201":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
assignIssueToUserProjectColumn(ctx, true)
}
// RemoveIssueFromUserProjectColumn removes an issue from a user-scope project column.
// This fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
func RemoveIssueFromUserProjectColumn(ctx *context.APIContext) {
// swagger:operation DELETE /users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id} user userRemoveIssueFromProjectColumn
// ---
// summary: Remove an issue from a user-scope project column
// description: Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
assignIssueToUserProjectColumn(ctx, false)
}
func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) {
if !reqUserProjectWriter(ctx) {
return
}
project, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
// User-scope projects can contain issues from any repo; no repo-ID constraint.
issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
if err := issue.LoadProjects(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
currentProjectIDs := make([]int64, 0, len(issue.Projects))
for _, p := range issue.Projects {
currentProjectIDs = append(currentProjectIDs, p.ID)
}
if !add {
exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !exists {
ctx.APIErrorNotFound()
return
}
newProjectIDs := make([]int64, 0, len(currentProjectIDs))
for _, id := range currentProjectIDs {
if id != column.ProjectID {
newProjectIDs = append(newProjectIDs, id)
}
}
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
} else {
alreadyInProject := slices.Contains(currentProjectIDs, column.ProjectID)
if !alreadyInProject {
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
copy(newProjectIDs, currentProjectIDs)
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
}
if err := project_model.MoveIssueToColumn(ctx, issue.ID, column.ProjectID, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
}
if add {
ctx.Status(http.StatusCreated)
} else {
ctx.Status(http.StatusNoContent)
}
}
// MoveUserProjectIssue moves an issue between columns of a user-scope project
func MoveUserProjectIssue(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects/{id}/issues/{issue_id}/move user userMoveProjectIssue
// ---
// summary: Move an issue between columns of a user-scope project
// description: Atomically moves an existing project issue into a different column, optionally setting its sorting position.
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/MoveProjectIssueOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.MoveProjectIssueOption)
column, err := project_model.GetColumn(ctx, form.ColumnID)
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIError(http.StatusUnprocessableEntity, "target column does not exist")
} else {
ctx.APIErrorInternal(err)
}
return
}
if column.ProjectID != project.ID {
ctx.APIError(http.StatusUnprocessableEntity, "target column does not belong to this project")
return
}
issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
var sorting int64
if form.Sorting != nil {
sorting = *form.Sorting
} else {
next, err := project_model.GetColumnIssueNextSorting(ctx, project.ID, column.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
sorting = next
}
if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{sorting: issue.ID}); err != nil {
if errors.Is(err, project_service.ErrIssueNotInProject) {
ctx.APIErrorNotFound()
return
}
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
+1 -3
View File
@@ -34,9 +34,7 @@ func ProtocolMiddlewares() (handlers []any) {
handlers = append(handlers, ForwardedHeadersHandler(setting.ReverseProxyLimit, setting.ReverseProxyTrustedProxies))
}
if setting.IsRouteLogEnabled() {
handlers = append(handlers, routing.NewLoggerHandler())
}
handlers = append(handlers, routing.NewRequestInfoHandler())
if setting.IsAccessLogEnabled() {
handlers = append(handlers, context.AccessLogger())
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"code.gitea.io/gitea/services/project_events"
)
// SessionTagHeader is the HTTP header browser tabs use to broadcast a
// per-page-load identifier with every mutation request. The server
// echoes it back inside SSE event payloads so the originating tab
// can suppress its own event after applying the optimistic update.
const SessionTagHeader = "X-Session-Tag"
// SessionTagMiddleware decorates each incoming request's context with
// the X-Session-Tag header value when present. Service- and model-
// layer publishers read the value via project_events.SessionTagFromContext.
//
// Empty / missing headers are a no-op.
func SessionTagMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tag := r.Header.Get(SessionTagHeader)
if tag != "" {
ctx := project_events.WithSessionTag(r.Context(), tag)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}
+6 -6
View File
@@ -214,7 +214,7 @@ func DeleteProject(ctx *context.Context) {
return
}
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
if err := project_service.DeleteProject(ctx, p.ID); err != nil {
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
@@ -283,7 +283,7 @@ func EditProjectPost(ctx *context.Context) {
p.Title = form.Title
p.Description = form.Content
p.CardType = form.CardType
if err = project_model.UpdateProject(ctx, p); err != nil {
if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil {
ctx.ServerError("UpdateProjects", err)
return
}
@@ -309,7 +309,7 @@ func ViewProject(ctx *context.Context) {
return
}
columns, err := project.GetColumns(ctx)
columns, err := project_model.GetProjectColumns(ctx, project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
@@ -496,7 +496,7 @@ func DeleteProjectColumn(ctx *context.Context) {
return
}
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil {
if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
@@ -514,7 +514,7 @@ func AddColumnToProjectPost(ctx *context.Context) {
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
if err := project_service.CreateColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
@@ -567,7 +567,7 @@ func EditProjectColumn(ctx *context.Context) {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
+1
View File
@@ -893,6 +893,7 @@ func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.A
ctx.NotFound(nil)
return nil, nil, nil
}
jobs.SortMatrixGroupsByName()
for _, job := range jobs {
job.Run = run
+14 -6
View File
@@ -191,7 +191,7 @@ func DeleteProject(ctx *context.Context) {
return
}
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
if err := project_service.DeleteProject(ctx, p.ID); err != nil {
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
@@ -264,7 +264,7 @@ func EditProjectPost(ctx *context.Context) {
p.Title = form.Title
p.Description = form.Content
p.CardType = form.CardType
if err = project_model.UpdateProject(ctx, p); err != nil {
if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil {
ctx.ServerError("UpdateProjects", err)
return
}
@@ -449,9 +449,17 @@ func UpdateIssueProject(ctx *context.Context) {
}
projectIDs := ctx.FormStringInt64s("id")
// Remove zero values - id=0 means "remove from all projects"
filteredIDs := make([]int64, 0, len(projectIDs))
for _, id := range projectIDs {
if id != 0 {
filteredIDs = append(filteredIDs, id)
}
}
projectIDs = filteredIDs
var failedIssues []int64
for _, issue := range issues {
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, projectIDs); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
failedIssues = append(failedIssues, issue.ID)
continue
@@ -561,7 +569,7 @@ func DeleteProjectColumn(ctx *context.Context) {
return
}
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil {
if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
@@ -589,7 +597,7 @@ func AddColumnToProjectPost(ctx *context.Context) {
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
if err := project_service.CreateColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
@@ -664,7 +672,7 @@ func EditProjectColumn(ctx *context.Context) {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/services/context"
project_service "code.gitea.io/gitea/services/projects"
)
// MoveColumns moves or keeps columns in a project and sorts them inside that project
@@ -39,7 +40,7 @@ func MoveColumns(ctx *context.Context) {
sortedColumnIDs[column.Sorting] = column.ColumnID
}
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
if err = project_service.ReorderColumns(ctx, project, sortedColumnIDs); err != nil {
ctx.ServerError("MoveColumnsOnProject", err)
return
}
+1 -1
View File
@@ -295,7 +295,7 @@ func Routes() *web.Router {
routes.Get("/ssh_info", misc.SSHInfo)
routes.Get("/api/healthz", healthcheck.Check)
mid = append(mid, common.MustInitSessioner(), context.Contexter())
mid = append(mid, common.SessionTagMiddleware(), common.MustInitSessioner(), context.Contexter())
// Get user from session if logged in.
webAuth := newWebAuthMiddleware()
+1 -1
View File
@@ -99,7 +99,7 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss
return &api.Issue{}
}
if len(issue.Projects) > 0 {
apiIssue.Projects = ToAPIProjectList(issue.Projects)
apiIssue.Projects = ToProjectList(ctx, issue.Projects, doer)
}
if err := issue.LoadAssignees(ctx); err != nil {
+165 -20
View File
@@ -4,34 +4,179 @@
package convert
import (
"context"
"fmt"
project_model "code.gitea.io/gitea/models/project"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
api "code.gitea.io/gitea/modules/structs"
)
// ToAPIProject converts a Project to API format
func ToAPIProject(p *project_model.Project) *api.Project {
apiProject := &api.Project{
ID: p.ID,
Title: p.Title,
Description: p.Description,
OwnerID: p.OwnerID,
RepoID: p.RepoID,
CreatorID: p.CreatorID,
IsClosed: p.IsClosed,
Created: p.CreatedUnix.AsTime(),
Updated: p.UpdatedUnix.AsTime(),
func ProjectTemplateTypeToString(t project_model.TemplateType) string {
switch t {
case project_model.TemplateTypeBasicKanban:
return "basic_kanban"
case project_model.TemplateTypeBugTriage:
return "bug_triage"
default:
return "none"
}
if p.IsClosed && p.ClosedDateUnix > 0 {
apiProject.Closed = p.ClosedDateUnix.AsTimePtr()
}
return apiProject
}
// ToAPIProjectList converts a list of Projects to API format
func ToAPIProjectList(projects []*project_model.Project) []*api.Project {
func ProjectTemplateTypeFromString(s string) (project_model.TemplateType, error) {
switch s {
case "", "none":
return project_model.TemplateTypeNone, nil
case "basic_kanban":
return project_model.TemplateTypeBasicKanban, nil
case "bug_triage":
return project_model.TemplateTypeBugTriage, nil
default:
return 0, fmt.Errorf("invalid template_type %q (expected none, basic_kanban, bug_triage)", s)
}
}
func ProjectCardTypeToString(t project_model.CardType) string {
switch t {
case project_model.CardTypeImagesAndText:
return "images_and_text"
default:
return "text_only"
}
}
func ProjectCardTypeFromString(s string) (project_model.CardType, error) {
switch s {
case "", "text_only":
return project_model.CardTypeTextOnly, nil
case "images_and_text":
return project_model.CardTypeImagesAndText, nil
default:
return 0, fmt.Errorf("invalid card_type %q (expected text_only, images_and_text)", s)
}
}
func ProjectTypeToString(t project_model.Type) string {
switch t {
case project_model.TypeIndividual:
return "individual"
case project_model.TypeRepository:
return "repository"
case project_model.TypeOrganization:
return "organization"
default:
return ""
}
}
// loadProjectCreators batch-fetches creators for the given projects + columns and
// returns a map keyed by user ID. Errors are surfaced; missing users are silently
// skipped (their creator field stays nil), matching the convention of other list
// converters that tolerate deleted users.
func loadProjectCreators(ctx context.Context, projects []*project_model.Project, columns []*project_model.Column) (map[int64]*user_model.User, error) {
idSet := container.Set[int64]{}
for _, p := range projects {
if p.CreatorID > 0 {
idSet.Add(p.CreatorID)
}
}
for _, c := range columns {
if c.CreatorID > 0 {
idSet.Add(c.CreatorID)
}
}
if len(idSet) == 0 {
return map[int64]*user_model.User{}, nil
}
return user_model.GetUsersMapByIDs(ctx, idSet.Values())
}
// ToProject converts a project_model.Project to api.Project.
// Caller is expected to preload p.Repo / p.Owner to avoid N+1 lookups.
func ToProject(ctx context.Context, p *project_model.Project, doer *user_model.User) *api.Project {
creators, _ := loadProjectCreators(ctx, []*project_model.Project{p}, nil)
return toProject(ctx, p, doer, creators)
}
func toProject(ctx context.Context, p *project_model.Project, doer *user_model.User, creators map[int64]*user_model.User) *api.Project {
state := api.StateOpen
if p.IsClosed {
state = api.StateClosed
}
project := &api.Project{
ID: p.ID,
Title: p.Title,
Description: p.Description,
OwnerID: p.OwnerID,
RepoID: p.RepoID,
State: state,
TemplateType: ProjectTemplateTypeToString(p.TemplateType),
CardType: ProjectCardTypeToString(p.CardType),
Type: ProjectTypeToString(p.Type),
NumOpenIssues: p.NumOpenIssues,
NumClosedIssues: p.NumClosedIssues,
NumIssues: p.NumIssues,
CreatedAt: p.CreatedUnix.AsTime(),
UpdatedAt: p.UpdatedUnix.AsTime(),
}
if p.ClosedDateUnix > 0 {
t := p.ClosedDateUnix.AsTime()
project.ClosedAt = &t
}
if creator, ok := creators[p.CreatorID]; ok {
project.Creator = ToUser(ctx, creator, doer)
}
if p.Type == project_model.TypeRepository && p.Repo != nil {
project.HTMLURL = p.Repo.HTMLURL() + fmt.Sprintf("/projects/%d", p.ID)
} else if p.Owner != nil {
project.HTMLURL = p.Owner.HTMLURL(ctx) + fmt.Sprintf("/-/projects/%d", p.ID)
}
return project
}
func ToProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User) *api.ProjectColumn {
creators, _ := loadProjectCreators(ctx, nil, []*project_model.Column{column})
return toProjectColumn(ctx, column, doer, creators)
}
func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn {
apiColumn := &api.ProjectColumn{
ID: column.ID,
Title: column.Title,
Default: column.Default,
Sorting: int(column.Sorting),
Color: column.Color,
ProjectID: column.ProjectID,
NumIssues: column.NumIssues,
CreatedAt: column.CreatedUnix.AsTime(),
UpdatedAt: column.UpdatedUnix.AsTime(),
}
if creator, ok := creators[column.CreatorID]; ok {
apiColumn.Creator = ToUser(ctx, creator, doer)
}
return apiColumn
}
func ToProjectList(ctx context.Context, projects []*project_model.Project, doer *user_model.User) []*api.Project {
creators, _ := loadProjectCreators(ctx, projects, nil)
result := make([]*api.Project, len(projects))
for i := range projects {
result[i] = ToAPIProject(projects[i])
for i, p := range projects {
result[i] = toProject(ctx, p, doer, creators)
}
return result
}
func ToProjectColumnList(ctx context.Context, columns []*project_model.Column, doer *user_model.User) []*api.ProjectColumn {
creators, _ := loadProjectCreators(ctx, nil, columns)
result := make([]*api.ProjectColumn, len(columns))
for i, column := range columns {
result[i] = toProjectColumn(ctx, column, doer, creators)
}
return result
}
+333
View File
@@ -0,0 +1,333 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package project_events publishes project board mutations as Server-Sent
// Events so other browser tabs viewing the same board can update their DOM
// in near real time.
//
// Each public Publish* helper marshals a typed payload to JSON, wraps it in
// an *eventsource.Event whose Name is "project-board.{project_id}", and
// fans the event out to every currently connected user that has read
// access to the project. All publish helpers are non-blocking: they spawn
// a goroutine so request handlers do not stall on slow consumers.
package project_events
import (
"context"
"strconv"
access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
)
// sessionTagCtxKey is the context key under which the X-Session-Tag value
// from the originating HTTP request is stashed. Publishers read it via
// SessionTagFromContext to attach to outgoing events so the originating
// browser tab can suppress its own echo.
type sessionTagCtxKey struct{}
// WithSessionTag returns ctx decorated with the provided session tag.
// Web/API middleware reads the X-Session-Tag header and calls this so
// service- and model-layer publishers can pull the tag back out.
func WithSessionTag(ctx context.Context, tag string) context.Context {
if tag == "" {
return ctx
}
return context.WithValue(ctx, sessionTagCtxKey{}, tag)
}
// SessionTagFromContext returns the session tag previously stored via
// WithSessionTag, or "" when none was set.
func SessionTagFromContext(ctx context.Context) string {
if v, ok := ctx.Value(sessionTagCtxKey{}).(string); ok {
return v
}
return ""
}
// Event payload structs ------------------------------------------------------
// CardMoved is emitted when an issue is moved between columns or reordered
// within a column.
type CardMoved struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
FromColumnID int64 `json:"from_column_id"`
ToColumnID int64 `json:"to_column_id"`
Sorting int64 `json:"sorting"`
SessionTag string `json:"session_tag,omitempty"`
}
// CardLinked is emitted when an issue is added to a project's default column.
type CardLinked struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
ColumnID int64 `json:"column_id"`
SessionTag string `json:"session_tag,omitempty"`
}
// CardUnlinked is emitted when an issue is removed from a project.
type CardUnlinked struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
SessionTag string `json:"session_tag,omitempty"`
}
// ColumnCreated is emitted when a new column is added to a project.
type ColumnCreated struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
Title string `json:"title"`
Color string `json:"color"`
Sorting int64 `json:"sorting"`
IsDefault bool `json:"is_default"`
}
// ColumnUpdated is emitted when a column's title, color, or sorting changes.
type ColumnUpdated struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
Title string `json:"title"`
Color string `json:"color"`
Sorting int64 `json:"sorting"`
}
// ColumnDeleted is emitted when a column is removed from a project.
// Deletion implicitly relocates issues to the default column, so the
// publisher will also emit one CardMoved per affected issue; the frontend
// only needs to drop the column and react to the per-issue moves.
type ColumnDeleted struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
}
// ColumnSort is one entry in a ColumnReordered batch.
type ColumnSort struct {
ColumnID int64 `json:"column_id"`
Sorting int64 `json:"sorting"`
}
// ColumnReordered is emitted when columns within a project are dragged into
// a new order.
type ColumnReordered struct {
ProjectID int64 `json:"project_id"`
Columns []ColumnSort `json:"columns"`
}
// ProjectUpdated is emitted when project metadata (title, description,
// card type, open/closed state) changes.
type ProjectUpdated struct {
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
CardType string `json:"card_type"`
IsClosed bool `json:"is_closed"`
}
// ProjectDeleted is emitted when a project is deleted.
type ProjectDeleted struct {
ProjectID int64 `json:"project_id"`
}
// Broadcast plumbing ---------------------------------------------------------
// broadcastFn is the package-level seam used to send an event to a set of
// uids. Tests swap it out to capture calls without touching the real
// eventsource manager.
var broadcastFn = defaultBroadcast
func defaultBroadcast(uids []int64, event *eventsource.Event) {
mgr := eventsource.GetManager()
for _, uid := range uids {
mgr.SendMessage(uid, event)
}
}
// connectedUIDsLister returns the uid set the broadcast helpers should
// consider as candidate recipients. Tests override it to feed a
// deterministic list.
var connectedUIDsLister = func() []int64 {
return eventsource.GetManager().ConnectedUIDs()
}
// projectLookup loads a project by id. Stubbable in tests so the
// access-filter logic can be exercised without spinning up a database.
var projectLookup = project_model.GetProjectByID
// projectAccessChecker decides whether the user identified by uid is
// allowed to read the given project. Tests stub this to bypass the real
// permission system.
var projectAccessChecker = canReadProject
// connectedUIDsWithProjectAccess returns the subset of currently connected
// uids that the access checker confirms can read projectID.
func connectedUIDsWithProjectAccess(ctx context.Context, projectID int64) []int64 {
uids := connectedUIDsLister()
if len(uids) == 0 {
return nil
}
project, err := projectLookup(ctx, projectID)
if err != nil {
log.Debug("project_events: GetProjectByID(%d) failed: %v", projectID, err)
return nil
}
allowed := make([]int64, 0, len(uids))
for _, uid := range uids {
ok, err := projectAccessChecker(ctx, uid, project)
if err != nil {
log.Debug("project_events: access check uid=%d project=%d: %v", uid, projectID, err)
continue
}
if ok {
allowed = append(allowed, uid)
}
}
return allowed
}
// canReadProject implements the real read-permission check used in
// production: repo projects defer to the repo's TypeProjects unit access;
// user / org projects fall back to user visibility.
func canReadProject(ctx context.Context, uid int64, project *project_model.Project) (bool, error) {
user, err := user_model.GetUserByID(ctx, uid)
if err != nil {
return false, err
}
if project.RepoID > 0 {
var repo *repo_model.Repository
if project.Repo != nil {
repo = project.Repo
} else {
repo, err = repo_model.GetRepositoryByID(ctx, project.RepoID)
if err != nil {
return false, err
}
}
// AccessModeRead == 1; we use the literal because the
// perm_model package's typed constant would force another
// import alias and the meaning is well established here.
ok, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeProjects, 1)
if err != nil {
return false, err
}
return ok, nil
}
if project.OwnerID > 0 {
owner := project.Owner
if owner == nil {
owner, err = user_model.GetUserByID(ctx, project.OwnerID)
if err != nil {
return false, err
}
}
return user_model.IsUserVisibleToViewer(ctx, owner, user), nil
}
return false, nil
}
// publishEvent is the shared pipeline used by every Publish* helper.
// It marshals the payload, builds the SSE Event, looks up authorized
// recipients, and fans the event out via broadcastFn. The whole thing
// runs inside the calling goroutine; callers should wrap it in `go` so
// request handlers stay responsive.
func publishEvent(ctx context.Context, projectID int64, payload any) {
data, err := json.Marshal(payload)
if err != nil {
log.Error("project_events: marshal payload for project %d: %v", projectID, err)
return
}
event := &eventsource.Event{
Name: eventName(projectID),
Data: data,
}
uids := connectedUIDsWithProjectAccess(ctx, projectID)
if len(uids) == 0 {
return
}
broadcastFn(uids, event)
}
// eventName returns the SSE event name for a given project id.
func eventName(projectID int64) string {
return "project-board." + strconv.FormatInt(projectID, 10)
}
// Publishers -----------------------------------------------------------------
// PublishCardMoved fans out a CardMoved event for the given payload.
func PublishCardMoved(ctx context.Context, payload CardMoved) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishCardLinked fans out a CardLinked event for the given payload.
func PublishCardLinked(ctx context.Context, payload CardLinked) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishCardUnlinked fans out a CardUnlinked event for the given payload.
func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnCreated fans out a ColumnCreated event for the given payload.
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnUpdated fans out a ColumnUpdated event for the given payload.
func PublishColumnUpdated(ctx context.Context, payload ColumnUpdated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnDeleted fans out a ColumnDeleted event for the given payload.
func PublishColumnDeleted(ctx context.Context, payload ColumnDeleted) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnReordered fans out a ColumnReordered event for the given payload.
func PublishColumnReordered(ctx context.Context, payload ColumnReordered) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishProjectUpdated fans out a ProjectUpdated event for the given payload.
func PublishProjectUpdated(ctx context.Context, payload ProjectUpdated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishProjectDeleted fans out a ProjectDeleted event for the given payload.
func PublishProjectDeleted(ctx context.Context, payload ProjectDeleted) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// detach returns a context safe for use in the fire-and-forget publish
// goroutine. The request's context carries a request-scoped DB session
// that is returned to the pool once the HTTP handler completes; reusing
// it from the goroutine races with that teardown and makes subsequent
// queries (GetProjectByID, access checks) fail intermittently. The
// session tag is already resolved synchronously before the goroutine
// starts, so the goroutine needs no request-scoped values — only a
// clean, process-lifetime DB context. ShutdownContext is backed by the
// global engine, outlives any single request, and is cancelled on app
// shutdown so we don't leak goroutines past teardown.
func detach(_ context.Context) context.Context {
return graceful.GetManager().ShutdownContext()
}
+322
View File
@@ -0,0 +1,322 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project_events
import (
"context"
"sync"
"testing"
"time"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// capturedCall is one observed broadcast: the recipient uid set plus the
// constructed Event.
type capturedCall struct {
uids []int64
event *eventsource.Event
}
// installFakes swaps every package-level seam used by publishEvent for
// test doubles: a fake uid lister, a stubbed project lookup that
// returns a synthetic project (no DB hit), an "everyone passes" access
// checker, and a broadcaster that pushes calls onto a buffered channel.
//
// The returned restore func reverts every seam; defer it in the test.
func installFakes(t *testing.T, uids []int64) (<-chan capturedCall, func()) {
t.Helper()
calls := make(chan capturedCall, 16)
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := projectAccessChecker
origLookup := projectLookup
broadcastFn = func(uids []int64, event *eventsource.Event) {
calls <- capturedCall{uids: append([]int64(nil), uids...), event: event}
}
connectedUIDsLister = func() []int64 {
return append([]int64(nil), uids...)
}
projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
return &project_model.Project{ID: id}, nil
}
projectAccessChecker = func(_ context.Context, _ int64, _ *project_model.Project) (bool, error) {
return true, nil
}
return calls, func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
projectAccessChecker = origChecker
projectLookup = origLookup
}
}
// awaitCall blocks until one capturedCall arrives or the test deadline
// elapses. It fails the test on timeout.
func awaitCall(t *testing.T, ch <-chan capturedCall) capturedCall {
t.Helper()
select {
case c := <-ch:
return c
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for broadcast")
return capturedCall{}
}
}
func TestEventNameFormat(t *testing.T) {
assert.Equal(t, "project-board.42", eventName(42))
assert.Equal(t, "project-board.0", eventName(0))
}
func TestPublishHelpers_NameAndPayload(t *testing.T) {
cases := []struct {
name string
invoke func(ctx context.Context)
wantName string
wantData any
}{
{
name: "card.moved",
wantName: "project-board.10",
invoke: func(ctx context.Context) {
PublishCardMoved(ctx, CardMoved{
ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3,
})
},
wantData: CardMoved{ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3},
},
{
name: "card.linked",
wantName: "project-board.11",
invoke: func(ctx context.Context) {
PublishCardLinked(ctx, CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9})
},
wantData: CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9},
},
{
name: "card.unlinked",
wantName: "project-board.12",
invoke: func(ctx context.Context) {
PublishCardUnlinked(ctx, CardUnlinked{ProjectID: 12, IssueID: 8})
},
wantData: CardUnlinked{ProjectID: 12, IssueID: 8},
},
{
name: "column.created",
wantName: "project-board.13",
invoke: func(ctx context.Context) {
PublishColumnCreated(ctx, ColumnCreated{
ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true,
})
},
wantData: ColumnCreated{
ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true,
},
},
{
name: "column.updated",
wantName: "project-board.14",
invoke: func(ctx context.Context) {
PublishColumnUpdated(ctx, ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"})
},
wantData: ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"},
},
{
name: "column.deleted",
wantName: "project-board.15",
invoke: func(ctx context.Context) {
PublishColumnDeleted(ctx, ColumnDeleted{ProjectID: 15, ColumnID: 5})
},
wantData: ColumnDeleted{ProjectID: 15, ColumnID: 5},
},
{
name: "column.reordered",
wantName: "project-board.16",
invoke: func(ctx context.Context) {
PublishColumnReordered(ctx, ColumnReordered{
ProjectID: 16,
Columns: []ColumnSort{
{ColumnID: 1, Sorting: 0},
{ColumnID: 2, Sorting: 1},
},
})
},
wantData: ColumnReordered{
ProjectID: 16,
Columns: []ColumnSort{
{ColumnID: 1, Sorting: 0},
{ColumnID: 2, Sorting: 1},
},
},
},
{
name: "project.updated",
wantName: "project-board.17",
invoke: func(ctx context.Context) {
PublishProjectUpdated(ctx, ProjectUpdated{
ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false,
})
},
wantData: ProjectUpdated{
ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false,
},
},
{
name: "project.deleted",
wantName: "project-board.18",
invoke: func(ctx context.Context) {
PublishProjectDeleted(ctx, ProjectDeleted{ProjectID: 18})
},
wantData: ProjectDeleted{ProjectID: 18},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ch, restore := installFakes(t, []int64{1})
defer restore()
tc.invoke(context.Background())
c := awaitCall(t, ch)
assert.Equal(t, tc.wantName, c.event.Name)
gotJSON, ok := c.event.Data.([]byte)
require.True(t, ok, "Event.Data should be []byte")
wantJSON, err := json.Marshal(tc.wantData)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(gotJSON))
})
}
}
// TestSessionTagPropagation verifies that when a publish is invoked
// inside a context decorated by WithSessionTag, the emitted JSON
// payload carries the tag.
func TestSessionTagPropagation(t *testing.T) {
ch, restore := installFakes(t, []int64{1})
defer restore()
ctx := WithSessionTag(context.Background(), "abc-123")
PublishCardMoved(ctx, CardMoved{
ProjectID: 99, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0,
})
c := awaitCall(t, ch)
var payload CardMoved
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "abc-123", payload.SessionTag)
}
// TestSessionTagExplicitOverridesContext verifies that an explicit
// SessionTag set on the payload struct is preserved.
func TestSessionTagExplicitOverridesContext(t *testing.T) {
ch, restore := installFakes(t, []int64{1})
defer restore()
ctx := WithSessionTag(context.Background(), "from-ctx")
PublishCardMoved(ctx, CardMoved{
ProjectID: 1, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0,
SessionTag: "explicit",
})
c := awaitCall(t, ch)
var payload CardMoved
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "explicit", payload.SessionTag)
}
// TestConnectedUIDsWithProjectAccess_FiltersByPermission ensures the
// helper drops uids the access checker rejects.
func TestConnectedUIDsWithProjectAccess_FiltersByPermission(t *testing.T) {
origLister := connectedUIDsLister
origChecker := projectAccessChecker
origLookup := projectLookup
defer func() {
connectedUIDsLister = origLister
projectAccessChecker = origChecker
projectLookup = origLookup
}()
connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} }
projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
return &project_model.Project{ID: id}, nil
}
allowed := map[int64]bool{1: true, 3: true}
projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) {
return allowed[uid], nil
}
got := connectedUIDsWithProjectAccess(context.Background(), 42)
assert.ElementsMatch(t, []int64{1, 3}, got)
}
// TestConnectedUIDsWithProjectAccess_NoConnections shortcuts when no
// users are connected; the project lookup must not be called.
func TestConnectedUIDsWithProjectAccess_NoConnections(t *testing.T) {
origLister := connectedUIDsLister
origLookup := projectLookup
defer func() {
connectedUIDsLister = origLister
projectLookup = origLookup
}()
connectedUIDsLister = func() []int64 { return nil }
called := false
projectLookup = func(_ context.Context, _ int64) (*project_model.Project, error) {
called = true
return &project_model.Project{}, nil
}
got := connectedUIDsWithProjectAccess(context.Background(), 42)
assert.Empty(t, got)
assert.False(t, called, "project lookup should be skipped when no uids are connected")
}
// TestPublishEvent_BroadcastsToAllowedUIDs exercises publishEvent
// directly to verify the uid set computed by the access filter is
// what gets handed to broadcastFn.
func TestPublishEvent_BroadcastsToAllowedUIDs(t *testing.T) {
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := projectAccessChecker
origLookup := projectLookup
defer func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
projectAccessChecker = origChecker
projectLookup = origLookup
}()
var mu sync.Mutex
var got []int64
broadcastFn = func(uids []int64, _ *eventsource.Event) {
mu.Lock()
got = append([]int64(nil), uids...)
mu.Unlock()
}
connectedUIDsLister = func() []int64 { return []int64{10, 20, 30} }
projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
return &project_model.Project{ID: id}, nil
}
projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) {
return uid != 20, nil
}
publishEvent(context.Background(), 1, ColumnDeleted{ProjectID: 1, ColumnID: 5})
mu.Lock()
defer mu.Unlock()
assert.ElementsMatch(t, []int64{10, 30}, got)
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/services/project_events"
)
// CreateColumn inserts a new column into a project and publishes a
// ColumnCreated event. Routers should call this instead of
// project_model.NewColumn so the SSE side-effect fires uniformly across
// repo, user, and org scopes.
func CreateColumn(ctx context.Context, column *project_model.Column) error {
if err := project_model.NewColumn(ctx, column); err != nil {
return err
}
project_events.PublishColumnCreated(ctx, project_events.ColumnCreated{
ProjectID: column.ProjectID,
ColumnID: column.ID,
Title: column.Title,
Color: column.Color,
Sorting: int64(column.Sorting),
IsDefault: column.Default,
})
return nil
}
// EditColumn updates a column and publishes a ColumnUpdated event.
func EditColumn(ctx context.Context, column *project_model.Column) error {
if err := project_model.UpdateColumn(ctx, column); err != nil {
return err
}
project_events.PublishColumnUpdated(ctx, project_events.ColumnUpdated{
ProjectID: column.ProjectID,
ColumnID: column.ID,
Title: column.Title,
Color: column.Color,
Sorting: int64(column.Sorting),
})
return nil
}
// DeleteColumn removes a column from a project and publishes the
// matching ColumnDeleted event. The model layer also moves the
// column's issues to the project's default column; we publish those
// individual moves so receiving tabs can patch the DOM without a full
// reload. We snapshot affected issues *before* the delete so we have
// their ids; the destination column id is resolved after.
func DeleteColumn(ctx context.Context, columnID int64) error {
// Snapshot the column + its issues so we know what to publish
// after the delete commits. Errors here are non-fatal: we still
// run the delete, and just skip per-issue events.
col, snapErr := project_model.GetColumn(ctx, columnID)
var (
projectID int64
movedIssues []int64
)
if snapErr == nil {
projectID = col.ProjectID
issues, err := col.GetIssues(ctx)
if err == nil {
movedIssues = make([]int64, 0, len(issues))
for _, pi := range issues {
movedIssues = append(movedIssues, pi.IssueID)
}
}
}
if err := project_model.DeleteColumnByID(ctx, columnID); err != nil {
return err
}
if snapErr != nil || projectID == 0 {
return nil
}
project_events.PublishColumnDeleted(ctx, project_events.ColumnDeleted{
ProjectID: projectID,
ColumnID: columnID,
})
// Resolve the new (default) column to attach to the per-issue
// CardMoved events. Failures here are tolerated; the frontend
// already knows the column is gone and will simply render its
// next refresh as authoritative.
project, err := project_model.GetProjectByID(ctx, projectID)
if err != nil {
return nil
}
defaultCol, err := project.MustDefaultColumn(ctx)
if err != nil {
return nil
}
for _, issueID := range movedIssues {
project_events.PublishCardMoved(ctx, project_events.CardMoved{
ProjectID: projectID,
IssueID: issueID,
FromColumnID: columnID,
ToColumnID: defaultCol.ID,
})
}
return nil
}
// ReorderColumns persists a new sort order for project columns and
// publishes a ColumnReordered batch event.
func ReorderColumns(ctx context.Context, project *project_model.Project, sortedColumnIDs map[int64]int64) error {
if err := project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
return err
}
cols := make([]project_events.ColumnSort, 0, len(sortedColumnIDs))
for sorting, columnID := range sortedColumnIDs {
cols = append(cols, project_events.ColumnSort{
ColumnID: columnID,
Sorting: sorting,
})
}
project_events.PublishColumnReordered(ctx, project_events.ColumnReordered{
ProjectID: project.ID,
Columns: cols,
})
return nil
}
// DeleteProject deletes a project and publishes a ProjectDeleted event.
func DeleteProject(ctx context.Context, projectID int64) error {
if err := project_model.DeleteProjectByID(ctx, projectID); err != nil {
return err
}
project_events.PublishProjectDeleted(ctx, project_events.ProjectDeleted{
ProjectID: projectID,
})
return nil
}
// publishProjectUpdated emits a ProjectUpdated event for the current
// state of the given project. It is exported via UpdateProject in
// project.go after the txn commits.
func publishProjectUpdated(ctx context.Context, project *project_model.Project) {
project_events.PublishProjectUpdated(ctx, project_events.ProjectUpdated{
ProjectID: project.ID,
Title: project.Title,
Description: project.Description,
CardType: convert.ProjectCardTypeToString(project.CardType),
IsClosed: project.IsClosed,
})
}
+117 -14
View File
@@ -15,11 +15,23 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/services/project_events"
)
// ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move
// issues that aren't yet attached to the column's project.
var ErrIssueNotInProject = errors.New("all issues have to be added to a project first")
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
// movedEvents accumulates one CardMoved per issue we touch so they
// can be published after the transaction commits successfully.
// We capture the from-column inside the txn (cheap extra query)
// and emit *all* moves, including same-column reorders, so the
// frontend can update sorting without re-fetching the whole column.
var movedEvents []project_events.CardMoved
err := db.WithTx(ctx, func(ctx context.Context) error {
movedEvents = movedEvents[:0]
issueIDs := make([]int64, 0, len(sortedIssueIDs))
for _, issueID := range sortedIssueIDs {
issueIDs = append(issueIDs, issueID)
@@ -32,7 +44,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
return err
}
if int(count) != len(sortedIssueIDs) {
return errors.New("all issues have to be added to a project first")
return ErrIssueNotInProject
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
@@ -63,7 +75,6 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
if err != nil {
return err
}
projectColumnID := projectColumnMap[column.ProjectID]
if projectColumnID != column.ID {
@@ -82,22 +93,114 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
}
}
// Update the column and sorting for this specific issue in this specific project.
// IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure
// that moving an issue's column in one project doesn't affect its column in other
// projects when the issue is assigned to multiple projects.
_, err = db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
Update(map[string]any{
"project_board_id": column.ID,
"sorting": sorting,
})
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
if err != nil {
return err
}
movedEvents = append(movedEvents, project_events.CardMoved{
ProjectID: column.ProjectID,
IssueID: issueID,
FromColumnID: projectColumnID,
ToColumnID: column.ID,
Sorting: sorting,
})
}
return nil
})
if err != nil {
return err
}
for _, ev := range movedEvents {
project_events.PublishCardMoved(ctx, ev)
}
return nil
}
// AssignOrRemoveProjects updates the projects associated with an issue
// (delegating to issues_model.IssueAssignOrRemoveProject) and publishes
// SSE events for each link/unlink so other tabs viewing the relevant
// project boards can update without a reload.
//
// Routers should prefer this helper over calling the model function
// directly so the publish side-effects fire at every call site.
func AssignOrRemoveProjects(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, newProjectIDs []int64) error {
// Snapshot the current project ids before the update so we can
// compute the link/unlink diff. If this read fails we just skip
// publishing — the user-visible operation still succeeds.
oldProjectIDs, snapErr := issueProjectIDs(ctx, issue.ID)
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, newProjectIDs); err != nil {
return err
}
if snapErr != nil {
return nil
}
added, removed := diffInt64Slices(oldProjectIDs, newProjectIDs)
for _, pid := range removed {
project_events.PublishCardUnlinked(ctx, project_events.CardUnlinked{
ProjectID: pid,
IssueID: issue.ID,
})
}
// For additions we want to surface the destination column so the
// receiving tab can refetch only that column's contents. The model
// function places newly added issues in each project's default
// column; re-derive that here.
for _, pid := range added {
project, err := project_model.GetProjectByID(ctx, pid)
if err != nil {
continue
}
col, err := project.MustDefaultColumn(ctx)
if err != nil {
continue
}
project_events.PublishCardLinked(ctx, project_events.CardLinked{
ProjectID: pid,
IssueID: issue.ID,
ColumnID: col.ID,
})
}
return nil
}
// issueProjectIDs reads the set of project ids currently linked to issue.
// Mirrors models/issues/(*Issue).projectIDs but lives at the service layer
// so we can keep the model surface untouched.
func issueProjectIDs(ctx context.Context, issueID int64) ([]int64, error) {
var ids []int64
err := db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ?", issueID).
Cols("project_id").
Find(&ids)
return ids, err
}
// diffInt64Slices returns the elements present in `b` but missing in `a`
// (added) and the elements present in `a` but missing in `b` (removed).
// Both inputs are treated as sets.
func diffInt64Slices(a, b []int64) (added, removed []int64) {
inA := make(map[int64]struct{}, len(a))
for _, v := range a {
inA[v] = struct{}{}
}
inB := make(map[int64]struct{}, len(b))
for _, v := range b {
inB[v] = struct{}{}
}
for _, v := range b {
if _, ok := inA[v]; !ok {
added = append(added, v)
}
}
for _, v := range a {
if _, ok := inB[v]; !ok {
removed = append(removed, v)
}
}
return added, removed
}
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) {
@@ -129,7 +232,7 @@ func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issu
func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) {
issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
o.ProjectIDs = []int64{project.ID}
o.SortType = "project-column-sorting"
o.SortType = issues_model.SortTypeProjectColumnSorting
}))
if err != nil {
return nil, err
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"code.gitea.io/gitea/models/db"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/optional"
)
// UpdateProjectOptions represents updatable project fields. Fields with no value are left unchanged.
type UpdateProjectOptions struct {
Title optional.Option[string]
Description optional.Option[string]
CardType optional.Option[project_model.CardType]
IsClosed optional.Option[bool]
}
// UpdateProject applies the provided options to the project atomically
// and emits a ProjectUpdated SSE event when the txn commits.
func UpdateProject(ctx context.Context, project *project_model.Project, opts UpdateProjectOptions) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if opts.Title.Has() {
project.Title = opts.Title.Value()
}
if opts.Description.Has() {
project.Description = opts.Description.Value()
}
if opts.CardType.Has() {
project.CardType = opts.CardType.Value()
}
if err := project_model.UpdateProject(ctx, project); err != nil {
return err
}
if opts.IsClosed.Has() && opts.IsClosed.Value() != project.IsClosed {
if err := project_model.ChangeProjectStatus(ctx, project, opts.IsClosed.Value()); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
publishProjectUpdated(ctx, project)
return nil
}
+16 -1
View File
@@ -1,4 +1,13 @@
{{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}}
{{/* $projectScope is read by the SSE handler to build the right column-issues
refetch URL (repo / org / user). RepoID > 0 wins over Owner so a repo
project nested under an owner is still treated as repo-scoped. */}}
{{$projectScope := "user"}}
{{if .Repository}}{{$projectScope = "repo"}}{{else if and .ContextUser .ContextUser.IsOrganization}}{{$projectScope = "org"}}{{end}}
{{$projectOwnerName := ""}}
{{if and .Repository .Repository.Owner}}{{$projectOwnerName = .Repository.Owner.Name}}{{else if .ContextUser}}{{$projectOwnerName = .ContextUser.Name}}{{end}}
{{$projectRepoName := ""}}
{{if .Repository}}{{$projectRepoName = .Repository.Name}}{{end}}
<div class="ui container fluid padded projects-view" data-global-init="initRepoProjectsView">
<div class="ui container flex-text-block project-header">
@@ -77,7 +86,13 @@
<div class="divider"></div>
</div>
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}" data-project-board-writable="{{$canWriteProject}}" {{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}"
data-project-board-writable="{{$canWriteProject}}"
data-project-id="{{.Project.ID}}"
data-project-scope="{{$projectScope}}"
data-project-owner="{{$projectOwnerName}}"
data-project-repo="{{$projectRepoName}}"
{{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
{{range .Columns}}
<div class="project-column" {{if .Color}}style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
+2 -2
View File
@@ -210,8 +210,8 @@
{{template "repo/issue/new_form" .}}
</div>
{{end}}
{{else}}{{/* not singed-in or not for pull-request */}}
{{if not .CommitCount}}
{{else}}{{/* not signed-in or not for pull-request */}}
{{if and (not .CommitCount) $.CompareInfo.CompareBase}}
<div class="ui segment">{{ctx.Locale.Tr "repo.commits.nothing_to_compare"}}</div>
{{end}}
{{end}}
+2315 -19
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {apiCreateRepo, apiCreateIssue, assertNoJsError, randomString} from './utils.ts';
test('mermaid diagram in issue', async ({page, request}) => {
const repoName = `e2e-mermaid-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
const body = '```mermaid\nflowchart LR\n Alpha --> Beta\n Beta --> Gamma\n```\n';
const {index} = await apiCreateIssue(request, {owner, repo: repoName, title: 'mermaid test', body});
await page.goto(`/${owner}/${repoName}/issues/${index}`);
const svg = page.frameLocator('iframe.markup-content-iframe').locator('svg');
await expect(svg).toContainText(/Alpha[\s\S]*Beta[\s\S]*Gamma/);
await assertNoJsError(page);
});
@@ -0,0 +1,77 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/url"
"os"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/dbfs"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/storage"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Regression for https://gitea.com/gitea/runner/issues/950: a runner that
// finalizes a task with no log output sends UpdateLog{Rows:[], NoMore:true}.
// The previous short-circuit on len(Rows)==0 skipped TransferLogs, leaving
// an orphan dbfs_data row. Verify the row is now archived and removed.
func TestActionsLogFinalizeWithoutRows(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-finalize-no-rows", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
const wfTreePath = ".gitea/workflows/finalize-no-rows.yml"
wfFileContent := fmt.Sprintf(`name: finalize-no-rows
on:
push:
paths:
- '%s'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: noop
`, wfTreePath)
createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "trigger", wfFileContent))
task := runner.fetchTask(t)
resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{
TaskId: task.Id,
Index: 0,
Rows: nil,
NoMore: true,
}))
require.NoError(t, err)
assert.EqualValues(t, 0, resp.Msg.AckIndex)
freshTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.True(t, freshTask.LogInStorage, "log_in_storage must flip after empty NoMore=true")
_, err = storage.Actions.Stat(freshTask.LogFilename)
assert.NoError(t, err, "archived log must exist in storage")
_, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename)
assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs")
})
}
+527
View File
@@ -0,0 +1,527 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIOrgProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("ListProjects", testAPIOrgListProjects)
t.Run("GetProject", testAPIOrgGetProject)
t.Run("CreateProject", testAPIOrgCreateProject)
t.Run("UpdateProject", testAPIOrgUpdateProject)
t.Run("ChangeProjectStatus", testAPIOrgChangeProjectStatus)
t.Run("DeleteProject", testAPIOrgDeleteProject)
t.Run("ListProjectColumns", testAPIOrgListProjectColumns)
t.Run("CreateProjectColumn", testAPIOrgCreateProjectColumn)
t.Run("UpdateProjectColumn", testAPIOrgUpdateProjectColumn)
t.Run("DeleteProjectColumn", testAPIOrgDeleteProjectColumn)
t.Run("AddIssueToProjectColumn", testAPIOrgAddIssueToProjectColumn)
t.Run("RemoveIssueFromProjectColumn", testAPIOrgRemoveIssueFromProjectColumn)
t.Run("ListProjectColumnIssues", testAPIOrgListProjectColumnIssues)
t.Run("MoveProjectIssue", testAPIOrgMoveProjectIssue)
t.Run("Permissions", testAPIOrgProjectPermissions)
}
// makeOrgProject creates a TypeOrganization project owned by the named org.
// org3 (id=3) is used throughout these tests. Per fixtures/org_user.yml,
// user2 (id=2) and user4 (id=4) are members; user5 is not.
func makeOrgProject(t *testing.T, orgName string, creatorID int64) *project_model.Project {
t.Helper()
org, err := organization.GetOrgByName(t.Context(), orgName)
assert.NoError(t, err)
p := &project_model.Project{
OwnerID: org.ID,
Title: "Test Org Project",
Description: "created by test helper",
CreatorID: creatorID,
Type: project_model.TypeOrganization,
TemplateType: project_model.TemplateTypeNone,
}
assert.NoError(t, project_model.NewProject(t.Context(), p))
return p
}
func testAPIOrgListProjects(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
req := NewRequest(t, "GET", "/api/v1/orgs/org3/projects").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var projects []*api.Project
DecodeJSON(t, resp, &projects)
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects?state=open").AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &projects)
for _, p := range projects {
assert.Equal(t, api.StateOpen, p.State)
}
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects?state=all").AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
func testAPIOrgGetProject(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, p.ID, got.ID)
assert.Equal(t, p.Title, got.Title)
assert.Equal(t, "organization", got.Type)
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects/99999").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgCreateProject(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{
Title: "Org API Project",
Description: "desc",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, "Org API Project", got.Title)
assert.Equal(t, "organization", got.Type)
// unauthenticated
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: "x"})
MakeRequest(t, req, http.StatusUnauthorized)
// empty title
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: ""}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// non-member must be forbidden (user5 is not in org3)
nonMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
nmToken := getUserToken(t, nonMember.Name, auth_model.AccessTokenScopeWriteIssue)
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: "x"}).AddTokenAuth(nmToken)
MakeRequest(t, req, http.StatusForbidden)
}
func testAPIOrgUpdateProject(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
newTitle := "Updated Org Project"
newDesc := "Updated desc"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
Title: &newTitle,
Description: &newDesc,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, newTitle, got.Title)
assert.Equal(t, newDesc, got.Description)
// non-existent project
req = NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3/projects/99999", &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgChangeProjectStatus(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
closed := api.StateClosed
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
State: &closed,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, api.StateClosed, got.State)
assert.NotNil(t, got.ClosedAt)
open := api.StateOpen
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
State: &open,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &got)
assert.Equal(t, api.StateOpen, got.State)
bogus := api.StateType("reopen")
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
State: &bogus,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func testAPIOrgDeleteProject(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgListProjectColumns(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
for i := 1; i <= 3; i++ {
col := &project_model.Column{
Title: fmt.Sprintf("OrgCol%d", i),
ProjectID: p.ID,
CreatorID: member.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
}
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", p.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var cols []*api.ProjectColumn
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 3)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns?page=1&limit=2", p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 2)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns?page=2&limit=2", p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 1)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
// non-existent project
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/99999/columns").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgCreateProjectColumn(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
Title: "OrgCol",
Color: "#123456",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var col api.ProjectColumn
DecodeJSON(t, resp, &col)
assert.Equal(t, "OrgCol", col.Title)
assert.Equal(t, "#123456", col.Color)
assert.Equal(t, p.ID, col.ProjectID)
// no color
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
Title: "Plain",
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &col)
assert.Equal(t, "Plain", col.Title)
// empty title
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
Title: "",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// non-existent project
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects/99999/columns", &api.CreateProjectColumnOption{
Title: "Orphan",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgUpdateProjectColumn(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
col := &project_model.Column{
Title: "Orig",
ProjectID: p.ID,
CreatorID: member.ID,
Color: "#000000",
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
newTitle := "Changed"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.ProjectColumn
DecodeJSON(t, resp, &got)
assert.Equal(t, newTitle, got.Title)
newColor := "#FF0000"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID), &api.EditProjectColumnOption{
Color: &newColor,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &got)
assert.Equal(t, newColor, got.Color)
// non-existent column
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/99999", p.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgDeleteProjectColumn(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeOrgProject(t, "org3", member.ID)
col := &project_model.Column{
Title: "ToDelete",
ProjectID: p.ID,
CreatorID: member.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgAddIssueToProjectColumn(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
p := makeOrgProject(t, "org3", member.ID)
col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col1))
col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col2))
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
// add to col1
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col1.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, col1.ID, pi.ProjectColumnID)
// move to col2 via POST
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col2.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
pi = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, col2.ID, pi.ProjectColumnID)
// idempotent: add to same column again
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col2.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// non-existent issue
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/99999", p.ID, col1.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// non-existent column
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/99999/issues/%d", p.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgRemoveIssueFromProjectColumn(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
p := makeOrgProject(t, "org3", member.ID)
col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), otherCol))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, member, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, col, map[int64]int64{0: issue.ID}))
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
// removing via the wrong column must 404 and not detach the issue
req := NewRequestWithJSON(t, "DELETE",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, otherCol.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
// correct column fully detaches the issue from the project
req = NewRequestWithJSON(t, "DELETE",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
}
func testAPIOrgListProjectColumnIssues(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
p := makeOrgProject(t, "org3", member.ID)
colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, member, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colA, map[int64]int64{0: issueA.ID}))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, member, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colB, map[int64]int64{0: issueB.ID}))
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
// colA contains only issueA
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", p.ID, colA.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var gotA []api.Issue
DecodeJSON(t, resp, &gotA)
assert.Len(t, gotA, 1)
assert.Equal(t, issueA.ID, gotA[0].ID)
// colB contains only issueB
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", p.ID, colB.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotB []api.Issue
DecodeJSON(t, resp, &gotB)
assert.Len(t, gotB, 1)
assert.Equal(t, issueB.ID, gotB[0].ID)
}
func testAPIOrgMoveProjectIssue(t *testing.T) {
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
p := makeOrgProject(t, "org3", member.ID)
colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: member.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, member, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colA, map[int64]int64{0: issue.ID}))
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
// move to colB
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", p.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: colB.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, colB.ID, pi.ProjectColumnID)
// non-existent target column -> 422
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", p.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: 99999},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// issue not in project -> 404
otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", p.ID, otherIssue.ID),
&api.MoveProjectIssueOption{ColumnID: colA.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIOrgProjectPermissions(t *testing.T) {
// org3 members per fixtures: user2 (id=2), user4 (id=4). Non-member: user5.
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
nonMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
p := makeOrgProject(t, "org3", member.ID)
memberToken := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
nmToken := getUserToken(t, nonMember.Name, auth_model.AccessTokenScopeWriteIssue)
adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue)
// anon can list
req := NewRequest(t, "GET", "/api/v1/orgs/org3/projects")
MakeRequest(t, req, http.StatusOK)
// member can read
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(memberToken)
MakeRequest(t, req, http.StatusOK)
// non-member cannot write
newTitle := "By NonMember"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(nmToken)
MakeRequest(t, req, http.StatusForbidden)
// non-member cannot delete
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(nmToken)
MakeRequest(t, req, http.StatusForbidden)
// non-member cannot create column
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
Title: "By NonMember",
}).AddTokenAuth(nmToken)
MakeRequest(t, req, http.StatusForbidden)
// member can write
newTitle = "By Member"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(memberToken)
MakeRequest(t, req, http.StatusOK)
// admin can write
newTitle = "By Admin"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(adminToken)
MakeRequest(t, req, http.StatusOK)
}
+757
View File
@@ -0,0 +1,757 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("ListProjects", testAPIListProjects)
t.Run("GetProject", testAPIGetProject)
t.Run("CreateProject", testAPICreateProject)
t.Run("UpdateProject", testAPIUpdateProject)
t.Run("ChangeProjectStatus", testAPIChangeProjectStatus)
t.Run("DeleteProject", testAPIDeleteProject)
t.Run("ListProjectColumns", testAPIListProjectColumns)
t.Run("CreateProjectColumn", testAPICreateProjectColumn)
t.Run("UpdateProjectColumn", testAPIUpdateProjectColumn)
t.Run("DeleteProjectColumn", testAPIDeleteProjectColumn)
t.Run("AddIssueToProjectColumn", testAPIAddIssueToProjectColumn)
t.Run("RemoveIssueFromProjectColumn", testAPIRemoveIssueFromProjectColumn)
t.Run("ListProjectColumnIssues", testAPIListProjectColumnIssues)
t.Run("MoveProjectIssue", testAPIMoveProjectIssue)
t.Run("Permissions", testAPIProjectPermissions)
}
func testAPIMoveProjectIssue(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
project := &project_model.Project{
Title: "Project for MoveIssue",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
colA := &project_model.Column{Title: "A", ProjectID: project.ID, CreatorID: owner.ID}
err = project_model.NewColumn(t.Context(), colA)
assert.NoError(t, err)
colB := &project_model.Column{Title: "B", ProjectID: project.ID, CreatorID: owner.ID}
err = project_model.NewColumn(t.Context(), colB)
assert.NoError(t, err)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID})
assert.NoError(t, err)
err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID})
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
&api.MoveProjectIssueOption{ColumnID: colB.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: project.ID, IssueID: issueA.ID})
assert.Equal(t, colB.ID, pi.ProjectColumnID)
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
&api.MoveProjectIssueOption{ColumnID: 99999},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueB.ID),
&api.MoveProjectIssueOption{ColumnID: colA.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIListProjects(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
// Test listing all projects
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects", owner.Name, repo.Name).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var projects []*api.Project
DecodeJSON(t, resp, &projects)
// Test state filter - open
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=open", owner.Name, repo.Name).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &projects)
for _, project := range projects {
assert.Equal(t, api.StateOpen, project.State, "Project should be open")
}
// Test state filter - all
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=all", owner.Name, repo.Name).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &projects)
// Test pagination
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?page=1&limit=5", owner.Name, repo.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
func testAPIGetProject(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Create a test project
project := &project_model.Project{
Title: "Test Project for API",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
// Test getting the project
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiProject api.Project
DecodeJSON(t, resp, &apiProject)
assert.Equal(t, project.Title, apiProject.Title)
assert.Equal(t, project.ID, apiProject.ID)
assert.Equal(t, repo.ID, apiProject.RepoID)
assert.NotEmpty(t, apiProject.HTMLURL)
// Test getting non-existent project
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPICreateProject(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Test creating a project
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
Title: "API Created Project",
Description: "This is a test project created via API",
TemplateType: "basic_kanban",
CardType: "images_and_text",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var project api.Project
DecodeJSON(t, resp, &project)
assert.Equal(t, "API Created Project", project.Title)
assert.Equal(t, "This is a test project created via API", project.Description)
assert.Equal(t, "basic_kanban", project.TemplateType)
assert.Equal(t, "images_and_text", project.CardType)
assert.Equal(t, api.StateOpen, project.State)
// Test creating with minimal data
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
Title: "Minimal Project",
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
var minimalProject api.Project
DecodeJSON(t, resp, &minimalProject)
assert.Equal(t, "Minimal Project", minimalProject.Title)
// Test creating without authentication
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
Title: "Unauthorized Project",
})
MakeRequest(t, req, http.StatusUnauthorized)
// Test creating with invalid data (empty title)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
Title: "",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func testAPIUpdateProject(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Create a test project
project := &project_model.Project{
Title: "Project to Update",
Description: "Original description",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Test updating project title and description
newTitle := "Updated Project Title"
newDesc := "Updated description"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
Title: &newTitle,
Description: &newDesc,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var updatedProject api.Project
DecodeJSON(t, resp, &updatedProject)
assert.Equal(t, newTitle, updatedProject.Title)
assert.Equal(t, newDesc, updatedProject.Description)
// Test updating non-existent project
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIChangeProjectStatus(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
project := &project_model.Project{
Title: "Project to Close",
Description: "Project to close and reopen",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
closed := api.StateClosed
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
State: &closed,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var updatedProject api.Project
DecodeJSON(t, resp, &updatedProject)
assert.Equal(t, api.StateClosed, updatedProject.State)
assert.NotNil(t, updatedProject.ClosedAt)
open := api.StateOpen
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
State: &open,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &updatedProject)
assert.Equal(t, api.StateOpen, updatedProject.State)
bogus := api.StateType("reopen")
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
State: &bogus,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func testAPIDeleteProject(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Create a test project
project := &project_model.Project{
Title: "Project to Delete",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Test deleting the project
req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// Test deleting non-existent project (including the one we just deleted)
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIListProjectColumns(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Create a test project
project := &project_model.Project{
Title: "Project for Columns Test",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
// Create test columns
for i := 1; i <= 3; i++ {
column := &project_model.Column{
Title: fmt.Sprintf("Column %d", i),
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), column)
assert.NoError(t, err)
}
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
// Test listing all columns — X-Total-Count must equal 3
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var columns []*api.ProjectColumn
DecodeJSON(t, resp, &columns)
assert.Len(t, columns, 3)
assert.Equal(t, "Column 1", columns[0].Title)
assert.Equal(t, "Column 2", columns[1].Title)
assert.Equal(t, "Column 3", columns[2].Title)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
// Test pagination: page 1 with limit 2 returns first 2 columns, total count still 3
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=1&limit=2", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &columns)
assert.Len(t, columns, 2)
assert.Equal(t, "Column 1", columns[0].Title)
assert.Equal(t, "Column 2", columns[1].Title)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
// Test pagination: page 2 with limit 2 returns remaining column
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=2&limit=2", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &columns)
assert.Len(t, columns, 1)
assert.Equal(t, "Column 3", columns[0].Title)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
// Test listing columns for non-existent project
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPICreateProjectColumn(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Create a test project
project := &project_model.Project{
Title: "Project for Column Creation",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Test creating a column with color
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
Title: "New Column",
Color: "#FF5733",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var column api.ProjectColumn
DecodeJSON(t, resp, &column)
assert.Equal(t, "New Column", column.Title)
assert.Equal(t, "#FF5733", column.Color)
assert.Equal(t, project.ID, column.ProjectID)
// Test creating a column without color
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
Title: "Simple Column",
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &column)
assert.Equal(t, "Simple Column", column.Title)
// Test creating with empty title
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
Title: "",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// Test creating for non-existent project
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name), &api.CreateProjectColumnOption{
Title: "Orphan Column",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUpdateProjectColumn(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Create a test project and column
project := &project_model.Project{
Title: "Project for Column Update",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
column := &project_model.Column{
Title: "Original Column",
ProjectID: project.ID,
CreatorID: owner.ID,
Color: "#000000",
}
err = project_model.NewColumn(t.Context(), column)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Test updating column title
newTitle := "Updated Column"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var updatedColumn api.ProjectColumn
DecodeJSON(t, resp, &updatedColumn)
assert.Equal(t, newTitle, updatedColumn.Title)
// Test updating column color
newColor := "#FF0000"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{
Color: &newColor,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &updatedColumn)
assert.Equal(t, newColor, updatedColumn.Color)
// Test updating non-existent column
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999", owner.Name, repo.Name, project.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIDeleteProjectColumn(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Create a test project and column
project := &project_model.Project{
Title: "Project for Column Deletion",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
column := &project_model.Column{
Title: "Column to Delete",
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), column)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Test deleting the column
req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// Test deleting non-existent column (including the one we just deleted)
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIAddIssueToProjectColumn(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
// Create a test project and column
project := &project_model.Project{
Title: "Project for Issue Assignment",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
column1 := &project_model.Column{
Title: "Column 1",
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), column1)
assert.NoError(t, err)
column2 := &project_model.Column{
Title: "Column 2",
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), column2)
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Test adding issue to column
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, issue.ID), nil).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// Verify issue is in the column
projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
ProjectID: project.ID,
IssueID: issue.ID,
})
assert.Equal(t, column1.ID, projectIssue.ProjectColumnID)
// Test moving issue to another column
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// Verify issue moved to new column
projectIssue = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
ProjectID: project.ID,
IssueID: issue.ID,
})
assert.Equal(t, column2.ID, projectIssue.ProjectColumnID)
// Test adding same issue to same column (should be idempotent)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// Test adding non-existent issue
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, 99999), nil).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// Test adding to non-existent column
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999/issues/%d", owner.Name, repo.Name, project.ID, issue.ID), nil).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIListProjectColumnIssues(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
project := &project_model.Project{
Title: "Project for Column Issues",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
columnA := &project_model.Column{
Title: "Column A",
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), columnA)
assert.NoError(t, err)
columnB := &project_model.Column{
Title: "Column B",
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), columnB)
assert.NoError(t, err)
// Place issueA in columnA, issueB in columnB.
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID})
assert.NoError(t, err)
err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnA, map[int64]int64{0: issueA.ID})
assert.NoError(t, err)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{project.ID})
assert.NoError(t, err)
err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnB, map[int64]int64{0: issueB.ID})
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
// Column A should contain only issueA.
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var issuesA []api.Issue
DecodeJSON(t, resp, &issuesA)
assert.Len(t, issuesA, 1)
assert.Equal(t, issueA.ID, issuesA[0].ID)
// Column B should contain only issueB.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnB.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var issuesB []api.Issue
DecodeJSON(t, resp, &issuesB)
assert.Len(t, issuesB, 1)
assert.Equal(t, issueB.ID, issuesB[0].ID)
}
func testAPIRemoveIssueFromProjectColumn(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
project := &project_model.Project{
Title: "Project for Issue Removal",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
column := &project_model.Column{
Title: "Column for Issue Removal",
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), column)
assert.NoError(t, err)
otherColumn := &project_model.Column{
Title: "Other Column",
ProjectID: project.ID,
CreatorID: owner.ID,
}
err = project_model.NewColumn(t.Context(), otherColumn)
assert.NoError(t, err)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID})
assert.NoError(t, err)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// Removing via a column the issue does not live in must 404 and not detach the issue
req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, otherColumn.ID, issue.ID), nil).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
ProjectID: project.ID,
IssueID: issue.ID,
})
req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column.ID, issue.ID), nil).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{
ProjectID: project.ID,
IssueID: issue.ID,
})
}
func testAPIProjectPermissions(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
nonCollaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
// Create a test project
project := &project_model.Project{
Title: "Permission Test Project",
RepoID: repo.ID,
Type: project_model.TypeRepository,
CreatorID: owner.ID,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
nonCollaboratorToken := getUserToken(t, nonCollaborator.Name, auth_model.AccessTokenScopeWriteIssue)
// Owner should be able to read
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
AddTokenAuth(ownerToken)
MakeRequest(t, req, http.StatusOK)
// Owner should be able to update
newTitle := "Updated by Owner"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(ownerToken)
MakeRequest(t, req, http.StatusOK)
// Non-collaborator should not be able to update
anotherTitle := "Updated by Non-collaborator"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
Title: &anotherTitle,
}).AddTokenAuth(nonCollaboratorToken)
MakeRequest(t, req, http.StatusForbidden)
// Non-collaborator should not be able to delete
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
AddTokenAuth(nonCollaboratorToken)
MakeRequest(t, req, http.StatusForbidden)
}
+497
View File
@@ -0,0 +1,497 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIUserProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("ListProjects", testAPIUserListProjects)
t.Run("GetProject", testAPIUserGetProject)
t.Run("CreateProject", testAPIUserCreateProject)
t.Run("UpdateProject", testAPIUserUpdateProject)
t.Run("ChangeProjectStatus", testAPIUserChangeProjectStatus)
t.Run("DeleteProject", testAPIUserDeleteProject)
t.Run("ListProjectColumns", testAPIUserListProjectColumns)
t.Run("CreateProjectColumn", testAPIUserCreateProjectColumn)
t.Run("UpdateProjectColumn", testAPIUserUpdateProjectColumn)
t.Run("DeleteProjectColumn", testAPIUserDeleteProjectColumn)
t.Run("AddIssueToProjectColumn", testAPIUserAddIssueToProjectColumn)
t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn)
t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues)
t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue)
t.Run("Permissions", testAPIUserProjectPermissions)
}
func makeUserProject(t *testing.T, owner *user_model.User) *project_model.Project {
t.Helper()
p := &project_model.Project{
OwnerID: owner.ID,
Title: "Test User Project",
Description: "created by test helper",
CreatorID: owner.ID,
Type: project_model.TypeIndividual,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), p)
assert.NoError(t, err)
return p
}
func testAPIUserListProjects(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects", owner.Name).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var projects []*api.Project
DecodeJSON(t, resp, &projects)
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=open", owner.Name).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &projects)
for _, p := range projects {
assert.Equal(t, api.StateOpen, p.State)
}
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=all", owner.Name).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
func testAPIUserGetProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, p.ID, got.ID)
assert.Equal(t, p.Title, got.Title)
assert.Equal(t, "individual", got.Type)
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/99999", owner.Name).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserCreateProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{
Title: "Created via API",
Description: "desc",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, "Created via API", got.Title)
assert.Equal(t, "individual", got.Type)
// unauthenticated
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: "x"})
MakeRequest(t, req, http.StatusUnauthorized)
// empty title
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: ""}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func testAPIUserUpdateProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
newTitle := "Updated Title"
newDesc := "Updated desc"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
Description: &newDesc,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, newTitle, got.Title)
assert.Equal(t, newDesc, got.Description)
}
func testAPIUserChangeProjectStatus(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
closed := api.StateClosed
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
State: &closed,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, api.StateClosed, got.State)
assert.NotNil(t, got.ClosedAt)
open := api.StateOpen
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
State: &open,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &got)
assert.Equal(t, api.StateOpen, got.State)
bogus := api.StateType("reopen")
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
State: &bogus,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func testAPIUserDeleteProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserListProjectColumns(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
for i := 1; i <= 3; i++ {
col := &project_model.Column{
Title: fmt.Sprintf("Col%d", i),
ProjectID: p.ID,
CreatorID: owner.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
}
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var cols []*api.ProjectColumn
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 3)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=1&limit=2", owner.Name, p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 2)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=2&limit=2", owner.Name, p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 1)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
}
func testAPIUserCreateProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
Title: "New Column",
Color: "#FF5733",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var col api.ProjectColumn
DecodeJSON(t, resp, &col)
assert.Equal(t, "New Column", col.Title)
assert.Equal(t, "#FF5733", col.Color)
assert.Equal(t, p.ID, col.ProjectID)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
Title: "Simple Column",
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &col)
assert.Equal(t, "Simple Column", col.Title)
// empty title
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
Title: "",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// non-existent project
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/99999/columns", owner.Name), &api.CreateProjectColumnOption{
Title: "Orphan",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserUpdateProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
col := &project_model.Column{
Title: "Original",
ProjectID: p.ID,
CreatorID: owner.ID,
Color: "#000000",
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
newTitle := "Updated Column"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.ProjectColumn
DecodeJSON(t, resp, &got)
assert.Equal(t, newTitle, got.Title)
newColor := "#FF0000"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
Color: &newColor,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &got)
assert.Equal(t, newColor, got.Color)
// non-existent column
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999", owner.Name, p.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserDeleteProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
col := &project_model.Column{
Title: "ToDelete",
ProjectID: p.ID,
CreatorID: owner.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserAddIssueToProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p := makeUserProject(t, owner)
col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col1))
col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col2))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// add to col1
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col1.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, col1.ID, pi.ProjectColumnID)
// move to col2 via POST
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
pi = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, col2.ID, pi.ProjectColumnID)
// idempotent: add to same column again
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// non-existent issue
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/99999", owner.Name, p.ID, col1.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// non-existent column
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999/issues/%d", owner.Name, p.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserRemoveIssueFromProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p := makeUserProject(t, owner)
col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), otherCol))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, col, map[int64]int64{0: issue.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// removing via the wrong column must 404 and not detach the issue
req := NewRequestWithJSON(t, "DELETE",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, otherCol.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
// correct column fully detaches the issue from the project
req = NewRequestWithJSON(t, "DELETE",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
}
func testAPIUserListProjectColumnIssues(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
p := makeUserProject(t, owner)
colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID}))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colB, map[int64]int64{0: issueB.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
// colA contains only issueA
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var gotA []api.Issue
DecodeJSON(t, resp, &gotA)
assert.Len(t, gotA, 1)
assert.Equal(t, issueA.ID, gotA[0].ID)
// colB contains only issueB
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colB.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotB []api.Issue
DecodeJSON(t, resp, &gotB)
assert.Len(t, gotB, 1)
assert.Equal(t, issueB.ID, gotB[0].ID)
}
func testAPIUserMoveProjectIssue(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p := makeUserProject(t, owner)
colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issue.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// move to colB
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: colB.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, colB.ID, pi.ProjectColumnID)
// non-existent target column -> 422
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: 99999},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// issue not in project -> 404
otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, otherIssue.ID),
&api.MoveProjectIssueOption{ColumnID: colA.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserProjectPermissions(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
p := makeUserProject(t, owner)
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
otherToken := getUserToken(t, other.Name, auth_model.AccessTokenScopeWriteIssue)
adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue)
// anon can read
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID)
MakeRequest(t, req, http.StatusOK)
// owner can write
newTitle := "By Owner"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(ownerToken)
MakeRequest(t, req, http.StatusOK)
// other user cannot write
newTitle = "By Other"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(otherToken)
MakeRequest(t, req, http.StatusForbidden)
// other user cannot delete
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(otherToken)
MakeRequest(t, req, http.StatusForbidden)
// admin can write
newTitle = "By Admin"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(adminToken)
MakeRequest(t, req, http.StatusOK)
}
+16 -28
View File
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
@@ -60,7 +61,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.NoError(t, err)
}
columns, err := project1.GetColumns(t.Context())
columns, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting)
@@ -80,7 +81,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
})
sess.MakeRequest(t, req, http.StatusOK)
columnsAfter, err := project1.GetColumns(t.Context())
columnsAfter, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
@@ -90,26 +91,6 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
}
func TestUpdateIssueProject(t *testing.T) {
defer tests.PrepareTestEnv(t)()
sess := loginUser(t, "user2")
t.Run("AssignAndRemove", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
"id": "1",
})
sess.MakeRequest(t, req, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
"id": "",
})
sess.MakeRequest(t, req, http.StatusOK)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
})
}
func TestUpdateIssueProjectColumn(t *testing.T) {
defer tests.PrepareTestEnv(t)()
@@ -158,7 +139,7 @@ func TestUpdateIssueProjectColumn(t *testing.T) {
Title: "other column",
ProjectID: project2.ID,
}))
columns, err := project2.GetColumns(t.Context())
columns, err := project_model.GetProjectColumns(t.Context(), project2.ID, db.ListOptionsAll)
require.NoError(t, err)
require.NotEmpty(t, columns)
@@ -180,13 +161,13 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
cards := htmlDoc.Find(".sidebar-project-card")
assert.Equal(t, 1, cards.Length())
title := cards.Find("a span.gt-ellipsis")
title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis")
assert.Contains(t, strings.TrimSpace(title.Text()), "First project")
columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo")
columnCombo := cards.Find(".sidebar-project-column-combo")
assert.Equal(t, 1, columnCombo.Length())
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
@@ -201,14 +182,16 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, "3", comboVal)
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""})
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{
"id": "0",
})
sess.MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
resp = sess.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
cards = htmlDoc.Find(".sidebar-project-card")
assert.Equal(t, 0, cards.Length())
}
@@ -311,6 +294,11 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
}
require.NoError(t, project_model.NewProject(t.Context(), &project))
// Get the default column
columns, err := project_model.GetProjectColumns(t.Context(), project.ID, db.ListOptionsAll)
require.NoError(t, err)
require.NotEmpty(t, columns)
// Add issues to the project
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID}))
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))
+6 -3
View File
@@ -92,9 +92,12 @@ func main() {
_, _ = fmt.Fprintln(os.Stdout, "lint go header ...")
succeed := lintGoHeader()
_, _ = fmt.Fprintln(os.Stdout, "lint for linux ...")
succeed = runCmd([]string{"GOOS=linux", "TAGS=bindata"}, "golangci-lint", append([]string{"run"}, os.Args[1:]...)) && succeed
_, _ = fmt.Fprintln(os.Stdout, "lint for windows ...")
succeed = runCmd([]string{"GOOS=windows", "TAGS=gogit"}, "golangci-lint", append([]string{"run"}, os.Args[1:]...)) && succeed
succeed = runCmd([]string{"GOOS=linux", "TAGS=bindata"}, "golangci-lint", append([]string{"run", "--build-tags=linux,bindata"}, os.Args[1:]...)) && succeed
if os.Getenv("CI") != "" {
// only lint for other platforms when in CI, to keep local lint fast
_, _ = fmt.Fprintln(os.Stdout, "lint for windows ...")
succeed = runCmd([]string{"GOOS=windows", "TAGS=gogit"}, "golangci-lint", append([]string{"run", "--build-tags=windows,gogit"}, os.Args[1:]...)) && succeed
}
if !succeed {
os.Exit(1)
}
+283 -2
View File
@@ -1,12 +1,37 @@
import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.ts';
import {GET, POST, request} from '../modules/fetch.ts';
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
import {toggleFullScreen} from '../utils.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {localUserSettings} from '../modules/user-settings.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
const SESSION_TAG_HEADER = 'X-Session-Tag';
// sessionTag is generated once per page load. It is attached as the
// X-Session-Tag header on every mutation request and compared against
// incoming SSE payloads so the originating tab can suppress its own
// echo (the source-of-truth DOM update already happened locally).
let sessionTag = '';
function ensureSessionTag(): string {
if (sessionTag) return sessionTag;
if (globalThis.crypto?.randomUUID) {
sessionTag = globalThis.crypto.randomUUID();
} else {
sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
return sessionTag;
}
function withSessionTag(headers: HeadersInit | undefined): Headers {
const h = new Headers(headers ?? {});
h.set(SESSION_TAG_HEADER, ensureSessionTag());
return h;
}
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement!;
@@ -29,6 +54,7 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi
try {
await POST(`${to.getAttribute('data-url')}/move`, {
data: columnSorting,
headers: withSessionTag(undefined),
});
} catch (error) {
console.error(error);
@@ -61,6 +87,7 @@ async function initRepoProjectSortable(): Promise<void> {
try {
await POST(mainBoard.getAttribute('data-url')!, {
data: columnSorting,
headers: withSessionTag(undefined),
});
} catch (error) {
console.error(error);
@@ -113,7 +140,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
try {
elForm.classList.add('is-loading');
await request(formLink, {method: formMethod, data: formData});
await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)});
if (!columnId) {
window.location.reload(); // newly added column, need to reload the page
return;
@@ -173,9 +200,263 @@ function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
}
}
// SSE handlers ---------------------------------------------------------------
type EventPayloadBase = {session_tag?: string};
type CardMovedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
from_column_id: number;
to_column_id: number;
sorting: number;
};
type CardLinkedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
column_id: number;
};
type CardUnlinkedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
};
type ColumnUpdatedPayload = {
project_id: number;
column_id: number;
title: string;
color: string;
sorting: number;
};
type ColumnDeletedPayload = {
project_id: number;
column_id: number;
};
type ColumnReorderedPayload = {
project_id: number;
columns: Array<{column_id: number; sorting: number}>;
};
type ProjectUpdatedPayload = {
project_id: number;
title: string;
description: string;
card_type: string;
is_closed: boolean;
};
// columnIssuesURL builds the appropriate "list issues for column" API
// path for the current page scope. Server-side these endpoints all
// return the same JSON shape; the frontend just needs the right base.
function columnIssuesURL(board: HTMLElement, columnID: number): string | null {
const projectID = board.getAttribute('data-project-id');
const scope = board.getAttribute('data-project-scope');
const owner = board.getAttribute('data-project-owner');
const repo = board.getAttribute('data-project-repo');
const {appSubUrl} = window.config;
if (!projectID || !owner) return null;
if (scope === 'repo' && repo) {
return `${appSubUrl}/api/v1/repos/${owner}/${repo}/projects/${projectID}/columns/${columnID}/issues`;
}
if (scope === 'org') {
return `${appSubUrl}/api/v1/orgs/${owner}/projects/${projectID}/columns/${columnID}/issues`;
}
return `${appSubUrl}/api/v1/users/${owner}/projects/${projectID}/columns/${columnID}/issues`;
}
function updateColumnCount(columnEl: HTMLElement): void {
const cards = columnEl.querySelectorAll('.issue-card').length;
const badge = columnEl.querySelector('.project-column-issue-count');
if (badge) badge.textContent = String(cards);
}
function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
if (!card) {
// Card is not currently rendered (filtered out, or new since
// page load). A targeted column re-fetch is the safe fallback.
refetchColumn(board, payload.to_column_id);
return;
}
const target = board.querySelector<HTMLElement>(`#board_${payload.to_column_id}`);
if (!target) return;
const fromColumn = card.parentElement;
target.append(card);
if (fromColumn instanceof HTMLElement) {
const fromColumnEl = fromColumn.closest<HTMLElement>('.project-column');
if (fromColumnEl) updateColumnCount(fromColumnEl);
}
const toColumnEl = target.closest<HTMLElement>('.project-column');
if (toColumnEl) updateColumnCount(toColumnEl);
}
async function refetchColumn(board: HTMLElement, columnID: number): Promise<void> {
const url = columnIssuesURL(board, columnID);
if (!url) return;
try {
const resp = await GET(url);
if (!resp.ok) return;
// Response shape: list of API issues; we don't have a templated
// card render available client-side, so we just refresh the
// column count badge here. The DOM-level reorder/insert is
// delivered by the matching CardMoved/CardUnlinked events.
const issues = await resp.json();
const target = board.querySelector<HTMLElement>(`#board_${columnID}`);
if (!target) return;
const colEl = target.closest<HTMLElement>('.project-column');
if (colEl) {
const badge = colEl.querySelector('.project-column-issue-count');
if (badge) badge.textContent = String(Array.isArray(issues) ? issues.length : 0);
}
} catch (error) {
console.error(error);
}
}
function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
refetchColumn(board, payload.column_id); // no await
}
function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
if (!card) return;
const colEl = card.closest<HTMLElement>('.project-column');
card.remove();
if (colEl) updateColumnCount(colEl);
}
function handleColumnCreated(): void {
// Rare event; reload is cheap and avoids client-side template duplication.
window.location.reload();
}
function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): void {
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
if (!colEl) return;
const titleEl = colEl.querySelector<HTMLElement>('.project-column-title-text');
if (titleEl) titleEl.textContent = payload.title;
if (payload.color) {
const textColor = contrastColor(payload.color);
colEl.style.setProperty('background', payload.color, 'important');
colEl.style.setProperty('color', textColor, 'important');
queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
} else {
colEl.style.removeProperty('background');
colEl.style.removeProperty('color');
queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
}
}
function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void {
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
if (colEl) colEl.remove();
}
function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void {
// Sort the columns array by the new sorting value, then re-attach
// each column element in that order. appendChild on an existing
// node moves it rather than cloning, so the result is an in-place
// reorder.
const order = Array.from(payload.columns).sort((a, b) => a.sorting - b.sorting);
for (const entry of order) {
const el = board.querySelector<HTMLElement>(`.project-column[data-id="${entry.column_id}"]`);
if (el) board.append(el);
}
}
function handleProjectUpdated(payload: ProjectUpdatedPayload): void {
const header = document.querySelector<HTMLElement>('.project-header h2');
if (header) header.textContent = payload.title;
const desc = document.querySelector<HTMLElement>('.project-description .render-content');
if (desc) desc.textContent = payload.description;
}
function handleProjectDeleted(): void {
// Best-effort: navigate up one path segment from the current URL.
// The board lives at .../projects/{id}; the listing page is the
// parent. Falling back to the homepage on any URL we don't
// recognise is acceptable since this is a destructive event.
const parts = window.location.pathname.split('/');
if (parts.length > 1) {
parts.pop();
window.location.href = parts.join('/') || '/';
} else {
window.location.href = '/';
}
}
// dispatchProjectEvent picks the right handler for an SSE payload.
// The backend uses one event name per project but disambiguates event
// types by payload shape; we sniff discriminating fields here. Order
// matters: the more specific shapes are checked first.
function dispatchProjectEvent(board: HTMLElement, payload: any): void {
if ('from_column_id' in payload && 'to_column_id' in payload) {
handleCardMoved(board, payload as CardMovedPayload);
} else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) {
handleCardLinked(board, payload as CardLinkedPayload);
} else if ('issue_id' in payload && !('column_id' in payload)) {
handleCardUnlinked(board, payload as CardUnlinkedPayload);
} else if ('columns' in payload) {
handleColumnReordered(board, payload as ColumnReorderedPayload);
} else if ('column_id' in payload && 'title' in payload) {
handleColumnUpdated(board, payload as ColumnUpdatedPayload);
if ('is_default' in payload) handleColumnCreated();
} else if ('column_id' in payload) {
handleColumnDeleted(board, payload as ColumnDeletedPayload);
} else if ('title' in payload && 'card_type' in payload) {
handleProjectUpdated(payload as ProjectUpdatedPayload);
} else if ('project_id' in payload && Object.keys(payload).length <= 2) {
handleProjectDeleted();
}
}
function initRepoProjectSSE(elProjectsView: HTMLElement): void {
const board = elProjectsView.querySelector<HTMLElement>('#project-board');
if (!board) return;
const projectID = board.getAttribute('data-project-id');
if (!projectID) return;
if (!window.EventSource || !window.SharedWorker) return;
ensureSessionTag();
const eventName = `project-board.${projectID}`;
let worker: UserEventsSharedWorker;
try {
worker = new UserEventsSharedWorker('project-board-worker');
} catch (error) {
console.error('project board SSE: failed to start worker', error);
return;
}
worker.addMessageEventListener((event: MessageEvent) => {
if (!event.data || event.data.type !== eventName) return;
let payload: any;
try {
payload = JSON.parse(event.data.data);
} catch (error) {
console.error('project board SSE: malformed payload', error, event.data);
return;
}
dispatchProjectEvent(board, payload);
});
worker.startPort();
// Subscribe to the per-project event name on top of the worker's
// default listener set so the SharedWorker forwards us the events.
worker.sharedWorker.port.postMessage({type: 'listen', eventType: eventName});
}
export function initRepoProjectsView(): void {
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
initRepoProjectToggleFullScreen(elProjectsView);
initRepoProjectSSE(elProjectsView);
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
if (!writableProjectBoard) return;