Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c45ea82fd1 | |||
| c19ecab35d | |||
| ad46f6cde8 | |||
| 078459c497 | |||
| d4de99f96b | |||
| 9f588d3dd3 | |||
| 4676a3af93 | |||
| 9c1699feb5 | |||
| 15acfdb783 | |||
| 1cd81ff925 | |||
| 580fe2df32 | |||
| 909bff1a52 | |||
| 8ae6245c19 | |||
| 086dd1858e | |||
| 013e844724 | |||
| 5a886307fd | |||
| d4d44cf283 | |||
| 8734be114a | |||
| 921b32984b | |||
| 77e3801a9c | |||
| 1011241a67 | |||
| 187daac598 | |||
| 3738809219 | |||
| ffd5e0698b | |||
| 79f7062d9e | |||
| f01953e764 | |||
| 6a27066269 | |||
| 71f3e28fe5 | |||
| de290f2121 | |||
| 8cd8291ed0 | |||
| 2eb7b3c7da | |||
| 7621b65403 |
@@ -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
|
||||
Executable
+10
@@ -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"
|
||||
Executable
+11
@@ -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."
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
data/
|
||||
repositories/
|
||||
indexers/
|
||||
queues/
|
||||
log/
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -119,6 +119,7 @@ jobs:
|
||||
|
||||
json:
|
||||
- "**/*.json"
|
||||
- "**/*.json5"
|
||||
|
||||
e2e:
|
||||
- "tests/e2e/**"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+212
-475
File diff suppressed because it is too large
Load Diff
@@ -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/**"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1175,6 +1175,7 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac
|
||||
ctx.APIErrorInternal(err)
|
||||
return nil, nil
|
||||
}
|
||||
jobs.SortMatrixGroupsByName()
|
||||
return run, jobs
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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}} {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span> {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
{{svg "octicon-check" 14}}
|
||||
{{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span> {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
</div>
|
||||
{{if .TotalTrackedTime}}
|
||||
<div class="flex-text-block">
|
||||
|
||||
Generated
+2315
-19
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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}} {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span> {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
{{svg "octicon-check" 14}}
|
||||
{{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span> {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
</div>
|
||||
{{if .TotalTrackedTime}}
|
||||
<div class="flex-text-block">
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user