32 Commits

Author SHA1 Message Date
oleks c45ea82fd1 Merge pull request 'fix(project): push SSE update when an issue on a board is closed/reopened' (#20) from fix/issue-19-sse-state into main 2026-05-17 20:59:50 +03:00
oleks c19ecab35d fix(project): push SSE update when an issue on a board is closed/reopened
Closing or reopening an issue did not notify project boards that carry
it as a card, so board tabs showed stale state until a manual reload.
CloseIssue/ReopenIssue only published milestone events and the issue
timeline notification — nothing project-scoped.

Add a CardStateChanged project event, published per linked project from
CloseIssue/ReopenIssue (best-effort; never fails the state change). The
board frontend flips the issue-state octicon in place and refetches the
affected column so state-filtered boards and counts stay correct. The
dispatch check precedes the CardUnlinked branch so a close/reopen is
not mistaken for a card removal.

Also switch a pre-existing String#match to RegExp#exec in the same file
to keep it lint-clean.

Closes #19
2026-05-17 20:58:17 +03:00
oleks ad46f6cde8 Merge pull request 'fix(projects): scope project-issue move to its own project' (#18) from fix/user-project-move-multiproject-detach into main 2026-05-17 17:06:19 +03:00
Claude 078459c497 fix(projects): scope project-issue move to its own project
MoveIssuesOnProjectColumn updated `project_issue` with a WHERE clause on
issue_id only. An issue assigned to several projects has one project_issue
row per project, so moving it within one project rewrote project_board_id
for every project the issue belonged to, detaching it from all the others.

Scope the UPDATE to (issue_id, project_id) so only the target project's
row changes. Mirrors the fix already present in upstream/main.

Adds an integration regression test asserting an issue in two user
projects keeps its column in the other project after a move. Fixes #17.
2026-05-17 16:59:31 +03:00
oleks d4de99f96b feat(sse): toast notifications for project-board and milestone events (#15) 2026-05-16 14:32:25 +03:00
oleks 9f588d3dd3 feat(milestone): SSE live-updating progress bars (#14) 2026-05-16 10:21:38 +03:00
oleks 4676a3af93 fix(project_events): use process-lifetime ctx for async SSE publish (#8) 2026-05-16 00:10:51 +03:00
oleks 9c1699feb5 feat(project): SSE push updates for project board pages (#7) 2026-05-15 22:15:26 +03:00
oleks 15acfdb783 feat(api): state filter + populated num_issues on project columns (#6) 2026-05-15 22:00:51 +03:00
Oleks 1cd81ff925 feat(api): state filter + populated num_issues on project columns
Adds two improvements to the user/org/repo project-board REST API:

* state filter on column-issues endpoints (issue #4)
  GET /api/v1/{users,orgs}/{name}/-/projects/{id}/columns/{col}/issues
  GET /api/v1/repos/{owner}/{repo}/projects/{id}/columns/{col}/issues
  Now accept ?state=open|closed|all (default open), matching the convention
  on the project-list endpoint and on /repos/.../issues. Applied at the
  IssuesOptions layer so all three scopes inherit the filter.

* populated num_issues / num_open_issues / num_closed_issues on column-list
  (issue #5)
  ColumnList.LoadIssueCounts runs two grouped queries against project_issue
  joined with issue (one open, one closed). All three List*Columns handlers
  call it before converting, so num_issues stops being null and consumers
  can render a kanban summary in a single round trip instead of N+1.

Tests:
* unit: empty-input fast path on ColumnList.LoadIssueCounts.
* integration: extended testAPIListProjectColumnIssues / -User / -Org to
  close an issue, then verify default=open hides it, state=closed and
  state=all return it, and the column-list response carries the correct
  open/closed/total split.

Closes #4, closes #5
2026-05-15 21:54:35 +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
91 changed files with 13393 additions and 835 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))))
+15
View File
@@ -136,6 +136,21 @@ func GetMilestoneByRepoID(ctx context.Context, repoID, id int64) (*Milestone, er
return m, nil
}
// GetMilestoneByID returns the milestone identified by id, regardless of
// which repository it belongs to. Used by the milestone_events SSE
// publisher, which only has the milestone id and re-reads the fresh
// counters from a detached, process-lifetime context.
func GetMilestoneByID(ctx context.Context, id int64) (*Milestone, error) {
m := new(Milestone)
has, err := db.GetEngine(ctx).ID(id).Get(m)
if err != nil {
return nil, err
} else if !has {
return nil, ErrMilestoneNotExist{ID: id}
}
return m, nil
}
// GetMilestoneByRepoIDANDName return a milestone if one exist by name and repo
func GetMilestoneByRepoIDANDName(ctx context.Context, repoID int64, name string) (*Milestone, error) {
var mile Milestone
+3 -15
View File
@@ -48,7 +48,9 @@ type Column struct {
ProjectID int64 `xorm:"INDEX NOT NULL"`
CreatorID int64 `xorm:"NOT NULL"`
NumIssues int64 `xorm:"-"`
NumIssues int64 `xorm:"-"`
NumOpenIssues int64 `xorm:"-"`
NumClosedIssues int64 `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
@@ -337,20 +339,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 {
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"strconv"
"code.gitea.io/gitea/models/db"
"xorm.io/builder"
)
// 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
}
// LoadIssueCounts populates NumIssues, NumOpenIssues, and NumClosedIssues on
// every column in the list using two grouped queries against project_issue
// joined with issue. Columns with no attached issues stay at zero counts —
// nothing else has to be wired up by the caller.
func (cl ColumnList) LoadIssueCounts(ctx context.Context) error {
if len(cl) == 0 {
return nil
}
columnIDs := make([]int64, 0, len(cl))
for _, c := range cl {
columnIDs = append(columnIDs, c.ID)
}
openCounts, err := countColumnIssuesByState(ctx, columnIDs, false)
if err != nil {
return err
}
closedCounts, err := countColumnIssuesByState(ctx, columnIDs, true)
if err != nil {
return err
}
for _, c := range cl {
c.NumOpenIssues = openCounts[c.ID]
c.NumClosedIssues = closedCounts[c.ID]
c.NumIssues = c.NumOpenIssues + c.NumClosedIssues
}
return nil
}
func countColumnIssuesByState(ctx context.Context, columnIDs []int64, isClosed bool) (map[int64]int64, error) {
out := make(map[int64]int64, len(columnIDs))
cond := builder.In("project_issue.project_board_id", columnIDs).
And(builder.Eq{"issue.is_closed": isClosed})
sub := builder.Select("project_issue.project_board_id AS project_board_id", "COUNT(*) AS cnt").
From("project_issue").
InnerJoin("issue", "issue.id = project_issue.issue_id").
Where(cond).
GroupBy("project_issue.project_board_id")
rows, err := db.GetEngine(ctx).Query(sub)
if err != nil {
return nil, err
}
for _, r := range rows {
columnID, _ := strconv.ParseInt(string(r["project_board_id"]), 10, 64)
cnt, _ := strconv.ParseInt(string(r["cnt"]), 10, 64)
out[columnID] = cnt
}
return out, 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
}
+75
View File
@@ -0,0 +1,75 @@
// 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)
t.Run("LoadIssueCountsEmpty", testLoadIssueCountsEmpty)
}
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 testLoadIssueCountsEmpty(t *testing.T) {
// Empty input is a fast path — must not touch the database and must not error.
// (The full open/closed-count behavior is exercised by the integration tests
// in tests/integration/api_*_project_test.go, which can join against the issue
// table; the unit-test fixture set here intentionally excludes it.)
assert.NoError(t, ColumnList{}.LoadIssueCounts(t.Context()))
}
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:
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package sessiontag carries a per-page-load identifier from the
// originating HTTP request down to the service- and model-layer SSE
// publishers. The publishers echo the tag back inside event payloads so
// the originating browser tab can suppress its own event after it has
// already applied the optimistic update locally.
//
// It is deliberately tiny and dependency-free so any feature that emits
// Server-Sent Events (project boards, milestones, ...) can share one
// context key without importing one another.
package sessiontag
import "context"
// sessionTagCtxKey is the context key under which the X-Session-Tag
// value from the originating HTTP request is stashed.
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 ""
}
+94 -18
View File
@@ -7,27 +7,103 @@ 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"`
NumOpenIssues int64 `json:"num_open_issues"`
NumClosedIssues int64 `json:"num_closed_issues"`
NumIssues int64 `json:"num_issues"`
// 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).
+940
View File
@@ -0,0 +1,940 @@
// 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
}
if err := columns.LoadIssueCounts(ctx); 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: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - 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,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
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 {
+3
View File
@@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
milestone_events "code.gitea.io/gitea/services/milestone_events"
)
// ListMilestones list milestones for a repository
@@ -230,6 +231,7 @@ func EditMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneProgress(ctx, milestone.ID)
ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone))
}
@@ -269,6 +271,7 @@ func DeleteMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneDeleted(ctx, ctx.Repo.Repository.ID, m.ID)
ctx.Status(http.StatusNoContent)
}
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"`
}
+980
View File
@@ -0,0 +1,980 @@
// 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
}
if err := columns.LoadIssueCounts(ctx); 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: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - 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,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
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/modules/sessiontag"
)
// 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 sessiontag.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 := sessiontag.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
+8 -1
View File
@@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/issue"
milestone_events "code.gitea.io/gitea/services/milestone_events"
"xorm.io/builder"
)
@@ -195,6 +196,8 @@ func EditMilestonePost(ctx *context.Context) {
return
}
milestone_events.PublishMilestoneProgress(ctx, m.ID)
ctx.Flash.Success(ctx.Tr("repo.milestones.edit_success", m.Name))
ctx.Redirect(ctx.Repo.RepoLink + "/milestones")
}
@@ -221,14 +224,18 @@ func ChangeMilestoneStatus(ctx *context.Context) {
}
return
}
milestone_events.PublishMilestoneProgress(ctx, id)
ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones?state=" + url.QueryEscape(ctx.PathParam("action")))
}
// DeleteMilestone delete a milestone
func DeleteMilestone(ctx *context.Context) {
if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
repoID := ctx.Repo.Repository.ID
milestoneID := ctx.FormInt64("id")
if err := issues_model.DeleteMilestoneByRepoID(ctx, repoID, milestoneID); err != nil {
ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error())
} else {
milestone_events.PublishMilestoneDeleted(ctx, repoID, milestoneID)
ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success"))
}
+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 {
+167 -20
View File
@@ -4,34 +4,181 @@
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,
NumOpenIssues: column.NumOpenIssues,
NumClosedIssues: column.NumClosedIssues,
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
}
+9
View File
@@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/storage"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -57,6 +58,10 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
return err
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.NewIssue(ctx, issue, mentions)
if len(issue.Labels) > 0 {
notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil)
@@ -160,6 +165,10 @@ func DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model
}
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.DeleteIssue(ctx, doer, issue)
return nil
+10
View File
@@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -75,6 +76,15 @@ func ChangeMilestoneAssign(ctx context.Context, issue *issues_model.Issue, doer
return err
}
// Both the previous and the new milestone may have had their issue
// counters move; publish progress for each affected milestone.
if oldMilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, oldMilestoneID)
}
if issue.MilestoneID > 0 && issue.MilestoneID != oldMilestoneID {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.IssueChangeMilestone(ctx, doer, issue, oldMilestoneID)
return nil
}
+30
View File
@@ -10,9 +10,29 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
project_events "code.gitea.io/gitea/services/project_events"
)
// publishProjectCardStateChanged notifies every project board the issue is a
// card on that its open/closed state changed, so subscribed board tabs can
// re-render the card live instead of showing stale state until reload.
// Best-effort: a failure here must not fail the close/reopen operation.
func publishProjectCardStateChanged(ctx context.Context, issue *issues_model.Issue, isClosed bool) {
if err := issue.LoadProjects(ctx); err != nil {
log.Error("LoadProjects for issue[%d]: %v", issue.ID, err)
return
}
for _, p := range issue.Projects {
project_events.PublishCardStateChanged(ctx, project_events.CardStateChanged{
ProjectID: p.ID,
IssueID: issue.ID,
IsClosed: isClosed,
})
}
}
// CloseIssue close an issue.
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
var comment *issues_model.Comment
@@ -34,7 +54,12 @@ func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model
return err
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
publishProjectCardStateChanged(ctx, issue, true)
return nil
}
@@ -47,7 +72,12 @@ func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_mode
return err
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
publishProjectCardStateChanged(ctx, issue, false)
return nil
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package milestone_events publishes milestone progress changes as
// Server-Sent Events so other browser tabs viewing the same repository's
// milestone list (or a single milestone's issue list) can update their
// progress bars in near real time.
//
// Each public Publish* helper marshals a typed payload to JSON, wraps it
// in an *eventsource.Event whose Name is "repo-milestones.{repo_id}", and
// fans the event out to every currently connected user that has read
// access to the repository's issues unit. All publish helpers are
// non-blocking: they spawn a goroutine so request handlers do not stall
// on slow consumers.
package milestone_events
import (
"context"
"strconv"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
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"
"code.gitea.io/gitea/modules/sessiontag"
)
// Event payload structs ------------------------------------------------------
// MilestoneProgress is emitted whenever a milestone's issue counters
// (and therefore its completeness percentage) change. It funnels every
// mutation that can move the bar: issue close/reopen, milestone
// (re)assignment, issue creation/deletion, milestone status change and
// milestone edit.
type MilestoneProgress struct {
RepoID int64 `json:"repo_id"`
MilestoneID int64 `json:"milestone_id"`
OpenIssues int `json:"open_issues"`
ClosedIssues int `json:"closed_issues"`
Completeness int `json:"completeness"`
SessionTag string `json:"session_tag,omitempty"`
}
// MilestoneDeleted is emitted when a milestone is deleted so viewers can
// drop the card (or navigate away from a single-milestone view).
type MilestoneDeleted struct {
RepoID int64 `json:"repo_id"`
MilestoneID int64 `json:"milestone_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()
}
// milestoneLookup re-reads a milestone by id from the detached context.
// Stubbable in tests so PublishMilestoneProgress can be exercised
// without a database.
var milestoneLookup = issues_model.GetMilestoneByID
// repoLookup loads a repository by id. Stubbable in tests so the
// access-filter logic can be exercised without spinning up a database.
var repoLookup = repo_model.GetRepositoryByID
// repoAccessChecker decides whether the user identified by uid is allowed
// to read the given repository's issues. Tests stub this to bypass the
// real permission system.
var repoAccessChecker = canReadMilestones
// connectedUIDsWithRepoIssueAccess returns the subset of currently
// connected uids that the access checker confirms can read the issues
// unit of repoID.
func connectedUIDsWithRepoIssueAccess(ctx context.Context, repoID int64) []int64 {
uids := connectedUIDsLister()
if len(uids) == 0 {
return nil
}
repo, err := repoLookup(ctx, repoID)
if err != nil {
log.Debug("milestone_events: GetRepositoryByID(%d) failed: %v", repoID, err)
return nil
}
allowed := make([]int64, 0, len(uids))
for _, uid := range uids {
ok, err := repoAccessChecker(ctx, uid, repo)
if err != nil {
log.Debug("milestone_events: access check uid=%d repo=%d: %v", uid, repoID, err)
continue
}
if ok {
allowed = append(allowed, uid)
}
}
return allowed
}
// canReadMilestones implements the real read-permission check used in
// production: a user may see milestone progress for a repo when they can
// read its issues unit.
func canReadMilestones(ctx context.Context, uid int64, repo *repo_model.Repository) (bool, error) {
user, err := user_model.GetUserByID(ctx, uid)
if err != nil {
return false, err
}
// AccessModeRead == 1; the literal mirrors project_events, where the
// perm_model typed constant would force another import alias and the
// meaning is well established here.
return access_model.HasAccessUnit(ctx, user, repo, unit.TypeIssues, 1)
}
// 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.
func publishEvent(ctx context.Context, repoID int64, payload any) {
data, err := json.Marshal(payload)
if err != nil {
log.Error("milestone_events: marshal payload for repo %d: %v", repoID, err)
return
}
event := &eventsource.Event{
Name: eventName(repoID),
Data: data,
}
uids := connectedUIDsWithRepoIssueAccess(ctx, repoID)
if len(uids) == 0 {
return
}
broadcastFn(uids, event)
}
// eventName returns the SSE event name for a given repo id.
func eventName(repoID int64) string {
return "repo-milestones." + strconv.FormatInt(repoID, 10)
}
// Publishers -----------------------------------------------------------------
// PublishMilestoneProgress re-reads the milestone's fresh counters and
// fans a MilestoneProgress event out to everyone who can read the repo's
// issues. The session tag is resolved synchronously from the request
// context before the goroutine starts; the goroutine itself runs on a
// detached, process-lifetime context so the request-scoped DB session
// being returned to the pool cannot make the re-fetch/access checks fail.
func PublishMilestoneProgress(ctx context.Context, milestoneID int64) {
if milestoneID <= 0 {
return
}
tag := sessiontag.SessionTagFromContext(ctx)
go func() {
detachCtx := detach(ctx)
m, err := milestoneLookup(detachCtx, milestoneID)
if err != nil {
log.Debug("milestone_events: GetMilestoneByID(%d) failed: %v", milestoneID, err)
return
}
payload := MilestoneProgress{
RepoID: m.RepoID,
MilestoneID: m.ID,
OpenIssues: m.NumOpenIssues,
ClosedIssues: m.NumClosedIssues,
Completeness: m.Completeness,
SessionTag: tag,
}
publishEvent(detachCtx, m.RepoID, payload)
}()
}
// PublishMilestoneDeleted fans a MilestoneDeleted event out for the given
// repo/milestone. No re-fetch is needed since the milestone is gone.
func PublishMilestoneDeleted(ctx context.Context, repoID, milestoneID int64) {
if repoID <= 0 || milestoneID <= 0 {
return
}
go func() {
detachCtx := detach(ctx)
publishEvent(detachCtx, repoID, MilestoneDeleted{
RepoID: repoID,
MilestoneID: milestoneID,
})
}()
}
// 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 (GetMilestoneByID, GetRepositoryByID, 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()
}
+335
View File
@@ -0,0 +1,335 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestone_events
import (
"context"
"sync"
"testing"
"time"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/sessiontag"
"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 the publishers for
// test doubles: a fake uid lister, a stubbed milestone lookup returning a
// synthetic milestone (no DB hit), a stubbed repo lookup, 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, milestone *issues_model.Milestone) (<-chan capturedCall, func()) {
t.Helper()
calls := make(chan capturedCall, 16)
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
origMsLookup := milestoneLookup
broadcastFn = func(uids []int64, event *eventsource.Event) {
calls <- capturedCall{uids: append([]int64(nil), uids...), event: event}
}
connectedUIDsLister = func() []int64 {
return append([]int64(nil), uids...)
}
milestoneLookup = func(_ context.Context, id int64) (*issues_model.Milestone, error) {
if milestone != nil {
return milestone, nil
}
return &issues_model.Milestone{ID: id, RepoID: 1}, nil
}
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
repoAccessChecker = func(_ context.Context, _ int64, _ *repo_model.Repository) (bool, error) {
return true, nil
}
return calls, func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
milestoneLookup = origMsLookup
}
}
// 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, "repo-milestones.42", eventName(42))
assert.Equal(t, "repo-milestones.0", eventName(0))
}
func TestPublishMilestoneProgress_NameAndPayload(t *testing.T) {
ms := &issues_model.Milestone{
ID: 7,
RepoID: 10,
NumIssues: 8,
NumClosedIssues: 6,
NumOpenIssues: 2,
Completeness: 75,
}
ch, restore := installFakes(t, []int64{1}, ms)
defer restore()
PublishMilestoneProgress(context.Background(), 7)
c := awaitCall(t, ch)
assert.Equal(t, "repo-milestones.10", c.event.Name)
data, ok := c.event.Data.([]byte)
require.True(t, ok, "Event.Data should be []byte")
var got MilestoneProgress
require.NoError(t, json.Unmarshal(data, &got))
assert.Equal(t, MilestoneProgress{
RepoID: 10, MilestoneID: 7, OpenIssues: 2, ClosedIssues: 6, Completeness: 75,
}, got)
}
func TestPublishMilestoneProgress_IgnoresNonPositiveID(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneProgress(context.Background(), 0)
PublishMilestoneProgress(context.Background(), -3)
select {
case <-ch:
t.Fatal("no broadcast expected for non-positive milestone id")
case <-time.After(200 * time.Millisecond):
}
}
func TestPublishMilestoneProgress_LookupErrorIsSilent(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
milestoneLookup = func(_ context.Context, _ int64) (*issues_model.Milestone, error) {
return nil, issues_model.ErrMilestoneNotExist{ID: 99}
}
PublishMilestoneProgress(context.Background(), 99)
select {
case <-ch:
t.Fatal("no broadcast expected when the milestone re-fetch fails")
case <-time.After(200 * time.Millisecond):
}
}
func TestPublishMilestoneDeleted_NameAndPayload(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneDeleted(context.Background(), 12, 5)
c := awaitCall(t, ch)
assert.Equal(t, "repo-milestones.12", c.event.Name)
var got MilestoneDeleted
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &got))
assert.Equal(t, MilestoneDeleted{RepoID: 12, MilestoneID: 5}, got)
}
func TestPublishMilestoneDeleted_IgnoresNonPositiveIDs(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneDeleted(context.Background(), 0, 5)
PublishMilestoneDeleted(context.Background(), 12, 0)
select {
case <-ch:
t.Fatal("no broadcast expected for non-positive ids")
case <-time.After(200 * time.Millisecond):
}
}
// TestSessionTagPropagation verifies that when a publish is invoked
// inside a context decorated by sessiontag.WithSessionTag, the emitted
// JSON payload carries the tag.
func TestSessionTagPropagation(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
ctx := sessiontag.WithSessionTag(context.Background(), "abc-123")
PublishMilestoneProgress(ctx, 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "abc-123", payload.SessionTag)
}
// TestSessionTagAbsentWhenUnset verifies the omitempty tag stays empty
// when no session tag is on the context.
func TestSessionTagAbsentWhenUnset(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
PublishMilestoneProgress(context.Background(), 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Empty(t, payload.SessionTag)
}
// TestSessionTagResolvedSynchronously ensures the tag is read from the
// request context before the goroutine starts, not from the detached
// context (which never carries request-scoped values).
func TestSessionTagResolvedSynchronously(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
ctx := sessiontag.WithSessionTag(context.Background(), "sync-tag")
PublishMilestoneProgress(ctx, 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "sync-tag", payload.SessionTag)
}
// TestConnectedUIDsWithRepoIssueAccess_FiltersByPermission ensures the
// helper drops uids the access checker rejects.
func TestConnectedUIDsWithRepoIssueAccess_FiltersByPermission(t *testing.T) {
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
defer func() {
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
}()
connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} }
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
allowed := map[int64]bool{1: true, 3: true}
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return allowed[uid], nil
}
got := connectedUIDsWithRepoIssueAccess(context.Background(), 42)
assert.ElementsMatch(t, []int64{1, 3}, got)
}
// TestConnectedUIDsWithRepoIssueAccess_NoConnections shortcuts when no
// users are connected; the repo lookup must not be called.
func TestConnectedUIDsWithRepoIssueAccess_NoConnections(t *testing.T) {
origLister := connectedUIDsLister
origRepoLookup := repoLookup
defer func() {
connectedUIDsLister = origLister
repoLookup = origRepoLookup
}()
connectedUIDsLister = func() []int64 { return nil }
called := false
repoLookup = func(_ context.Context, _ int64) (*repo_model.Repository, error) {
called = true
return &repo_model.Repository{}, nil
}
got := connectedUIDsWithRepoIssueAccess(context.Background(), 42)
assert.Empty(t, got)
assert.False(t, called, "repo 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 := repoAccessChecker
origRepoLookup := repoLookup
defer func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
}()
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} }
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return uid != 20, nil
}
publishEvent(context.Background(), 1, MilestoneDeleted{RepoID: 1, MilestoneID: 5})
mu.Lock()
defer mu.Unlock()
assert.ElementsMatch(t, []int64{10, 30}, got)
}
// TestPublishMilestoneProgress_NoConnectionsNoBroadcast verifies the
// connected-uid shortcut: with nobody connected nothing is sent even
// though the milestone re-fetch succeeds.
func TestPublishMilestoneProgress_NoConnectionsNoBroadcast(t *testing.T) {
ch, restore := installFakes(t, nil, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
PublishMilestoneProgress(context.Background(), 3)
select {
case <-ch:
t.Fatal("no broadcast expected when no users are connected")
case <-time.After(200 * time.Millisecond):
}
}
// TestPublishMilestoneProgress_FanOutTargetList verifies the recipient
// list handed to broadcast is exactly the access-filtered set.
func TestPublishMilestoneProgress_FanOutTargetList(t *testing.T) {
ch, restore := installFakes(t, []int64{5, 6, 7}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return uid != 6, nil
}
PublishMilestoneProgress(context.Background(), 3)
c := awaitCall(t, ch)
assert.ElementsMatch(t, []int64{5, 7}, c.uids)
}
+341
View File
@@ -0,0 +1,341 @@
// 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"
"code.gitea.io/gitea/modules/sessiontag"
)
// WithSessionTag re-exports modules/sessiontag.WithSessionTag so existing
// callers of project_events keep working after the context-key helper was
// extracted into its own dependency-free package (shared with
// milestone_events and any future SSE feature).
func WithSessionTag(ctx context.Context, tag string) context.Context {
return sessiontag.WithSessionTag(ctx, tag)
}
// SessionTagFromContext re-exports modules/sessiontag.SessionTagFromContext.
func SessionTagFromContext(ctx context.Context) string {
return sessiontag.SessionTagFromContext(ctx)
}
// 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"`
}
// CardStateChanged is emitted when an issue's open/closed state changes
// while it is a card on the project. It lets boards re-render the card's
// state live (and is the hook state-filtered boards use to correct their
// column counts) without a full page reload.
type CardStateChanged struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
IsClosed bool `json:"is_closed"`
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)
}
// PublishCardStateChanged fans out a CardStateChanged event for the given payload.
func PublishCardStateChanged(ctx context.Context, payload CardStateChanged) {
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()
}
+330
View File
@@ -0,0 +1,330 @@
// 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: "card.state_changed",
wantName: "project-board.12",
invoke: func(ctx context.Context) {
PublishCardStateChanged(ctx, CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true})
},
wantData: CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true},
},
{
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,
})
}
+120 -8
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,10 +93,10 @@ 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.
// Scope the update to this issue *in this project*. Without the
// project_id predicate, an issue that belongs to several projects
// would have every project_issue row rewritten to the target
// column, detaching it from all other projects.
_, err = db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
Update(map[string]any{
@@ -95,9 +106,110 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
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 +241,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}}
+2 -2
View File
@@ -27,7 +27,7 @@
</div>
{{end}}
<div class="tw-flex tw-flex-col tw-gap-2">
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100"></progress>
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100" data-milestone-id="{{.Milestone.ID}}" data-repo-id="{{.Repository.ID}}"></progress>
<div class="flex-text-block tw-gap-4">
<div class="flex-text-inline">
{{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}}
@@ -46,7 +46,7 @@
{{end}}
{{end}}
</div>
<div>{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
<div class="milestone-completeness-pct">{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
{{if .TotalTrackedTime}}
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
{{svg "octicon-clock"}}
+5 -5
View File
@@ -15,16 +15,16 @@
{{template "repo/issue/filters" .}}
<!-- milestone list -->
<div class="milestone-list">
<div class="milestone-list" data-repo-id="{{$.Repository.ID}}">
{{range .Milestones}}
<li class="milestone-card">
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{$.Repository.ID}}">
<div class="milestone-header">
<h3 class="flex-text-block tw-m-0">
{{svg "octicon-milestone" 16}}
<a class="muted" href="{{$.RepoLink}}/milestone/{{.ID}}">{{.Name}}</a>
</h3>
<div class="tw-flex tw-items-center">
<span class="tw-mr-2">{{.Completeness}}%</span>
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<progress value="{{.Completeness}}" max="100"></progress>
</div>
</div>
@@ -32,11 +32,11 @@
<div class="group">
<div class="flex-text-block">
{{svg "octicon-issue-opened" 14}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</div>
{{if .TotalTrackedTime}}
<div class="flex-text-block">
+2315 -19
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -73,7 +73,7 @@
</div>
<div class="milestone-list">
{{range .Milestones}}
<li class="milestone-card">
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{.Repo.ID}}">
<div class="milestone-header">
<h3 class="flex-text-block tw-m-0">
<span class="ui large label">
@@ -83,7 +83,7 @@
<a class="muted" href="{{.Repo.Link}}/milestone/{{.ID}}">{{.Name}}</a>
</h3>
<div class="tw-flex tw-items-center">
<span class="tw-mr-2">{{.Completeness}}%</span>
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<progress value="{{.Completeness}}" max="100"></progress>
</div>
</div>
@@ -91,11 +91,11 @@
<div class="group">
<div class="flex-text-block">
{{svg "octicon-issue-opened" 14}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</div>
{{if .TotalTrackedTime}}
<div class="flex-text-block">
+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")
})
}
+573
View File
@@ -0,0 +1,573 @@
// 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"
issue_service "code.gitea.io/gitea/services/issue"
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)
// Close issueA, then exercise the state filter (issue #4).
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, member, ""))
// default (state omitted) -> open only -> colA returns nothing
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 openOnly []api.Issue
DecodeJSON(t, resp, &openOnly)
assert.Empty(t, openOnly)
// state=closed -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=closed", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var closedOnly []api.Issue
DecodeJSON(t, resp, &closedOnly)
assert.Len(t, closedOnly, 1)
assert.Equal(t, issueA.ID, closedOnly[0].ID)
// state=all -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=all", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var all []api.Issue
DecodeJSON(t, resp, &all)
assert.Len(t, all, 1)
// Columns endpoint populates num_issues / num_open_issues / num_closed_issues (issue #5).
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[colA.ID]) {
assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
}
if assert.NotNil(t, byID[colB.ID]) {
assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
}
}
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)
}
+809
View File
@@ -0,0 +1,809 @@
// 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"
issue_service "code.gitea.io/gitea/services/issue"
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)
// Close issueA, then exercise the new state= query parameter.
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
// Default (state omitted) -> open only -> columnA returns nothing.
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 gotOpenOnly []api.Issue
DecodeJSON(t, resp, &gotOpenOnly)
assert.Empty(t, gotOpenOnly, "default state=open must hide the closed issueA")
// state=closed -> returns issueA.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotClosed []api.Issue
DecodeJSON(t, resp, &gotClosed)
assert.Len(t, gotClosed, 1)
assert.Equal(t, issueA.ID, gotClosed[0].ID)
// state=all -> returns issueA.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=all", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotAll []api.Issue
DecodeJSON(t, resp, &gotAll)
assert.Len(t, gotAll, 1)
// And the columns endpoint must populate num_issues / num_open_issues /
// num_closed_issues — issue #5. columnA has 1 closed; columnB has 1 open.
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 listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[columnA.ID]) {
assert.EqualValues(t, 1, byID[columnA.ID].NumIssues, "columnA total")
assert.EqualValues(t, 0, byID[columnA.ID].NumOpenIssues, "columnA open")
assert.EqualValues(t, 1, byID[columnA.ID].NumClosedIssues, "columnA closed")
}
if assert.NotNil(t, byID[columnB.ID]) {
assert.EqualValues(t, 1, byID[columnB.ID].NumIssues, "columnB total")
assert.EqualValues(t, 1, byID[columnB.ID].NumOpenIssues, "columnB open")
assert.EqualValues(t, 0, byID[columnB.ID].NumClosedIssues, "columnB closed")
}
}
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)
}
+582
View File
@@ -0,0 +1,582 @@
// 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"
issue_service "code.gitea.io/gitea/services/issue"
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("MoveProjectIssueMultiProjectIsolation", testAPIUserMoveProjectIssueMultiProjectIsolation)
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)
// Close issueA, then exercise the state filter (issue #4).
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
// default (state omitted) -> open only -> colA has nothing
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 openOnly []api.Issue
DecodeJSON(t, resp, &openOnly)
assert.Empty(t, openOnly)
// state=closed -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var closedOnly []api.Issue
DecodeJSON(t, resp, &closedOnly)
assert.Len(t, closedOnly, 1)
assert.Equal(t, issueA.ID, closedOnly[0].ID)
// state=all -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=all", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var all []api.Issue
DecodeJSON(t, resp, &all)
assert.Len(t, all, 1)
// Columns endpoint must populate num_issues / num_open_issues / num_closed_issues (issue #5).
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[colA.ID]) {
assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
}
if assert.NotNil(t, byID[colB.ID]) {
assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
}
}
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)
}
// Regression for #17: moving an issue's column in one user project must not
// rewrite its column in other projects the issue also belongs to.
func testAPIUserMoveProjectIssueMultiProjectIsolation(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p1 := makeUserProject(t, owner)
p2 := makeUserProject(t, owner)
p1ColA := &project_model.Column{Title: "p1-A", ProjectID: p1.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColA))
p1ColB := &project_model.Column{Title: "p1-B", ProjectID: p1.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColB))
p2Col := &project_model.Column{Title: "p2", ProjectID: p2.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), p2Col))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p1.ID, p2.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p1ColA, map[int64]int64{0: issue.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p2Col, map[int64]int64{0: issue.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// move the issue inside p1 only
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p1.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: p1ColB.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// p1 updated as requested
pi1 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p1.ID, IssueID: issue.ID})
assert.Equal(t, p1ColB.ID, pi1.ProjectColumnID)
// p2 must be untouched
pi2 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p2.ID, IssueID: issue.ID})
assert.Equal(t, p2Col.ID, pi2.ProjectColumnID, "issue must remain in its original column in other projects")
}
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)
}
+7
View File
@@ -1,9 +1,16 @@
import {createApp} from 'vue';
import DashboardRepoList from '../components/DashboardRepoList.vue';
import {initRepoMilestoneListSSE} from './repo-milestone-sse.ts';
export function initDashboardRepoList() {
const el = document.querySelector('#dashboard-repo-list');
if (el) {
createApp(DashboardRepoList).mount(el);
}
// The dashboard milestones page lists milestones across many repos;
// subscribe to live progress for each. subscribeRepos is guarded so
// this is a no-op if repo-legacy already wired it on the same page.
if (document.querySelector('.page-content.dashboard.milestones li.milestone-card[data-repo-id]')) {
initRepoMilestoneListSSE();
}
}
+7
View File
@@ -14,6 +14,7 @@ import {initRepoSettings} from './repo-settings.ts';
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoMilestoneListSSE, initRepoMilestoneSingleSSE} from './repo-milestone-sse.ts';
import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
@@ -50,6 +51,12 @@ export function initRepository() {
// Labels
initCompLabelEdit('.page-content.repository.labels');
initRepoMilestone();
if (pageContent.matches('.page-content.repository.milestones')) {
initRepoMilestoneListSSE();
}
if (pageContent.matches('.page-content.repository.milestone-issue-list')) {
initRepoMilestoneSingleSSE();
}
initRepoNew();
initRepoCloneButtons();
+193
View File
@@ -0,0 +1,193 @@
import {UserEventsSharedWorker} from '../modules/worker.ts';
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
// milestoneTitle does a best-effort lookup of the milestone's display
// name for toast text. List cards expose it as a heading/link inside the
// card; the single-milestone view puts it in the page header. Falls back
// to a generic label so a toast still fires if the markup shifts.
function milestoneTitle(milestoneID: number): string {
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${milestoneID}"]`);
const fromCard = card?.querySelector('.milestone-card-title, h3, .title, a[href*="/milestone/"]')?.textContent?.trim();
if (fromCard) return fromCard;
const onSingle = document.querySelector<HTMLElement>(`progress[data-milestone-id="${milestoneID}"]`);
if (onSingle) {
const h = document.querySelector('.repository.milestone-issue-list .milestone-title, .page-content .milestone-title, h1, h2')?.textContent?.trim();
if (h) return h;
}
return 'Milestone';
}
// sessionTag is generated once per page load. The mutation requests on
// milestone pages (close/open/delete/edit) flow through the existing
// fetch helpers which attach the X-Session-Tag header; the backend
// echoes it back inside SSE payloads so the originating tab can suppress
// its own echo. We only need the read side here: skip any event whose
// session_tag matches ours.
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;
}
type MilestoneProgressPayload = {
repo_id: number;
milestone_id: number;
open_issues: number;
closed_issues: number;
completeness: number;
session_tag?: string;
};
type MilestoneDeletedPayload = {
repo_id: number;
milestone_id: number;
};
function isProgressPayload(p: any): p is MilestoneProgressPayload {
return p && typeof p.completeness === 'number' && 'open_issues' in p;
}
// patchMilestoneCard updates every progress-bar / counter site for a
// single milestone id, covering both the list-card layout (milestones
// list, dashboard) and the single-milestone big progress bar.
function patchMilestoneCard(payload: MilestoneProgressPayload): void {
const selector = `[data-milestone-id="${payload.milestone_id}"]`;
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
// The element itself may be a <progress> (single view) or a card
// <li> containing a <progress> (list views).
const progressEls = el instanceof HTMLProgressElement
? [el]
: Array.from(el.querySelectorAll<HTMLProgressElement>('progress'));
for (const pe of progressEls) {
pe.value = payload.completeness;
}
const scope: ParentNode = el instanceof HTMLProgressElement ? document : el;
for (const pct of scope.querySelectorAll<HTMLElement>('.milestone-completeness-pct')) {
// The list cards render just the number; the single-milestone
// view renders an i18n HTML fragment ("<strong>N%</strong>
// Completed"). Detect which by whether the node already holds a
// <strong> child.
const strong = pct.querySelector('strong');
if (strong) {
strong.textContent = `${payload.completeness}%`;
} else {
pct.textContent = String(payload.completeness);
}
}
for (const oc of scope.querySelectorAll<HTMLElement>('.milestone-open-count')) {
oc.textContent = String(payload.open_issues);
}
for (const cc of scope.querySelectorAll<HTMLElement>('.milestone-closed-count')) {
cc.textContent = String(payload.closed_issues);
}
}
}
function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void {
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${payload.milestone_id}"]`);
if (card) {
card.remove();
return;
}
// Single-milestone view: the milestone we are looking at is gone.
const single = document.querySelector<HTMLElement>(`progress[data-milestone-id="${payload.milestone_id}"]`);
if (single) {
const parts = window.location.pathname.split('/');
// .../milestone/{id} -> go up to the milestones listing.
const idx = parts.lastIndexOf('milestone');
const dest = idx > 0 ? `${parts.slice(0, idx).join('/')}/milestones` : '/';
// Delay so the "milestone deleted" warning toast is visible before
// the page navigates out from under the viewer.
setTimeout(() => { window.location.href = dest }, 1500);
}
}
function dispatchMilestoneEvent(payload: any): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
if (isProgressPayload(payload)) {
patchMilestoneCard(payload);
const total = payload.open_issues + payload.closed_issues;
showInfoToast(`${milestoneTitle(payload.milestone_id)} · ${payload.closed_issues}/${total} closed (${payload.completeness}%)`);
} else if ('milestone_id' in payload && 'repo_id' in payload) {
const title = milestoneTitle(payload.milestone_id);
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
showWarningToast(title === 'Milestone' ? 'A milestone was deleted' : `Milestone “${title}” was deleted`);
}
}
// subscribed guards against a double subscription if more than one init
// entry point matches the same page (e.g. the dashboard milestones page
// is wired both from repo-legacy and dashboard).
let subscribed = false;
// subscribeRepos opens one SharedWorker subscription per distinct repo
// id and dispatches every "repo-milestones.{repoID}" event by payload.
function subscribeRepos(repoIDs: Set<string>): void {
if (subscribed) return;
if (!repoIDs.size) return;
if (!window.EventSource || !window.SharedWorker) return;
subscribed = true;
ensureSessionTag();
let worker: UserEventsSharedWorker;
try {
worker = new UserEventsSharedWorker('repo-milestone-worker');
} catch (error) {
console.error('milestone SSE: failed to start worker', error);
return;
}
const eventNames = new Set<string>();
for (const repoID of repoIDs) {
eventNames.add(`repo-milestones.${repoID}`);
}
worker.addMessageEventListener((event: MessageEvent) => {
if (!event.data || !eventNames.has(event.data.type)) return;
let payload: any;
try {
payload = JSON.parse(event.data.data);
} catch (error) {
console.error('milestone SSE: malformed payload', error, event.data);
return;
}
dispatchMilestoneEvent(payload);
});
worker.startPort();
for (const name of eventNames) {
worker.sharedWorker.port.postMessage({type: 'listen', eventType: name});
}
}
// initRepoMilestoneListSSE wires the milestone list page and the
// dashboard milestones page: collect every distinct data-repo-id present
// on the cards (the dashboard mixes many repos) and subscribe to each.
export function initRepoMilestoneListSSE(): void {
const cards = document.querySelectorAll<HTMLElement>('li.milestone-card[data-repo-id]');
if (!cards.length) return;
const repoIDs = new Set<string>();
for (const card of cards) {
const id = card.getAttribute('data-repo-id');
if (id) repoIDs.add(id);
}
subscribeRepos(repoIDs);
}
// initRepoMilestoneSingleSSE wires the single-milestone issue list view.
export function initRepoMilestoneSingleSSE(): void {
const progress = document.querySelector<HTMLElement>('progress[data-milestone-id][data-repo-id]');
if (!progress) return;
const repoID = progress.getAttribute('data-repo-id');
if (!repoID) return;
subscribeRepos(new Set([repoID]));
}
+344 -2
View File
@@ -1,12 +1,51 @@
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';
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
// issueRef returns a short human label for a card, preferring the
// rendered "#index" anchor text and falling back to the internal id.
function issueRef(card: HTMLElement | null, issueID: number): string {
const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim();
const m = /#\d+/.exec(idx ?? '');
return m ? m[0] : `#${issueID}`;
}
function columnName(board: HTMLElement, columnID: number): string {
const t = board.querySelector<HTMLElement>(`.project-column[data-id="${columnID}"] .project-column-title-text`)?.textContent?.trim();
return t || `column ${columnID}`;
}
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 +68,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 +101,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 +154,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 +214,310 @@ 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 CardStateChangedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
is_closed: boolean;
};
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);
showInfoToast(`#${payload.issue_id}${columnName(board, payload.to_column_id)}`);
return;
}
const ref = issueRef(card, payload.issue_id);
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);
showInfoToast(`${ref}${columnName(board, payload.to_column_id)}`);
}
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
showInfoToast(`#${payload.issue_id} added to ${columnName(board, payload.column_id)}`);
}
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 ref = issueRef(card, payload.issue_id);
const colEl = card.closest<HTMLElement>('.project-column');
card.remove();
if (colEl) updateColumnCount(colEl);
showInfoToast(`${ref} removed from board`);
}
function handleCardStateChanged(board: HTMLElement, payload: CardStateChangedPayload): 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;
// Flip the issue state octicon in place (matches templates/shared/issueicon.tmpl).
// PR cards carry merged/draft variants we don't recompute here; the column
// refetch below keeps state-filtered boards and counts correct regardless.
const icon = card.querySelector<SVGElement>('.issue-card-icon svg');
if (icon && !icon.classList.contains('octicon-git-pull-request')) {
icon.classList.remove('octicon-issue-opened', 'octicon-issue-closed', 'tw-text-green', 'tw-text-red');
icon.classList.add(
payload.is_closed ? 'octicon-issue-closed' : 'octicon-issue-opened',
payload.is_closed ? 'tw-text-red' : 'tw-text-green',
);
}
const ref = issueRef(card, payload.issue_id);
// The card's containing column is `#board_{columnID}` (its direct parent).
const parent = card.parentElement;
if (parent instanceof HTMLElement && parent.id.startsWith('board_')) {
refetchColumn(board, Number(parent.id.slice('board_'.length)));
}
showInfoToast(`${ref} ${payload.is_closed ? 'closed' : 'reopened'}`);
}
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');
const oldTitle = titleEl?.textContent?.trim();
if (titleEl) titleEl.textContent = payload.title;
if (oldTitle && oldTitle !== payload.title) {
showInfoToast(`Column renamed to “${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) return;
const name = colEl.querySelector<HTMLElement>('.project-column-title-text')?.textContent?.trim();
colEl.remove();
showInfoToast(name ? `Column “${name}” removed` : 'A column was removed');
}
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.
// Show a sticky warning first and delay the redirect briefly so the
// user understands why the page is about to change under them.
showWarningToast('This project was deleted — returning to the project list');
const parts = window.location.pathname.split('/');
const dest = parts.length > 1 ? (parts.slice(0, -1).join('/') || '/') : '/';
setTimeout(() => { window.location.href = dest }, 1500);
}
// 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 ('issue_id' in payload && 'is_closed' in payload) {
// CardStateChanged: must precede the CardUnlinked branch below, whose
// "issue_id and no column_id" shape would otherwise swallow it and
// wrongly remove the card on a close/reopen.
handleCardStateChanged(board, payload as CardStateChangedPayload);
} else 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;