0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-14 10:57:54 +02:00

Merge ceaa60f6911d0f49c1fd09a5e0a909af32910eac into 0a3aaeafe7bef9d6935422f4b91c77c216c01b21

This commit is contained in:
☙◦ The Tablet ❀ GamerGirlandCo ◦❧ 2026-05-09 10:15:23 -07:00 committed by GitHub
commit 2964d3a0d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 55512 additions and 407 deletions

View File

@ -147,6 +147,7 @@ ESLINT_CONCURRENCY ?= 2
SWAGGER_SPEC := templates/swagger/v1_json.tmpl
SWAGGER_SPEC_INPUT := templates/swagger/v1_input.json
SWAGGER_SPEC_GROUP_INPUT := templates/swagger/v1_groups.json
SWAGGER_EXCLUDE := code.gitea.io/sdk
OPENAPI3_SPEC := templates/swagger/v1_openapi3_json.tmpl
@ -228,6 +229,8 @@ generate-swagger: $(SWAGGER_SPEC) $(OPENAPI3_SPEC) ## generate the swagger spec
$(SWAGGER_SPEC): $(GO_SOURCES) $(SWAGGER_SPEC_INPUT)
$(GO) run $(SWAGGER_PACKAGE) generate spec --exclude "$(SWAGGER_EXCLUDE)" --input "$(SWAGGER_SPEC_INPUT)" --output './$(SWAGGER_SPEC)'
$(GO) generate -v ./build_tools/...
$(GO) run $(SWAGGER_PACKAGE) mixin -o './$(SWAGGER_SPEC)' $(SWAGGER_SPEC) $(SWAGGER_SPEC_GROUP_INPUT)
.PHONY: swagger-check
swagger-check: generate-swagger

308
build_tools/swagger/main.go Normal file
View File

@ -0,0 +1,308 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:generate go run main.go ../../
package main
import (
"bytes"
encjson "encoding/json" //nolint:depguard // this package wraps it
"errors"
"fmt"
"iter"
"log"
"os"
"path/filepath"
"regexp"
"code.gitea.io/gitea/modules/json"
)
type Pair struct {
Key string
Value any
}
type OrderedMap struct {
Pairs []Pair
indices map[string]int
}
func (o OrderedMap) Get(key string) (any, bool) {
if _, ok := o.indices[key]; ok {
return o.Pairs[o.indices[key]].Value, true
}
return nil, false
}
func (o *OrderedMap) Set(key string, value any) {
if _, ok := o.indices[key]; ok {
o.Pairs[o.indices[key]] = Pair{key, value}
} else {
o.Pairs = append(o.Pairs, Pair{key, value})
o.indices[key] = len(o.Pairs) - 1
}
}
func (o OrderedMap) Iter() iter.Seq2[string, any] {
return func(yield func(string, any) bool) {
for _, it := range o.Pairs {
yield(it.Key, it.Value)
}
}
}
var errNilSentinel = errors.New("nil sentinel")
func (o *OrderedMap) UnmarshalJSON(data []byte) error {
trimmed := bytes.TrimSpace(data)
if bytes.Equal(trimmed, []byte("null")) {
o.Pairs = nil
o.indices = nil
return nil
}
dec := encjson.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
tok, err := dec.Token()
if err != nil {
return err
}
delim, ok := tok.(encjson.Delim)
if !ok || delim != '{' {
return errors.New("OrderedMap: expected '{' at start of object")
}
// Reset storage
if o.indices == nil {
o.indices = make(map[string]int)
} else {
for k := range o.indices {
delete(o.indices, k)
}
}
o.Pairs = o.Pairs[:0]
for dec.More() {
tk, err := dec.Token()
if err != nil {
return err
}
key, ok := tk.(string)
if !ok {
return fmt.Errorf(
"OrderedMap: expected string key, got %T (%v)",
tk,
tk,
)
}
var raw encjson.RawMessage
if err := dec.Decode(&raw); err != nil {
return fmt.Errorf("OrderedMap: decode value for %q: %w", key, err)
}
val, err := decodeJSONValue(raw)
if err != nil {
return fmt.Errorf("OrderedMap: unmarshal value for %q: %w", key, err)
}
if idx, exists := o.indices[key]; exists {
o.Pairs[idx].Value = val
} else {
o.indices[key] = len(o.Pairs)
o.Pairs = append(o.Pairs, Pair{Key: key, Value: val})
}
}
end, err := dec.Token()
if err != nil {
return err
}
if d, ok := end.(encjson.Delim); !ok || d != '}' {
return errors.New("OrderedMap: expected '}' at end of object")
}
return nil
}
func decodeJSONValue(raw encjson.RawMessage) (any, error) {
t := bytes.TrimSpace(raw)
if bytes.Equal(t, []byte("null")) {
return nil, errNilSentinel
}
d := encjson.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
tok, err := d.Token()
if err != nil {
return nil, err
}
switch tt := tok.(type) {
case encjson.Delim:
switch tt {
case '{':
var inner OrderedMap
if err := inner.UnmarshalJSON(raw); err != nil {
return nil, err
}
return inner, nil
case '[':
var arr []any
for d.More() {
var elemRaw encjson.RawMessage
if err := d.Decode(&elemRaw); err != nil {
return nil, err
}
v, err := decodeJSONValue(elemRaw)
if err != nil && !errors.Is(err, errNilSentinel) {
return nil, err
}
arr = append(arr, v)
}
if end, err := d.Token(); err != nil {
return nil, err
} else if end != encjson.Delim(']') {
return nil, errors.New("expected ']'")
}
return arr, nil
default:
return nil, fmt.Errorf("unexpected delimiter %q", tt)
}
default:
var v any
d = encjson.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
if err := d.Decode(&v); err != nil {
return nil, err
}
return v, nil
}
}
func (o OrderedMap) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
buf.WriteString("{")
for i, kv := range o.Pairs {
if i != 0 {
buf.WriteString(",")
}
key, err := json.Marshal(kv.Key)
if err != nil {
return nil, err
}
buf.Write(key)
buf.WriteString(":")
val, err := json.Marshal(kv.Value)
if err != nil {
return nil, err
}
buf.Write(val)
}
buf.WriteString("}")
return buf.Bytes(), nil
}
var rxPath = regexp.MustCompile(`(?m)^(/repos/\{owner})/(\{repo})`)
func generatePaths(root string) *OrderedMap {
pathData := &OrderedMap{
indices: make(map[string]int),
}
endpoints := &OrderedMap{
indices: make(map[string]int),
}
fileToRead, err := filepath.Rel(root, "./templates/swagger/v1_json.tmpl")
if err != nil {
log.Fatal(err)
}
swaggerBytes, err := os.ReadFile(fileToRead)
if err != nil {
log.Fatal(err)
}
raw := OrderedMap{
indices: make(map[string]int),
}
err = json.Unmarshal(swaggerBytes, &raw)
if err != nil {
log.Fatal(err)
}
rpaths, has := raw.Get("paths")
if !has {
log.Fatal("paths not found")
}
paths := rpaths.(OrderedMap)
for k, v := range paths.Iter() {
if !rxPath.MatchString(k) {
// skip if this endpoint does not start with `/repos/{owner}/{repo}`
continue
}
// generate new endpoint path with `/group/{group_id}` in between the `owner` and `repo` params
nk := rxPath.ReplaceAllString(k, "$1/group/{group_id}/$2")
methodMap := v.(OrderedMap)
for method, methodSpec := range methodMap.Iter() {
specMap := methodSpec.(OrderedMap)
var params []OrderedMap
aparams, has := specMap.Get("parameters")
if !has {
continue
}
rparams := aparams.([]any)
for _, rparam := range rparams {
params = append(params, rparam.(OrderedMap))
}
param := OrderedMap{
indices: make(map[string]int),
}
param.Set("description", "group ID of the repo")
param.Set("name", "group_id")
param.Set("type", "integer")
param.Set("format", "int64")
param.Set("required", true)
param.Set("in", "path")
params = append(params, param)
// i believe for...range loops create copies of each item that's iterated over,
// so we need to take extra care to ensure we're mutating the original map entry
specMap.Set("parameters", params)
methodMap.Set(method, specMap)
//(methodMap[method].(map[string]any))["parameters"] = params
}
endpoints.Set(nk, methodMap)
}
pathData.Set("paths", endpoints)
return pathData
}
func writeMapToFile(filename string, data *OrderedMap) {
marshaledBytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Fatal(err)
}
marshaledBytes = append(marshaledBytes, '\n')
err = os.WriteFile(filename, marshaledBytes, 0o666)
if err != nil {
log.Fatal(err)
}
}
func main() {
var err error
root := "../../"
if len(os.Args) > 1 {
root = os.Args[1]
}
err = os.Chdir(root)
if err != nil {
log.Fatal(err)
}
pathData := generatePaths(".")
out := "./templates/swagger/v1_groups.json"
writeMapToFile(out, pathData)
}

View File

@ -200,6 +200,7 @@ Gitea or set your environment appropriately.`, "")
// the environment is set by serv command
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
username := os.Getenv(repo_module.EnvRepoUsername)
groupID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvRepoGroupID), 10, 64)
reponame := os.Getenv(repo_module.EnvRepoName)
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
@ -271,7 +272,7 @@ Gitea or set your environment appropriately.`, "")
hookOptions.OldCommitIDs = oldCommitIDs
hookOptions.NewCommitIDs = newCommitIDs
hookOptions.RefFullNames = refFullNames
extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
extra := private.HookPreReceive(ctx, username, reponame, groupID, hookOptions)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(batch) failed: %v", extra.Error)
}
@ -297,7 +298,7 @@ Gitea or set your environment appropriately.`, "")
fmt.Fprintf(out, " Checking %d references\n", count)
extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
extra := private.HookPreReceive(ctx, username, reponame, groupID, hookOptions)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(last) failed: %v", extra.Error)
}
@ -370,6 +371,7 @@ Gitea or set your environment appropriately.`, "")
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
pusherName := os.Getenv(repo_module.EnvPusherName)
groupID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvRepoGroupID), 10, 64)
hookOptions := private.HookOptions{
UserName: pusherName,
@ -417,7 +419,7 @@ Gitea or set your environment appropriately.`, "")
hookOptions.OldCommitIDs = oldCommitIDs
hookOptions.NewCommitIDs = newCommitIDs
hookOptions.RefFullNames = refFullNames
resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions)
resp, extra := private.HookPostReceive(ctx, repoUser, repoName, groupID, hookOptions)
if extra.HasError() {
_ = dWriter.Close()
hookPrintResults(results)
@ -437,7 +439,7 @@ Gitea or set your environment appropriately.`, "")
if count == 0 {
if wasEmpty && masterPushed {
// We need to tell the repo to reset the default branch to master
extra := private.SetDefaultBranch(ctx, repoUser, repoName, "master")
extra := private.SetDefaultBranch(ctx, repoUser, repoName, groupID, "master")
if extra.HasError() {
return fail(ctx, extra.UserMsg, "SetDefaultBranch failed: %v", extra.Error)
}
@ -455,7 +457,7 @@ Gitea or set your environment appropriately.`, "")
fmt.Fprintf(out, " Processing %d references\n", count)
resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions)
resp, extra := private.HookPostReceive(ctx, repoUser, repoName, groupID, hookOptions)
if resp == nil {
_ = dWriter.Close()
hookPrintResults(results)
@ -468,7 +470,7 @@ Gitea or set your environment appropriately.`, "")
if wasEmpty && masterPushed {
// We need to tell the repo to reset the default branch to master
extra := private.SetDefaultBranch(ctx, repoUser, repoName, "master")
extra := private.SetDefaultBranch(ctx, repoUser, repoName, groupID, "master")
if extra.HasError() {
return fail(ctx, extra.UserMsg, "SetDefaultBranch failed: %v", extra.Error)
}
@ -537,6 +539,7 @@ Gitea or set your environment appropriately.`, "")
repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
pusherName := os.Getenv(repo_module.EnvPusherName)
groupID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvRepoGroupID), 10, 64)
// 1. Version and features negotiation.
// S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n)
@ -651,7 +654,7 @@ Gitea or set your environment appropriately.`, "")
}
// 3. run hook
resp, extra := private.HookProcReceive(ctx, repoUser, repoName, hookOptions)
resp, extra := private.HookProcReceive(ctx, repoUser, repoName, groupID, hookOptions)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookProcReceive failed: %v", extra.Error)
}

View File

@ -201,8 +201,17 @@ func runServ(ctx context.Context, c *cli.Command) error {
repoPath := strings.TrimPrefix(sshCmdArgs[1], "/")
repoPathFields := strings.SplitN(repoPath, "/", 2)
rawGroup, _, _ := strings.Cut(repoPathFields[1], "/")
var groupID int64
if len(repoPathFields) != 2 {
return fail(ctx, "Invalid repository path", "Invalid repository path: %v", repoPath)
if len(repoPathFields) == 3 {
groupID, err = strconv.ParseInt(rawGroup, 10, 64)
if err != nil {
return fail(ctx, "Invalid repository path", "Invalid repository path: %v", repoPath)
}
} else {
return fail(ctx, "Invalid repository path", "Invalid repository path: %v", repoPath)
}
}
username := repoPathFields[0]
@ -249,16 +258,16 @@ func runServ(ctx context.Context, c *cli.Command) error {
requestedMode := getAccessMode(verb, lfsVerb)
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
results, extra := private.ServCommand(ctx, keyID, username, reponame, groupID, requestedMode, verb, lfsVerb)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "ServCommand failed: %s", extra.Error)
}
// because the original repoPath maybe redirected, we need to use the returned actual repository information
if results.IsWiki {
repoPath = repo_model.RelativeWikiPath(results.OwnerName, results.RepoName)
repoPath = repo_model.RelativeWikiPath(results.OwnerName, results.RepoName, groupID)
} else {
repoPath = repo_model.RelativePath(results.OwnerName, results.RepoName)
repoPath = repo_model.RelativePath(results.OwnerName, results.RepoName, groupID)
}
// LFS SSH protocol
@ -322,6 +331,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
repo_module.EnvRepoUsername+"="+results.OwnerName,
repo_module.EnvPusherName+"="+results.UserName,
repo_module.EnvPusherEmail+"="+results.UserEmail,
repo_module.EnvRepoGroupID+"="+strconv.FormatInt(groupID, 10),
repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10),
repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
repo_module.EnvPRID+"="+strconv.Itoa(0),

View File

@ -259,6 +259,14 @@ func (a *Action) GetRepoName(ctx context.Context) string {
return a.Repo.Name
}
func (a *Action) GetRepoGroup(ctx context.Context) string {
_ = a.LoadRepo(ctx)
if a.Repo == nil || a.Repo.GroupID == 0 {
return ""
}
return strconv.FormatInt(a.Repo.GroupID, 10)
}
// ShortRepoName returns the name of the action repository
// trimmed to max 33 chars.
func (a *Action) ShortRepoName(ctx context.Context) string {
@ -267,19 +275,26 @@ func (a *Action) ShortRepoName(ctx context.Context) string {
// GetRepoPath returns the virtual path to the action repository.
func (a *Action) GetRepoPath(ctx context.Context) string {
return path.Join(a.GetRepoUserName(ctx), a.GetRepoName(ctx))
return path.Join(a.GetRepoUserName(ctx), a.GetRepoGroup(ctx), a.GetRepoName(ctx))
}
// ShortRepoPath returns the virtual path to the action repository
// trimmed to max 20 + 1 + 33 chars.
func (a *Action) ShortRepoPath(ctx context.Context) string {
return path.Join(a.ShortRepoUserName(ctx), a.ShortRepoName(ctx))
return path.Join(a.ShortRepoUserName(ctx), a.makeGroupSegment(ctx), a.GetRepoGroup(ctx), a.ShortRepoName(ctx))
}
// GetRepoLink returns relative link to action repository.
func (a *Action) GetRepoLink(ctx context.Context) string {
// path.Join will skip empty strings
return path.Join(setting.AppSubURL, "/", url.PathEscape(a.GetRepoUserName(ctx)), url.PathEscape(a.GetRepoName(ctx)))
return path.Join(setting.AppSubURL, "/", url.PathEscape(a.GetRepoUserName(ctx)), a.makeGroupSegment(ctx), a.GetRepoGroup(ctx), url.PathEscape(a.GetRepoName(ctx)))
}
func (a *Action) makeGroupSegment(ctx context.Context) string {
if a.GetRepoGroup(ctx) != "" {
return "group"
}
return ""
}
// GetRepoAbsoluteLink returns the absolute link to action repository.

View File

@ -29,6 +29,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 2
@ -60,6 +61,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: true
group_id: 0
-
id: 3
@ -91,6 +93,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 4
@ -124,6 +127,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 5
@ -154,6 +158,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 1
-
id: 6
@ -184,6 +190,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 7
@ -214,6 +221,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 8
@ -244,6 +252,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 9
@ -274,6 +283,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 10
@ -305,6 +315,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 11
@ -336,6 +347,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 12
@ -366,6 +378,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 13
@ -396,6 +409,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 14
@ -427,6 +441,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 15
@ -458,6 +473,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 16
@ -489,6 +505,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 17
@ -519,6 +536,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 18
@ -549,6 +567,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 19
@ -579,6 +598,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 20
@ -609,6 +629,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 21
@ -639,6 +660,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 22
@ -669,6 +691,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 23
@ -699,6 +722,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 0
-
id: 24
@ -729,6 +754,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 1
-
id: 25
@ -759,6 +786,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 26
@ -789,6 +817,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 27
@ -819,6 +848,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 0
-
id: 28
@ -849,6 +880,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 1
-
id: 29
@ -879,6 +912,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 30
@ -909,6 +943,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 31
@ -940,6 +975,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 32 # org public repo
@ -970,6 +1006,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 2
-
id: 33
@ -1001,6 +1039,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 34
@ -1031,6 +1070,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 35
@ -1061,6 +1101,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 36
@ -1092,6 +1133,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 37
@ -1123,6 +1165,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 38
@ -1154,6 +1197,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 0
-
id: 39
@ -1185,6 +1230,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 1
-
id: 40
@ -1216,6 +1263,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 0
-
id: 41
@ -1247,6 +1296,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 1
-
id: 42
@ -1278,6 +1329,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 43
@ -1308,6 +1360,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 0
-
id: 44
@ -1339,6 +1393,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 45
@ -1369,6 +1424,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 46
@ -1400,6 +1456,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 1
-
id: 47
@ -1431,6 +1489,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 2
-
id: 48
@ -1462,6 +1522,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 3
-
id: 49
@ -1493,6 +1555,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 50
@ -1524,6 +1587,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 51
@ -1555,6 +1619,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 52
@ -1586,6 +1651,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 53
@ -1614,6 +1680,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 54
@ -1695,6 +1762,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 60
@ -1726,6 +1794,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
-
id: 61
@ -1757,6 +1826,8 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 0
-
id: 62
@ -1788,5 +1859,7 @@
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
group_id: 0
group_sort_order: 0
# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly

View File

@ -26,7 +26,7 @@ func TestIterateLFSMetaObjectsForRepoUpdatesDoNotSkip(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, "user2", "repo1")
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, "user2", "repo1", 0)
assert.NoError(t, err)
defer test.MockVariableValue(&setting.Database.IterateBufferSize, 1)()

41
models/group/avatar.go Normal file
View File

@ -0,0 +1,41 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"net/url"
"code.gitea.io/gitea/models/avatars"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
)
func (g *Group) CustomAvatarRelativePath() string {
return g.Avatar
}
func (g *Group) relAvatarLink() string {
// If no avatar - path is empty
avatarPath := g.CustomAvatarRelativePath()
if len(avatarPath) == 0 {
return ""
}
return setting.AppSubURL + "/group-avatars/" + url.PathEscape(g.Avatar)
}
func (g *Group) AvatarLink(ctx context.Context) string {
relLink := g.relAvatarLink()
if relLink != "" {
return httplib.MakeAbsoluteURL(ctx, relLink)
}
return ""
}
func (g *Group) AvatarLinkWithSize(size int) string {
if g.Avatar == "" {
return avatars.DefaultAvatarLink()
}
return avatars.GenerateUserAvatarImageLink(g.Avatar, size)
}

62
models/group/errors.go Normal file
View File

@ -0,0 +1,62 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"errors"
"fmt"
"code.gitea.io/gitea/modules/util"
)
type ErrGroupNotExist struct {
ID int64
}
// IsErrGroupNotExist checks if an error is a ErrCommentNotExist.
func IsErrGroupNotExist(err error) bool {
var errGroupNotExist ErrGroupNotExist
ok := errors.As(err, &errGroupNotExist)
return ok
}
func (err ErrGroupNotExist) Error() string {
return fmt.Sprintf("group does not exist [id: %d]", err.ID)
}
func (err ErrGroupNotExist) Unwrap() error {
return util.ErrNotExist
}
type ErrGroupTooDeep struct {
ID int64
}
func IsErrGroupTooDeep(err error) bool {
var errGroupTooDeep ErrGroupTooDeep
ok := errors.As(err, &errGroupTooDeep)
return ok
}
func (err ErrGroupTooDeep) Error() string {
return fmt.Sprintf("group has reached or exceeded the subgroup nesting limit [id: %d]", err.ID)
}
type ErrUserDoesNotHaveAccessToGroup struct {
UserID, GroupID int64
}
func (e ErrUserDoesNotHaveAccessToGroup) Error() string {
return fmt.Sprintf("user %d does not have access to group %d", e.UserID, e.GroupID)
}
func (e ErrUserDoesNotHaveAccessToGroup) Unwrap() error {
return util.ErrPermissionDenied
}
func IsErrUserDoesNotHaveAccessToGroup(err error) bool {
var eNoAccess ErrUserDoesNotHaveAccessToGroup
ok := errors.As(err, &eNoAccess)
return ok
}

502
models/group/group.go Normal file
View File

@ -0,0 +1,502 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"fmt"
"net/url"
"slices"
"strconv"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
)
// Group represents a group of repositories for a user or organization
type Group struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
OwnerName string
Owner *user_model.User `xorm:"-"`
LowerName string `xorm:"TEXT NOT NULL"`
Name string `xorm:"TEXT NOT NULL"`
Description string `xorm:"TEXT"`
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 0"`
Avatar string `xorm:"VARCHAR(64)"`
ParentGroupID int64 `xorm:"INDEX DEFAULT NULL"`
ParentGroup *Group `xorm:"-"`
Subgroups RepoGroupList `xorm:"-"`
SortOrder int `xorm:"INDEX"`
}
// GroupLink returns the link to this group
func (g *Group) GroupLink() string {
return setting.AppSubURL + "/" + url.PathEscape(g.OwnerName) + "/groups/" + strconv.FormatInt(g.ID, 10)
}
func (g *Group) OrgGroupLink() string {
return setting.AppSubURL + "/org/" + url.PathEscape(g.OwnerName) + "/groups/" + strconv.FormatInt(g.ID, 10)
}
func (Group) TableName() string { return "repo_group" }
func init() {
db.RegisterModel(new(Group))
db.RegisterModel(new(RepoGroupTeam))
db.RegisterModel(new(RepoGroupUnit))
}
func (g *Group) doLoadSubgroups(ctx context.Context, recursive bool, cond builder.Cond, currentLevel int) error {
if currentLevel >= 20 {
return ErrGroupTooDeep{
g.ID,
}
}
if g.Subgroups != nil {
return nil
}
var err error
g.Subgroups, err = FindGroupsByCond(ctx, &FindGroupsOptions{
ParentGroupID: g.ID,
}, cond)
if err != nil {
return err
}
slices.SortStableFunc(g.Subgroups, func(a, b *Group) int {
return a.SortOrder - b.SortOrder
})
if recursive {
for _, group := range g.Subgroups {
err = group.doLoadSubgroups(ctx, recursive, cond, currentLevel+1)
if err != nil {
return err
}
}
}
return nil
}
func (g *Group) LoadSubgroups(ctx context.Context, recursive bool) error {
fgo := &FindGroupsOptions{
ParentGroupID: g.ID,
}
return g.doLoadSubgroups(ctx, recursive, fgo.ToConds(), 0)
}
func (g *Group) LoadAccessibleSubgroups(ctx context.Context, recursive bool, doer *user_model.User) error {
return g.doLoadSubgroups(ctx, recursive, AccessibleGroupCondition(doer, g.OwnerID, unit.TypeInvalid, perm.AccessModeRead), 0)
}
func (g *Group) LoadAttributes(ctx context.Context) error {
err := g.LoadOwner(ctx)
if err != nil {
return err
}
return g.LoadParentGroup(ctx)
}
func (g *Group) LoadParentGroup(ctx context.Context) error {
if g.ParentGroup != nil {
return nil
}
if g.ParentGroupID == 0 {
return nil
}
parentGroup, err := GetGroupByID(ctx, g.ParentGroupID)
if err != nil {
return err
}
g.ParentGroup = parentGroup
return nil
}
func (g *Group) LoadOwner(ctx context.Context) error {
if g.Owner != nil {
return nil
}
var err error
g.Owner, err = user_model.GetUserByID(ctx, g.OwnerID)
return err
}
func (g *Group) CanAccess(ctx context.Context, user *user_model.User) (bool, error) {
return g.CanAccessAtLevel(ctx, user, perm.AccessModeRead)
}
func (g *Group) CanAccessAtLevel(ctx context.Context, user *user_model.User, level perm.AccessMode) (bool, error) {
return g.CanAccessUnitAtLevel(ctx, user, unit.TypeInvalid, level)
}
func (g *Group) CanAccessUnitAtLevel(ctx context.Context, user *user_model.User, u unit.Type, level perm.AccessMode) (bool, error) {
if user != nil {
ownedBy, err := g.IsOwnedBy(ctx, user.ID)
if err != nil {
return false, err
}
if ownedBy {
return true, nil
}
}
orCond := builder.Or(AccessibleGroupCondition(user, g.OwnerID, u, level))
if level == perm.AccessModeRead {
orCond = orCond.Or(builder.Eq{"`repo_group`.visibility": structs.VisibleTypePublic})
}
return db.GetEngine(ctx).Table(g.TableName()).Where(builder.And(builder.Eq{"`repo_group`.id": g.ID}, orCond)).Exist()
}
func (g *Group) IsOwnedBy(ctx context.Context, userID int64) (bool, error) {
return db.GetEngine(ctx).
Where(
builder.Or(
UserOrgTeamPermCond("`repo_group`.id", userID, g.OwnerID, perm.AccessModeOwner),
universalGroupPermBuilder("`repo_group`.id", userID, g.OwnerID, false)).
And(builder.Eq{"`repo_group`.id": g.ID})).
Table(g.TableName()).
Exist()
}
func (g *Group) CanCreateIn(ctx context.Context, userID int64) (bool, error) {
cond := builder.Eq{
"team_user.uid": userID,
"repo_group_team.group_id": g.ID,
"repo_group_team.can_create_in": true,
}
isAdmin, err := g.IsAdminOf(ctx, userID)
if err != nil {
return false, err
}
res, err := db.GetEngine(ctx).
Join("INNER", "team_user", "team_user.team_id = repo_group_team.team_id").
Where(cond).
Table("repo_group_team").
Exist()
if err != nil {
return false, err
}
return isAdmin || res, nil
}
func (g *Group) IsAdminOf(ctx context.Context, userID int64) (bool, error) {
return db.GetEngine(ctx).
Where(
builder.Or(
UserOrgTeamPermCond("`repo_group`.id", userID, g.OwnerID, perm.AccessModeAdmin),
universalGroupPermBuilder("`repo_group`.id", userID, g.OwnerID, false)).
And(builder.Eq{"`repo_group`.id": g.ID})).
Table(g.TableName()).
Exist()
}
func (g *Group) ShortName(length int) string {
return util.EllipsisDisplayString(g.Name, length)
}
func (g *Group) IsPrivateBecauseOfParentPermissions(ctx context.Context, user *user_model.User) (bool, error) {
cond := AccessibleParentGroupCond(ctx, "`repo_group`.`id`", g.ID, user)
has, err := db.GetEngine(ctx).Where(cond.And(builder.Eq{
"`repo_group`.id": g.ID,
})).Table(g.TableName()).Exist()
return !has, err
}
func GetGroupByIDAndCond(ctx context.Context, id int64, cond builder.Cond) (*Group, error) {
group := new(Group)
has, err := db.GetEngine(ctx).
Where(cond.And(builder.Eq{"`repo_group`.id": id})).Get(group)
if err != nil {
return nil, err
} else if !has {
return nil, ErrGroupNotExist{id}
}
return group, nil
}
func GetGroupByID(ctx context.Context, id int64) (*Group, error) {
return GetGroupByIDAndCond(ctx, id, builder.Expr("1 = 1"))
}
func GetGroupByRepoID(ctx context.Context, repoID int64) (*Group, error) {
group := new(Group)
_, err := db.GetEngine(ctx).
Join("INNER", "repository", "repository.group_id = repo_group.id").
Where(builder.Eq{"repository.`id`": repoID}).
Get(group)
return group, err
}
func ParentGroupCondByRepoID(ctx context.Context, repoID int64, idStr string) builder.Cond {
g, err := GetGroupByRepoID(ctx, repoID)
if err != nil {
return builder.In(idStr)
}
return ParentGroupCond(ctx, idStr, g.ID)
}
type FindGroupsOptions struct {
db.ListOptions
OwnerID int64
ParentGroupID int64
CanCreateIn optional.Option[bool]
ActorID int64
Name string
}
func (opts FindGroupsOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}
if opts.ParentGroupID > 0 {
cond = cond.And(builder.Eq{"parent_group_id": opts.ParentGroupID})
} else if opts.ParentGroupID == 0 {
cond = cond.And(builder.Eq{"parent_group_id": 0})
}
if opts.CanCreateIn.Has() && opts.ActorID > 0 {
cond = cond.And(builder.In("id",
builder.Select("repo_group_team.group_id").
From("repo_group_team").
Where(builder.Eq{"team_user.uid": opts.ActorID}).
Join("INNER", "team_user", "team_user.team_id = repo_group_team.team_id").
And(builder.Eq{"repo_group_team.can_create_in": true})))
}
if opts.Name != "" {
cond = cond.And(builder.Eq{"lower_name": opts.Name})
}
return cond
}
func FindGroups(ctx context.Context, opts *FindGroupsOptions) (RepoGroupList, error) {
sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.Page > 0 {
sess = db.SetSessionPagination(sess, opts)
}
groups := make([]*Group, 0, 10)
return groups, sess.
Asc("repo_group.sort_order").
Find(&groups)
}
func findGroupsByCond(ctx context.Context, opts *FindGroupsOptions, cond builder.Cond) db.Engine {
if opts.Page <= 0 {
opts.Page = 1
}
sess := db.GetEngine(ctx).Where(cond.And(opts.ToConds()))
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
return sess.Asc("sort_order")
}
func FindGroupsByCond(ctx context.Context, opts *FindGroupsOptions, cond builder.Cond) (RepoGroupList, error) {
defaultSize := 50
if opts.PageSize > 0 {
defaultSize = opts.PageSize
}
sess := findGroupsByCond(ctx, opts, cond)
groups := make([]*Group, 0, defaultSize)
if err := sess.Find(&groups); err != nil {
return nil, err
}
return groups, nil
}
func CountGroups(ctx context.Context, opts *FindGroupsOptions) (int64, error) {
return db.GetEngine(ctx).Where(opts.ToConds()).Count(new(Group))
}
func UpdateGroupOwnerName(ctx context.Context, oldUser, newUser string) error {
if _, err := db.GetEngine(ctx).Exec("UPDATE `repo_group` SET owner_name=? WHERE owner_name=?", newUser, oldUser); err != nil {
return fmt.Errorf("change group owner name: %w", err)
}
return nil
}
// GetParentGroupChain returns a slice containing a group and its ancestors
func GetParentGroupChain(ctx context.Context, groupID int64) (RepoGroupList, error) {
groupList := make([]*Group, 0, 20)
currentGroupID := groupID
for {
if currentGroupID < 1 {
break
}
if len(groupList) >= 20 {
return nil, ErrGroupTooDeep{currentGroupID}
}
currentGroup, err := GetGroupByID(ctx, currentGroupID)
if err != nil {
return nil, err
}
groupList = append(groupList, currentGroup)
currentGroupID = currentGroup.ParentGroupID
}
slices.Reverse(groupList)
return groupList, nil
}
func GetParentGroupIDChain(ctx context.Context, groupID int64) ([]int64, error) {
var ids []int64
groupList, err := GetParentGroupChain(ctx, groupID)
if err != nil {
return nil, err
}
ids = util.SliceMap(groupList, func(g *Group) int64 {
return g.ID
})
return ids, err
}
func groupHierarchyCTEBuilder(cond builder.Cond) builder.Cond {
firstPart := builder.Select(fmt.Sprintf("repo_group.*"), "1 as depth").
From("repo_group").
Where(builder.And(builder.Eq{
"parent_group_id": 0,
}, cond))
secondPart := builder.Select("r.*", "h.depth + 1").
From("repo_group", "r").
Join("INNER", "group_hierarchy h", "r.parent_group_id = h.id")
firstSql, _ := firstPart.ToBoundSQL()
secondSql, _ := secondPart.ToBoundSQL()
return builder.Expr(firstSql + " UNION ALL " + secondSql)
}
func AccessibleParentGroupCond(ctx context.Context, idStr string, groupID int64, user *user_model.User) builder.Cond {
owner, err := GetOwnerByGroupID(ctx, groupID)
if err != nil {
return builder.Exists(builder.Select("1 as dummy").Where(builder.Eq{
"dummy": 1,
}))
}
accessibleCond := AccessibleGroupCondition(user, owner.ID, unit.TypeInvalid, perm.AccessModeRead)
unionBldr := groupHierarchyCTEBuilder(accessibleCond)
unionSql, err := builder.ToBoundSQL(unionBldr)
if err != nil {
}
s := db.GetEngine(ctx)
s.SQL("WITH RECURSIVE group_hierarchy AS ("+unionSql+") SELECT id from group_hierarchy", unionSql)
var g []*Group
err = s.Find(&g)
if err != nil {
log.Info("%s", err.Error())
}
return builder.In(idStr, builder.Expr("(WITH RECURSIVE group_hierarchy AS ("+unionSql+") SELECT id from group_hierarchy)"))
//db.GetEngine(ctx).SQL()
}
// ParentGroupCond returns a condition matching a group and its ancestors
func ParentGroupCond(ctx context.Context, idStr string, groupID int64) builder.Cond {
groupList, err := GetParentGroupIDChain(ctx, groupID)
if err != nil {
log.Info("Error building group cond: %w", err)
return builder.NotIn(idStr)
}
return builder.In(idStr, groupList)
}
func UpdateGroup(ctx context.Context, group *Group) error {
sess := db.GetEngine(ctx)
_, err := sess.Table(group.TableName()).ID(group.ID).Update(group)
return err
}
func MoveGroup(ctx context.Context, group *Group, newParent int64, newSortOrder int) error {
sess := db.GetEngine(ctx)
ng, err := GetGroupByID(ctx, newParent)
if err != nil && !IsErrGroupNotExist(err) {
return err
}
var siblings RepoGroupList
var tmpSiblings RepoGroupList
if ng != nil {
if ng.OwnerID != group.OwnerID {
return fmt.Errorf("group[%d]'s ownerID is not equal to new parent group[%d]'s owner ID", group.ID, ng.ID)
}
if err = ng.LoadSubgroups(ctx, false); err != nil {
return err
}
filtered := container.Filter(ng.Subgroups, func(e *Group) bool {
return e.ID != group.ID
})
siblings = append(append(filtered[0:min(newSortOrder, len(ng.Subgroups))], group), filtered[newSortOrder:]...)
} else if newParent <= 0 {
tmpSiblings, err = FindGroups(ctx, &FindGroupsOptions{
OwnerID: group.OwnerID,
ParentGroupID: 0,
})
tmpSiblings = container.Filter(tmpSiblings, func(e *Group) bool {
return group.ID != e.ID
})
tmpSiblings2 := make(RepoGroupList, newSortOrder)
copy(tmpSiblings2, tmpSiblings[0:newSortOrder])
tmpSiblings2 = append(tmpSiblings2, group)
siblings = append(tmpSiblings2, tmpSiblings[newSortOrder:]...)
}
parentGroupChain, err := GetParentGroupChain(ctx, newParent)
if err != nil {
return err
}
if len(parentGroupChain) >= 20 {
return ErrGroupTooDeep{
ID: group.ID,
}
}
err = group.LoadOwner(ctx)
if err != nil {
return err
}
group.OwnerName = group.Owner.Name
group.ParentGroupID = newParent
group.SortOrder = newSortOrder
for i, gg := range siblings {
log.Info("ITEM %+v", gg)
gg.SortOrder = i
if _, err = sess.Table(group.TableName()).
ID(gg.ID).
AllCols().
Update(gg); err != nil {
return err
}
}
if group.ParentGroup != nil && newParent != 0 {
group.ParentGroup = nil
if err = group.LoadParentGroup(ctx); err != nil {
return err
}
}
return nil
}
func GetOwnerByGroupID(ctx context.Context, groupID int64) (*user_model.User, error) {
e := db.GetEngine(ctx)
tableName := "repo_group"
user := new(user_model.User)
has, err := e.Join("INNER", tableName, fmt.Sprintf("`%s`.owner_id = `user`.`id`", tableName)).
Where(builder.Eq{fmt.Sprintf("`%s`.id", tableName): groupID}).Get(user)
if !has {
return nil, user_model.ErrUserNotExist{}
}
return user, err
}

128
models/group/group_list.go Normal file
View File

@ -0,0 +1,128 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/structs"
"xorm.io/builder"
)
type RepoGroupList []*Group
func (groups RepoGroupList) LoadOwners(ctx context.Context) error {
for _, g := range groups {
if g.Owner == nil {
err := g.LoadOwner(ctx)
if err != nil {
return err
}
}
}
return nil
}
func universalGroupPermBuilder(idStr string, userID, orgID int64, includeAdmin bool) builder.Cond {
adminSubquery := builder.Select("1").
From("`user`").
Where(builder.Eq{"`user`.is_admin": true, "`user`.`id`": userID})
eqCond := builder.Eq{"`team_user`.uid": userID}
teamSubquery := builder.Select("`team`.id").From("`team`").
Join("LEFT", "team_user", "team.id = team_user.team_id").
Join("LEFT", "`user` as iu", "iu.`id` = `team_user`.uid").
Where(builder.And(
builder.Eq{"`team`.org_id": orgID},
eqCond,
builder.Gte{"`team`.authorize": perm.AccessModeOwner},
))
sq := builder.Select("`repo_group`.id").
From("`repo_group`").
Join("LEFT", "team", "`team`.org_id = `repo_group`.owner_id").
Where(
builder.And(
builder.Eq{"`repo_group`.owner_id": orgID},
builder.And(
builder.In("`team`.id", teamSubquery),
)))
cond := builder.In(idStr, sq)
if includeAdmin {
cond = cond.Or(builder.Exists(adminSubquery))
}
return cond
}
// userOrgTeamGroupBuilder returns group ids where user's teams can access.
func userOrgTeamGroupBuilder(userID, orgID int64) *builder.Builder {
return builder.Select("`repo_group_team`.group_id").
From("repo_group_team").
Join("INNER", "team_user", "`team_user`.team_id = `repo_group_team`.team_id").
Where(builder.And(builder.Eq{"`team_user`.uid": userID}, builder.Eq{"`repo_group_team`.org_id": orgID}))
}
// UserOrgTeamPermCond returns a condition to select ids of groups that a user can access at the level described by `level`
func UserOrgTeamPermCond(idStr string, userID, orgID int64, level perm.AccessMode) builder.Cond {
selCond := userOrgTeamGroupBuilder(userID, orgID)
selCond = selCond.InnerJoin("team", "`team`.id = `repo_group_team`.team_id").
And(builder.Or(builder.Gte{"`team`.authorize": level}, builder.Gte{"`repo_group_team`.access_mode": level}))
return builder.In(idStr, selCond)
}
// UserOrgTeamGroupCond returns a condition to select ids of groups that a user's team can access
func UserOrgTeamGroupCond(idStr string, userID, orgID int64) builder.Cond {
return builder.In(idStr, userOrgTeamGroupBuilder(userID, orgID))
}
// userOrgTeamUnitGroupCond returns a condition to select group ids where user's teams can access the special unit.
func userOrgTeamUnitGroupCond(idStr string, userID, orgID int64, unitType unit.Type) builder.Cond {
return builder.Or(builder.In(
idStr, userOrgTeamUnitGroupBuilder(userID, orgID, unitType)))
}
// userOrgTeamUnitGroupBuilder returns group ids where user's teams can access the special unit.
func userOrgTeamUnitGroupBuilder(userID, orgID int64, unitType unit.Type) *builder.Builder {
return userOrgTeamGroupBuilder(userID, orgID).
Join("INNER", "team_unit", "`team_unit`.team_id = `repo_group_team`.team_id").
Where(builder.Eq{"`team_unit`.`type`": unitType}).
And(builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)})
}
// AccessibleGroupCondition returns a condition that matches groups which a user can access via the specified unit
func AccessibleGroupCondition(user *user_model.User, orgID int64, unitType unit.Type, minMode perm.AccessMode) builder.Cond {
cond := builder.NewCond()
if user == nil || !user.IsRestricted || user.ID <= 0 {
orgVisibilityLimit := []int{int(structs.VisibleTypePrivate)}
if user == nil || user.ID <= 0 {
orgVisibilityLimit = append(orgVisibilityLimit, int(structs.VisibleTypeLimited))
}
condAnd := builder.And(
builder.NotIn("`repo_group`.owner_id", builder.Select("`user`.`id`").From("`user`").Where(
builder.And(
builder.Eq{"type": user_model.UserTypeOrganization},
builder.In("visibility", orgVisibilityLimit)),
)))
condAnd = condAnd.And(builder.NotIn("`repo_group`.visibility", orgVisibilityLimit))
cond = cond.Or(condAnd)
}
if user != nil {
cond = cond.Or(universalGroupPermBuilder("`repo_group`.id", user.ID, orgID, true))
cond = cond.Or(UserOrgTeamPermCond("`repo_group`.id", user.ID, orgID, minMode))
if unitType == unit.TypeInvalid {
cond = cond.Or(
UserOrgTeamGroupCond("`repo_group`.id", user.ID, orgID),
)
} else {
cond = cond.Or(
userOrgTeamUnitGroupCond("`repo_group`.id", user.ID, orgID, unitType),
)
}
}
return cond
}

158
models/group/group_team.go Normal file
View File

@ -0,0 +1,158 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
// RepoGroupTeam represents a relation for a team's access to a group
type RepoGroupTeam struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"`
TeamID int64 `xorm:"UNIQUE(s)"`
GroupID int64 `xorm:"UNIQUE(s)"`
AccessMode perm.AccessMode
CanCreateIn bool
Units []*RepoGroupUnit `xorm:"-"`
}
func (g *RepoGroupTeam) LoadGroupUnits(ctx context.Context) error {
var err error
g.Units, err = GetUnitsByGroupID(ctx, g.GroupID, g.TeamID)
return err
}
func (g *RepoGroupTeam) UnitAccessModeEx(ctx context.Context, tp unit.Type) (accessMode perm.AccessMode, exist bool) {
accessMode = perm.AccessModeNone
if err := g.LoadGroupUnits(ctx); err != nil {
log.Warn("Error loading units of team for group[%d] (ID: %d): %s", g.GroupID, g.TeamID, err.Error())
return accessMode, false
}
for _, u := range g.Units {
if u.Type == tp {
accessMode = u.AccessMode
exist = true
break
}
}
return accessMode, exist
}
// HasTeamGroup returns true if the given group belongs to a team.
func HasTeamGroup(ctx context.Context, orgID, teamID, groupID int64) bool {
has, _ := db.GetEngine(ctx).
Where("org_id=?", orgID).
And("team_id=?", teamID).
And("group_id=?", groupID).
And("access_mode >= ?", perm.AccessModeRead).
Get(new(RepoGroupTeam))
return has
}
// AddTeamGroup adds a group to a team
func AddTeamGroup(ctx context.Context, orgID, teamID, groupID int64, access perm.AccessMode, canCreateIn bool) error {
if access < perm.AccessModeWrite {
canCreateIn = false
}
_, err := db.GetEngine(ctx).Insert(&RepoGroupTeam{
OrgID: orgID,
GroupID: groupID,
TeamID: teamID,
AccessMode: access,
CanCreateIn: canCreateIn,
})
return err
}
func UpdateTeamGroup(ctx context.Context, orgID, teamID, groupID int64, access perm.AccessMode, canCreateIn, isNew bool) (err error) {
if access <= perm.AccessModeNone {
canCreateIn = false
}
if isNew {
err = AddTeamGroup(ctx, orgID, teamID, groupID, access, canCreateIn)
} else {
_, err = db.GetEngine(ctx).
Table("repo_group_team").
Where("org_id=?", orgID).
And("team_id=?", teamID).
And("group_id =?", groupID).
Update(&RepoGroupTeam{
OrgID: orgID,
TeamID: teamID,
GroupID: groupID,
AccessMode: access,
CanCreateIn: canCreateIn,
})
}
return err
}
// RemoveTeamGroup removes a group from a team
func RemoveTeamGroup(ctx context.Context, orgID, teamID, groupID int64) error {
_, err := db.DeleteByBean(ctx, &RepoGroupTeam{
TeamID: teamID,
GroupID: groupID,
OrgID: orgID,
})
return err
}
func FindGroupTeams(ctx context.Context, groupID int64) (gteams []*RepoGroupTeam, err error) {
return gteams, db.GetEngine(ctx).
Where("group_id=?", groupID).
Table("repo_group_team").
Find(&gteams)
}
func FindUserGroupTeams(ctx context.Context, groupID, userID int64) (gteams []*RepoGroupTeam, err error) {
return gteams, db.GetEngine(ctx).
Where("group_id=?", groupID).
And("team_user.uid = ?", userID).
Table("repo_group_team").
Join("INNER", "team_user", "team_user.team_id = repo_group_team.team_id").
Find(&gteams)
}
func FindGroupTeamByTeamID(ctx context.Context, groupID, teamID int64) (gteam *RepoGroupTeam, err error) {
gteam = new(RepoGroupTeam)
has, err := db.GetEngine(ctx).
Where("group_id=?", groupID).
And("team_id = ?", teamID).
Table("repo_group_team").
Get(gteam)
if !has {
gteam = nil
}
return gteam, err
}
func GetAncestorPermissions(ctx context.Context, groupID, teamID int64) (perm.AccessMode, error) {
sess := db.GetEngine(ctx)
groups, err := GetParentGroupIDChain(ctx, groupID)
if err != nil {
return perm.AccessModeNone, err
}
gteams := make([]*RepoGroupTeam, 0)
err = sess.In("group_id", groups).And("team_id = ?", teamID).Find(&gteams)
if err != nil {
return perm.AccessModeNone, err
}
mapped := util.SliceMap(gteams, func(g *RepoGroupTeam) perm.AccessMode {
return g.AccessMode
})
maxMode := max(mapped[0])
for _, m := range mapped[1:] {
maxMode = max(maxMode, m)
}
return maxMode, nil
}

View File

@ -0,0 +1,56 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
)
// RepoGroupUnit describes all units of a repository group
type RepoGroupUnit struct {
ID int64 `xorm:"pk autoincr"`
GroupID int64 `xorm:"UNIQUE(s)"`
TeamID int64 `xorm:"UNIQUE(s)"`
Type unit.Type `xorm:"UNIQUE(s)"`
AccessMode perm.AccessMode
}
func (g *RepoGroupUnit) Unit() unit.Unit {
return unit.Units[g.Type]
}
func GetUnitsByGroupID(ctx context.Context, groupID, teamID int64) (units []*RepoGroupUnit, err error) {
return units, db.GetEngine(ctx).Where("group_id = ?", groupID).And("team_id = ?", teamID).Find(&units)
}
func GetGroupUnit(ctx context.Context, groupID, teamID int64, unitType unit.Type) (unit *RepoGroupUnit, err error) {
unit = new(RepoGroupUnit)
_, err = db.GetEngine(ctx).
Where("group_id = ?", groupID).
And("team_id = ?", teamID).
And("type = ?", unitType).
Get(unit)
return unit, err
}
func GetMaxGroupUnit(ctx context.Context, groupID int64, unitType unit.Type) (unit *RepoGroupUnit, err error) {
units := make([]*RepoGroupUnit, 0)
err = db.GetEngine(ctx).
Where("group_id = ?", groupID).
And("type = ?", unitType).
Find(&units)
if err != nil {
return nil, err
}
for _, u := range units {
if unit == nil || u.AccessMode > unit.AccessMode {
unit = u
}
}
return unit, err
}

View File

@ -148,7 +148,7 @@ func (issue *Issue) getCrossReferences(stdCtx context.Context, ctx *crossReferen
refRepo = ctx.OrigIssue.Repo
} else {
// Issues in other repositories
refRepo, err = repo_model.GetRepositoryByOwnerAndName(stdCtx, ref.Owner, ref.Name)
refRepo, err = repo_model.GetRepositoryByOwnerAndName(stdCtx, ref.Owner, ref.Name, ref.GroupID)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
continue

View File

@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.26.0 ends at migration ID number 330 (database version 331)
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
newMigration(332, "Add group_id and group_sort_order columns to repository table", v1_27.AddGroupColumnsToRepositoryTable),
}
return preparedMigrations
}

View File

@ -84,7 +84,7 @@ func AddCommitDivergenceToPulls(x *xorm.Engine) error {
log.Error("Missing base repo with id %d for PR ID %d", pr.BaseRepoID, pr.ID)
continue
}
repoStore := repo_model.StorageRepo(repo_model.RelativePath(baseRepo.OwnerName, baseRepo.Name))
repoStore := repo_model.StorageRepo(repo_model.RelativePath(baseRepo.OwnerName, baseRepo.Name, 0))
gitRefName := fmt.Sprintf("refs/pull/%d/head", pr.Index)
divergence, err := gitrepo.GetDivergingCommits(graceful.GetManager().HammerContext(), repoStore, pr.BaseBranch, gitRefName)
if err != nil {

View File

@ -160,7 +160,7 @@ func migratePushMirrors(x *xorm.Engine) error {
func getRemoteAddress(ownerName, repoName, remoteName string) (string, error) {
ctx := context.Background()
relativePath := repo_model.RelativePath(ownerName, repoName)
relativePath := repo_model.RelativePath(ownerName, repoName, 0)
if exist, _ := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(relativePath)); !exist {
return "", nil
}

View File

@ -0,0 +1,20 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import "xorm.io/xorm"
func AddGroupColumnsToRepositoryTable(x *xorm.Engine) error {
type Repository struct {
LowerName string `xorm:"UNIQUE(s) UNIQUE(g) INDEX NOT NULL"`
GroupID int64 `xorm:"UNIQUE(s) INDEX DEFAULT 0"`
OwnerID int64 `xorm:"UNIQUE(s) UNIQUE(g) index"`
GroupSortOrder int
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: false,
IgnoreIndices: false,
}, new(Repository))
return err
}

View File

@ -88,7 +88,7 @@ func FixReleaseSha1OnReleaseTable(ctx context.Context, x *xorm.Engine) error {
userCache[repo.OwnerID] = user
}
gitRepo, err = gitrepo.OpenRepository(ctx, repo_model.StorageRepo(repo_model.RelativePath(user.Name, repo.Name)))
gitRepo, err = gitrepo.OpenRepository(ctx, repo_model.StorageRepo(repo_model.RelativePath(user.Name, repo.Name, 0)))
if err != nil {
return err
}

View File

@ -0,0 +1,37 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package organization
import (
"context"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
)
func GetTeamsWithAccessToGroup(ctx context.Context, orgID, groupID int64, mode perm.AccessMode) ([]*Team, error) {
teams := make([]*Team, 0)
inCond := group_model.ParentGroupCond(ctx, "repo_group_team.group_id", groupID)
return teams, db.GetEngine(ctx).Distinct("team.*").Where("repo_group_team.access_mode >= ?", mode).
Join("INNER", "repo_group_team", "repo_group_team.team_id = team.id and repo_group_team.org_id = ?", orgID).
And("repo_group_team.org_id = ?", orgID).
And(inCond).
OrderBy("name").
Find(&teams)
}
func GetTeamsWithAccessToGroupUnit(ctx context.Context, orgID, groupID int64, mode perm.AccessMode, unitType unit.Type) ([]*Team, error) {
teams := make([]*Team, 0)
inCond := group_model.ParentGroupCond(ctx, "repo_group_team.group_id", groupID)
return teams, db.GetEngine(ctx).Where("repo_group_team.access_mode >= ?", mode).
Join("INNER", "repo_group_team", "repo_group_team.team_id = team.id").
Join("INNER", "repo_group_unit", "repo_group_unit.team_id = team.id").
And("repo_group_team.org_id = ?", orgID).
And(inCond).
And("repo_group_unit.type = ?", unitType).
OrderBy("name").
Find(&teams)
}

View File

@ -126,6 +126,17 @@ func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams T
Find(&teams)
}
// GetUserGroupTeams returns teams in a group that a user has access to
func GetUserGroupTeams(ctx context.Context, groupID, userID int64) (teams TeamList, err error) {
return teams, db.GetEngine(ctx).
Where("`repo_group_team`.group_id = ?", groupID).
Join("INNER", "repo_group_team", "`repo_group_team`.team_id = `team`.id").
Join("INNER", "team_user", "`team_user`.team_id = `team`.id").
And("`team_user`.uid = ?", userID).
Asc("`team`.name").
Find(&teams)
}
func GetTeamsByOrgIDs(ctx context.Context, orgIDs []int64) (TeamList, error) {
teams := make([]*Team, 0, 10)
return teams, db.GetEngine(ctx).Where(builder.In("org_id", orgIDs)).Find(&teams)

View File

@ -13,6 +13,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
perm_model "code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
@ -482,6 +483,14 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos
return perm, nil
}
}
groupTeams, err := group_model.FindUserGroupTeams(ctx, repo.GroupID, user.ID)
for _, team := range groupTeams {
if team.AccessMode >= perm_model.AccessModeAdmin {
perm.AccessMode = perm_model.AccessModeOwner
perm.unitsMode = nil
return perm, nil
}
}
for _, u := range repo.Units {
for _, team := range teams {
@ -489,6 +498,11 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos
unitAccessMode := max(perm.unitsMode[u.Type], minAccessMode, teamMode)
perm.unitsMode[u.Type] = unitAccessMode
}
for _, team := range groupTeams {
teamMode, _ := team.UnitAccessModeEx(ctx, u.Type)
unitAccessMode := max(perm.unitsMode[u.Type], minAccessMode, teamMode)
perm.unitsMode[u.Type] = unitAccessMode
}
}
return perm, err
@ -529,6 +543,17 @@ func IsUserRepoAdmin(ctx context.Context, repo *repo_model.Repository, user *use
return true, nil
}
groupTeams, err := organization.GetUserGroupTeams(ctx, repo.GroupID, user.ID)
if err != nil {
return false, err
}
for _, team := range groupTeams {
if team.AccessMode >= perm_model.AccessModeAdmin {
return true, nil
}
}
teams, err := organization.GetUserRepoTeams(ctx, repo.OwnerID, user.ID, repo.ID)
if err != nil {
return false, err

View File

@ -37,10 +37,14 @@ type SearchTeamRepoOptions struct {
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
sess := db.GetEngine(ctx)
if opts.TeamID > 0 {
sess = sess.In("id",
builder.Select("repo_id").
From("team_repo").
Where(builder.Eq{"team_id": opts.TeamID}),
sess = sess.Where(
builder.Or(
builder.In("id", builder.Select("repo_id").
From("team_repo").
Where(builder.Eq{"team_id": opts.TeamID}),
),
builder.In("id", ReposAccessibleByGroupTeamBuilder(opts.TeamID)),
),
)
}
if opts.PageSize > 0 {

View File

@ -153,10 +153,10 @@ const (
// Repository represents a git repository.
type Repository struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"UNIQUE(s) index"`
OwnerID int64 `xorm:"UNIQUE(s) UNIQUE(g) index"`
OwnerName string
Owner *user_model.User `xorm:"-"`
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
LowerName string `xorm:"UNIQUE(s) UNIQUE(g) INDEX NOT NULL"`
Name string `xorm:"INDEX NOT NULL"`
Description string `xorm:"TEXT"`
Website string `xorm:"VARCHAR(2048)"`
@ -219,19 +219,30 @@ type Repository struct {
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
ArchivedUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
GroupID int64 `xorm:"UNIQUE(g) INDEX DEFAULT 0"`
GroupSortOrder int `xorm:"INDEX"`
}
func init() {
db.RegisterModel(new(Repository))
}
func RelativePath(ownerName, repoName string) string {
return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".git"
func RelativePathBaseName(ownerName, repoName string, groupID int64) string {
var groupSegment string
if groupID > 0 {
groupSegment = strconv.FormatInt(groupID, 10) + "/"
}
return strings.ToLower(ownerName) + "/" + groupSegment + strings.ToLower(repoName)
}
func RelativePath(ownerName, repoName string, groupID int64) string {
return RelativePathBaseName(ownerName, repoName, groupID) + ".git"
}
// RelativePath should be an unix style path like username/reponame.git
func (repo *Repository) RelativePath() string {
return RelativePath(repo.OwnerName, repo.Name)
return RelativePath(repo.OwnerName, repo.Name, repo.GroupID)
}
type StorageRepo string
@ -354,7 +365,7 @@ func (repo *Repository) LoadAttributes(ctx context.Context) error {
// FullName returns the repository full name
func (repo *Repository) FullName() string {
return repo.OwnerName + "/" + repo.Name
return repo.OwnerName + "/" + groupSegmentWithTrailingSlash(repo.GroupID) + repo.Name
}
// HTMLURL returns the repository HTML URL
@ -378,7 +389,11 @@ func (repo *Repository) CommitLink(commitID string) (result string) {
// APIURL returns the repository API URL
func (repo *Repository) APIURL(ctxOpt ...context.Context) string {
ctx := util.OptionalArg(ctxOpt, context.TODO())
return httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/api/v1/repos/"+url.PathEscape(repo.OwnerName)+"/"+url.PathEscape(repo.Name))
var groupSegment string
if repo.GroupID > 0 {
groupSegment = fmt.Sprintf("group/%d/", repo.GroupID)
}
return httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/api/v1/repos/"+url.PathEscape(repo.OwnerName)+"/"+groupSegment+url.PathEscape(repo.Name))
}
// GetCommitsCountCacheKey returns cache key used for commits count caching.
@ -580,18 +595,24 @@ func (repo *Repository) IsGenerated() bool {
}
// RepoPath returns repository path by given user and repository name.
func RepoPath(userName, repoName string) string { //revive:disable-line:exported
return filepath.Join(setting.RepoRootPath, filepath.Clean(strings.ToLower(userName)), filepath.Clean(strings.ToLower(repoName)+".git"))
func RepoPath(userName, repoName string, groupID int64) string { //revive:disable-line:exported
var joinArgs []string
joinArgs = append(joinArgs, user_model.UserPath(userName))
if groupID > 0 {
joinArgs = append(joinArgs, strconv.FormatInt(groupID, 10))
}
joinArgs = append(joinArgs, strings.ToLower(repoName)+".git")
return filepath.Join(joinArgs...)
}
// RepoPath returns the repository path
func (repo *Repository) RepoPath() string {
return RepoPath(repo.OwnerName, repo.Name)
return RepoPath(repo.OwnerName, repo.Name, repo.GroupID)
}
// Link returns the repository relative url
func (repo *Repository) Link() string {
return setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name)
return setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + groupSegmentWithTrailingSlash(repo.GroupID) + url.PathEscape(repo.Name)
}
// ComposeCompareURL returns the repository comparison URL
@ -656,13 +677,28 @@ type CloneLink struct {
Tea string
}
func getGroupSegment(gid int64) string {
var groupSegment string
if gid > 0 {
groupSegment = fmt.Sprintf("group/%d", gid)
}
return groupSegment
}
func groupSegmentWithTrailingSlash(gid int64) string {
if gid < 1 {
return ""
}
return getGroupSegment(gid) + "/"
}
// ComposeHTTPSCloneURL returns HTTPS clone URL based on the given owner and repository name.
func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string) string {
return fmt.Sprintf("%s%s/%s.git", httplib.GuessCurrentAppURL(ctx), url.PathEscape(owner), url.PathEscape(repo))
func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string, groupID int64) string {
return fmt.Sprintf("%s%s/%s%s.git", httplib.GuessCurrentAppURL(ctx), url.PathEscape(owner), groupSegmentWithTrailingSlash(groupID), url.PathEscape(repo))
}
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string, groupID int64) string {
sshUser := setting.SSH.User
sshDomain := setting.SSH.Domain
@ -681,7 +717,7 @@ func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) strin
// non-standard port, it must use full URI
if setting.SSH.Port != 22 {
sshHost := net.JoinHostPort(sshDomain, strconv.Itoa(setting.SSH.Port))
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
return fmt.Sprintf("ssh://%s@%s/%s/%s%s.git", sshUser, sshHost, url.PathEscape(ownerName), groupSegmentWithTrailingSlash(groupID), url.PathEscape(repoName))
}
// for standard port, it can use a shorter URI (without the port)
@ -690,31 +726,31 @@ func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) strin
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
}
if setting.Repository.UseCompatSSHURI {
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
return fmt.Sprintf("ssh://%s@%s/%s/%s%s.git", sshUser, sshHost, url.PathEscape(ownerName), groupSegmentWithTrailingSlash(groupID), url.PathEscape(repoName))
}
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
return fmt.Sprintf("%s@%s:%s/%s%s.git", sshUser, sshHost, url.PathEscape(ownerName), groupSegmentWithTrailingSlash(groupID), url.PathEscape(repoName))
}
// ComposeTeaCloneCommand returns Tea CLI clone command based on the given owner and repository name.
func ComposeTeaCloneCommand(ctx context.Context, owner, repo string) string {
return fmt.Sprintf("tea clone %s/%s", url.PathEscape(owner), url.PathEscape(repo))
func ComposeTeaCloneCommand(ctx context.Context, owner, repo string, groupID int64) string {
return fmt.Sprintf("tea clone %s/%s%s", url.PathEscape(owner), url.PathEscape(repo), groupSegmentWithTrailingSlash(groupID))
}
func (repo *Repository) cloneLink(ctx context.Context, doer *user_model.User, repoPathName string) *CloneLink {
func (repo *Repository) cloneLink(ctx context.Context, doer *user_model.User, repoPathName string, groupID int64) *CloneLink {
return &CloneLink{
SSH: ComposeSSHCloneURL(doer, repo.OwnerName, repoPathName),
HTTPS: ComposeHTTPSCloneURL(ctx, repo.OwnerName, repoPathName),
Tea: ComposeTeaCloneCommand(ctx, repo.OwnerName, repoPathName),
SSH: ComposeSSHCloneURL(doer, repo.OwnerName, repoPathName, groupID),
HTTPS: ComposeHTTPSCloneURL(ctx, repo.OwnerName, repoPathName, groupID),
Tea: ComposeTeaCloneCommand(ctx, repo.OwnerName, repoPathName, groupID),
}
}
// CloneLink returns clone URLs of repository.
func (repo *Repository) CloneLink(ctx context.Context, doer *user_model.User) (cl *CloneLink) {
return repo.cloneLink(ctx, doer, repo.Name)
return repo.cloneLink(ctx, doer, repo.Name, repo.GroupID)
}
func (repo *Repository) CloneLinkGeneral(ctx context.Context) (cl *CloneLink) {
return repo.cloneLink(ctx, nil /* no doer, use a general git user */, repo.Name)
return repo.cloneLink(ctx, nil /* no doer, use a general git user */, repo.Name, repo.GroupID)
}
// GetOriginalURLHostname returns the hostname of a URL or the URL
@ -779,11 +815,17 @@ func (err ErrRepoNotExist) Unwrap() error {
}
// GetRepositoryByOwnerAndName returns the repository by given owner name and repo name
func GetRepositoryByOwnerAndName(ctx context.Context, ownerName, repoName string) (*Repository, error) {
func GetRepositoryByOwnerAndName(ctx context.Context, ownerName, repoName string, groupID int64) (*Repository, error) {
var repo Repository
var gid any = groupID
if groupID == 0 {
gid = nil
}
_ = gid
has, err := db.GetEngine(ctx).Table("repository").Select("repository.*").
Join("INNER", "`user`", "`user`.id = repository.owner_id").
Where("repository.lower_name = ?", strings.ToLower(repoName)).
And("`repository`.group_id = ?", groupID).
And("`user`.lower_name = ?", strings.ToLower(ownerName)).
Get(&repo)
if err != nil {
@ -795,10 +837,11 @@ func GetRepositoryByOwnerAndName(ctx context.Context, ownerName, repoName string
}
// GetRepositoryByName returns the repository by given name under user if exists.
func GetRepositoryByName(ctx context.Context, ownerID int64, name string) (*Repository, error) {
func GetRepositoryByName(ctx context.Context, ownerID, groupID int64, name string) (*Repository, error) {
var repo Repository
has, err := db.GetEngine(ctx).
Where("`owner_id`=?", ownerID).
And("`group_id`=?", groupID).
And("`lower_name`=?", strings.ToLower(name)).
NoAutoCondition().
Get(&repo)
@ -816,7 +859,7 @@ func GetRepositoryByURL(ctx context.Context, repoURL string) (*Repository, error
if err != nil || ret.OwnerName == "" {
return nil, errors.New("unknown or malformed repository URL")
}
return GetRepositoryByOwnerAndName(ctx, ret.OwnerName, ret.RepoName)
return GetRepositoryByOwnerAndName(ctx, ret.OwnerName, ret.RepoName, ret.GroupID)
}
// GetRepositoryByURLRelax also accepts an SSH clone URL without user part
@ -852,10 +895,11 @@ func GetRepositoriesMapByIDs(ctx context.Context, ids []int64) (map[int64]*Repos
return repos, db.GetEngine(ctx).In("id", ids).Find(&repos)
}
func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) {
func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string, groupID int64) (bool, error) {
return db.GetEngine(ctx).Get(&Repository{
OwnerID: u.ID,
LowerName: strings.ToLower(repoName),
GroupID: groupID,
})
}

View File

@ -158,6 +158,7 @@ type SearchRepoOptions struct {
OwnerID int64
PriorityOwnerID int64
TeamID int64
GroupID int64
OrderBy db.SearchOrderBy
Private bool // Include private repositories in results
StarredByID int64
@ -289,9 +290,9 @@ func UserCollaborationRepoCond(idStr string, userID int64) builder.Cond {
)
}
// UserOrgTeamRepoCond selects repos that the given user has access to through team membership
// UserOrgTeamRepoCond selects repos that the given user has access to through team membership and/or group permissions
func UserOrgTeamRepoCond(idStr string, userID int64) builder.Cond {
return builder.In(idStr, userOrgTeamRepoBuilder(userID))
return builder.In(idStr, userOrgTeamRepoBuilder(userID), userOrgTeamRepoGroupBuilder(userID))
}
// userOrgTeamRepoBuilder returns repo ids where user's teams can access.
@ -302,6 +303,12 @@ func userOrgTeamRepoBuilder(userID int64) *builder.Builder {
Where(builder.Eq{"`team_user`.uid": userID})
}
// userOrgTeamRepoGroupBuilder selects repos that the given user has access to through team membership and group permissions
func userOrgTeamRepoGroupBuilder(userID int64) *builder.Builder {
return userOrgTeamRepoBuilder(userID).
Join("INNER", "repo_group_team", "`repo_group_team`.team_id=`team_repo`.team_id")
}
// userOrgTeamUnitRepoBuilder returns repo ids where user's teams can access the special unit.
func userOrgTeamUnitRepoBuilder(userID int64, unitType unit.Type) *builder.Builder {
return userOrgTeamRepoBuilder(userID).
@ -310,9 +317,18 @@ func userOrgTeamUnitRepoBuilder(userID int64, unitType unit.Type) *builder.Build
And(builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)})
}
func userOrgTeamUnitRepoGroupBuilder(userID int64, unitType unit.Type) *builder.Builder {
return userOrgTeamRepoGroupBuilder(userID).
Join("INNER", "team_unit", "`team_unit`.team_id = `team_repo`.team_id").
Where(builder.Eq{"`team_unit`.`type`": unitType}).
And(builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)})
}
// userOrgTeamUnitRepoCond returns a condition to select repo ids where user's teams can access the special unit.
func userOrgTeamUnitRepoCond(idStr string, userID int64, unitType unit.Type) builder.Cond {
return builder.In(idStr, userOrgTeamUnitRepoBuilder(userID, unitType))
return builder.Or(builder.In(
idStr, userOrgTeamUnitRepoBuilder(userID, unitType)),
builder.In(idStr, userOrgTeamUnitRepoGroupBuilder(userID, unitType)))
}
// UserOrgUnitRepoCond selects repos that the given user has access to through org and the special unit
@ -320,7 +336,18 @@ func UserOrgUnitRepoCond(idStr string, userID, orgID int64, unitType unit.Type)
return builder.In(idStr,
userOrgTeamUnitRepoBuilder(userID, unitType).
And(builder.Eq{"`team_unit`.org_id": orgID}),
)
userOrgTeamUnitRepoGroupBuilder(userID, unitType).And(builder.Eq{"`team_unit`.org_id": orgID}))
}
// ReposAccessibleByGroupTeamBuilder returns repositories that are accessible by a team via group permissions
func ReposAccessibleByGroupTeamBuilder(teamID int64) *builder.Builder {
innerGroupCond := builder.Select("`repo_group`.id").
From("repo_group").
InnerJoin("repo_group_team", "`repo_group_team`.group_id = `repo_group`.id").
Where(builder.Eq{"`repo_group_team`.team_id": teamID})
return builder.Select("`repository`.id").
From("repository").
Where(builder.In("`repository`.group_id", innerGroupCond))
}
// userOrgPublicRepoCond returns the condition that one user could access all public repositories in organizations
@ -445,6 +472,11 @@ func SearchRepositoryCondition(opts SearchRepoOptions) builder.Cond {
if opts.TeamID > 0 {
cond = cond.And(builder.In("`repository`.id", builder.Select("`team_repo`.repo_id").From("team_repo").Where(builder.Eq{"`team_repo`.team_id": opts.TeamID})))
}
if opts.GroupID > 0 {
cond = cond.And(builder.Eq{"`repository`.group_id": opts.GroupID})
} else if opts.GroupID == -1 {
cond = cond.And(builder.Lt{"`repository`.group_id": 1})
}
if opts.Keyword != "" {
// separate keyword

View File

@ -182,30 +182,30 @@ func TestComposeSSHCloneURL(t *testing.T) {
setting.SSH.Domain = "domain"
setting.SSH.Port = 22
setting.Repository.UseCompatSSHURI = false
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo", 0))
setting.Repository.UseCompatSSHURI = true
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo", 0))
// test SSH_DOMAIN while use non-standard SSH port
setting.SSH.Port = 123
setting.Repository.UseCompatSSHURI = false
assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo", 0))
setting.Repository.UseCompatSSHURI = true
assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo", 0))
// test IPv6 SSH_DOMAIN
setting.Repository.UseCompatSSHURI = false
setting.SSH.Domain = "::1"
setting.SSH.Port = 22
assert.Equal(t, "git@[::1]:user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
assert.Equal(t, "git@[::1]:user/repo.git", ComposeSSHCloneURL(nil, "user", "repo", 0))
setting.SSH.Port = 123
assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo", 0))
setting.SSH.User = "(DOER_USERNAME)"
setting.SSH.Domain = "domain"
setting.SSH.Port = 22
assert.Equal(t, "doer@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
assert.Equal(t, "doer@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo", 0))
setting.SSH.Port = 123
assert.Equal(t, "ssh://doer@domain:123/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
assert.Equal(t, "ssh://doer@domain:123/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo", 0))
}
func TestIsUsableRepoName(t *testing.T) {

View File

@ -254,7 +254,7 @@ func CreatePendingRepositoryTransfer(ctx context.Context, doer, newOwner *user_m
}
// Check if new owner has repository with same name.
if has, err := IsRepositoryModelExist(ctx, newOwner, repo.Name); err != nil {
if has, err := IsRepositoryModelExist(ctx, newOwner, repo.Name, repo.GroupID); err != nil {
return fmt.Errorf("IsRepositoryExist: %w", err)
} else if has {
return ErrRepoAlreadyExist{

View File

@ -7,7 +7,6 @@ package repo
import (
"context"
"fmt"
"strings"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
@ -72,15 +71,13 @@ func (err ErrWikiInvalidFileName) Unwrap() error {
// WikiCloneLink returns clone URLs of repository wiki.
func (repo *Repository) WikiCloneLink(ctx context.Context, doer *user_model.User) *CloneLink {
return repo.cloneLink(ctx, doer, repo.Name+".wiki")
return repo.cloneLink(ctx, doer, repo.Name+".wiki", repo.GroupID)
}
func RelativeWikiPath(ownerName, repoName string) string {
return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".wiki.git"
func RelativeWikiPath(ownerName, repoName string, groupID int64) string {
return RelativePathBaseName(ownerName, repoName, groupID) + ".wiki.git"
}
// WikiStorageRepo returns the storage repo for the wiki
// The wiki repository should have the same object format as the code repository
func (repo *Repository) WikiStorageRepo() StorageRepo {
return StorageRepo(RelativeWikiPath(repo.OwnerName, repo.Name))
return StorageRepo(RelativeWikiPath(repo.OwnerName, repo.Name, repo.GroupID))
}

View File

@ -25,6 +25,6 @@ func TestRepository_RelativeWikiPath(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.Equal(t, "user2/repo1.wiki.git", repo_model.RelativeWikiPath(repo.OwnerName, repo.Name))
assert.Equal(t, "user2/repo1.wiki.git", repo_model.RelativeWikiPath(repo.OwnerName, repo.Name, repo.GroupID))
assert.Equal(t, "user2/repo1.wiki.git", repo.WikiStorageRepo().RelativePath())
}

View File

@ -0,0 +1,71 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
organization_model "code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"xorm.io/builder"
)
// FindGroupMembers finds all users who have access to a group via team membership
func FindGroupMembers(ctx context.Context, groupID int64, opts *organization_model.FindOrgMembersOpts) (user_model.UserList, error) {
cond := builder.
Select("`team_user`.uid").
From("team_user").
InnerJoin("org_user", "`org_user`.uid = team_user.uid").
InnerJoin("repo_group_team", "`repo_group_team`.team_id = team_user.team_id").
Where(builder.Eq{"`org_user`.org_id": opts.OrgID}).
And(group_model.ParentGroupCond(context.TODO(), "`repo_group_team`.group_id", groupID))
if opts.PublicOnly() {
cond = cond.And(builder.Eq{"`org_user`.is_public": true})
}
sess := db.GetEngine(ctx).Where(builder.In("`user`.id", cond))
if opts.ListOptions.PageSize > 0 {
sess = db.SetSessionPagination(sess, opts)
users := make([]*user_model.User, 0, opts.ListOptions.PageSize)
return users, sess.Find(&users)
}
var users []*user_model.User
err := sess.Find(&users)
return users, err
}
func GetGroupTeams(ctx context.Context, groupID int64) ([]*organization_model.Team, error) {
var teams []*organization_model.Team
return teams, db.GetEngine(ctx).
Where("`repo_group_team`.group_id = ?", groupID).
Join("INNER", "repo_group_team", "`repo_group_team`.team_id = `team`.id").
Asc("`team`.name").
Find(&teams)
}
func IsGroupMember(ctx context.Context, groupID int64, user *user_model.User) (bool, error) {
if user == nil {
return false, nil
}
return db.GetEngine(ctx).
Where("`repo_group_team`.group_id = ?", groupID).
Join("INNER", "repo_group_team", "`repo_group_team`.team_id = `team_user`.team_id").
And("`team_user`.uid = ?", user.ID).
Table("team_user").
Exist()
}
func GetGroupRepos(ctx context.Context, groupID int64, doer *user_model.User) ([]*repo_model.Repository, error) {
sess := db.GetEngine(ctx)
repos := make([]*repo_model.Repository, 0)
return repos, sess.Table("repository").
Where("group_id = ?", groupID).
And(builder.In("id", repo_model.AccessibleRepoIDsQuery(doer))).
OrderBy("group_sort_order").
Find(&repos)
}

View File

@ -596,6 +596,7 @@ var (
"avatar", // avatar by email hash
"avatars", // user avatars by file name
"repo-avatars",
"group-avatars",
"captcha",
"login", // oauth2 login
@ -606,6 +607,8 @@ var (
"explore",
"issues",
"pulls",
"groups",
"group",
"milestones",
"notifications",

View File

@ -19,3 +19,26 @@ func FilterSlice[E any, T comparable](s []E, include func(E) (T, bool)) []T {
}
return slices.Clip(filtered)
}
func Filter[E any](s []E, include func(E) bool) []E {
filtered := make([]E, 0, len(s)) // slice will be clipped before returning
for i := range s {
if ok := include(s[i]); ok {
filtered = append(filtered, s[i])
}
}
return slices.Clip(filtered)
}
func DedupeBy[E any, I comparable](s []E, id func(E) I) []E {
filtered := make([]E, 0, len(s)) // slice will be clipped before returning
seen := make(map[I]bool, len(s))
for i := range s {
itemID := id(s[i])
if _, ok := seen[itemID]; !ok {
filtered = append(filtered, s[i])
seen[itemID] = true
}
}
return slices.Clip(filtered)
}

View File

@ -8,6 +8,7 @@ import (
"fmt"
"net"
stdurl "net/url"
"strconv"
"strings"
"code.gitea.io/gitea/modules/httplib"
@ -102,6 +103,7 @@ type RepositoryURL struct {
// if the URL belongs to current Gitea instance, then the below fields have values
OwnerName string
GroupID int64
RepoName string
RemainingPath string
}
@ -123,12 +125,23 @@ func ParseRepositoryURL(ctx context.Context, repoURL string) (*RepositoryURL, er
fillPathParts := func(s string) {
s = strings.TrimPrefix(s, "/")
fields := strings.SplitN(s, "/", 3)
fields := strings.SplitN(s, "/", 4)
var pathErr error
if len(fields) >= 2 {
ret.OwnerName = fields[0]
ret.RepoName = strings.TrimSuffix(fields[1], ".git")
if len(fields) == 3 {
ret.RemainingPath = "/" + fields[2]
if len(fields) >= 3 {
ret.GroupID, pathErr = strconv.ParseInt(fields[1], 10, 64)
if pathErr != nil {
ret.RepoName = strings.TrimSuffix(fields[1], ".git")
ret.RemainingPath = "/" + fields[2]
return
}
ret.RepoName = strings.TrimSuffix(fields[2], ".git")
if len(fields) >= 4 {
ret.RemainingPath = "/" + fields[3]
}
} else {
ret.RepoName = strings.TrimSuffix(fields[1], ".git")
}
}
}
@ -161,7 +174,11 @@ func ParseRepositoryURL(ctx context.Context, repoURL string) (*RepositoryURL, er
// MakeRepositoryWebLink generates a web link (http/https) for a git repository (by guessing sometimes)
func MakeRepositoryWebLink(repoURL *RepositoryURL) string {
if repoURL.OwnerName != "" {
return setting.AppSubURL + "/" + repoURL.OwnerName + "/" + repoURL.RepoName
var groupSegment string
if repoURL.GroupID > 0 {
groupSegment = strconv.FormatInt(repoURL.GroupID, 10) + "/"
}
return setting.AppSubURL + "/" + repoURL.OwnerName + "/" + groupSegment + repoURL.RepoName
}
// now, let's guess, for example:

View File

@ -274,7 +274,7 @@ func convertResult(searchResult *es.SearchResponse, kw string, pageSize int) (in
// <em> and </em> tags? If elastic search has handled that?
startIndex, endIndex = contentMatchIndexPos(c[0], "<em>", "</em>")
if startIndex == -1 {
panic(fmt.Sprintf("1===%s,,,%#v,,,%s", kw, hit.Highlight, c[0]))
panic(fmt.Sprintf("1===%s,,%#v,,%s", kw, hit.Highlight, c[0]))
}
} else {
panic(fmt.Sprintf("2===%#v", hit.Highlight))

View File

@ -662,7 +662,7 @@ body:
name: Name
title: Title
about: About
labels: label1,label2,,label3 ,,
labels: label1,label2,label3 ,
ref: Ref
body:
- type: markdown
@ -731,7 +731,7 @@ body:
name: Name
title: Title
about: About
labels: label1,label2,,label3 ,,
labels: label1,label2,label3 ,
ref: Ref
---
Content

View File

@ -63,10 +63,10 @@ var globalVars = sync.OnceValue(func() *globalVarsType {
v.shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`)
// anyHashPattern splits url containing SHA into parts
v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})((\.\w+)*)(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`)
v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,6}([0-9a-f]{40,64})((\.\w+)*)(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`)
// comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash"
v.comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`)
v.comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,6}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`)
// fullURLPattern matches full URL like "mailto:...", "https://..." and "ssh+git://..."
v.fullURLPattern = regexp.MustCompile(`^[a-z][-+\w]+:`)
@ -82,13 +82,13 @@ var globalVars = sync.OnceValue(func() *globalVarsType {
v.emojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`)
// example: https://domain/org/repo/pulls/27#hash
v.issueFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#](\S+)?)?\b`)
v.issueFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/(?:[\w_.-]+/)?[\w_.-]+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#](\S+)?)?\b`)
// example: https://domain/org/repo/pulls/27/files#hash
v.filesChangedFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`)
v.filesChangedFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/(?:[\w_.-]+/)?[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`)
// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20"
v.codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)
v.codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/((?:[^\s/]+/)?)([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)
// cleans: "<foo/bar", "<any words/", ("<html", "<head", "<script", "<style", "<?", "<%")
v.tagCleaner = regexp.MustCompile(`(?i)<(/?\w+/\w+|/[\w ]+/|/?(html|head|script|style|%|\?)\b)`)

View File

@ -18,6 +18,7 @@ import (
type RenderCodePreviewOptions struct {
FullURL string
OwnerName string
GroupID int64
RepoName string
CommitID string
FilePath string
@ -34,10 +35,14 @@ func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosSt
opts := RenderCodePreviewOptions{
FullURL: node.Data[m[0]:m[1]],
OwnerName: node.Data[m[2]:m[3]],
RepoName: node.Data[m[4]:m[5]],
CommitID: node.Data[m[6]:m[7]],
FilePath: node.Data[m[8]:m[9]],
}
if len(m) >= 12 {
opts.GroupID, _ = strconv.ParseInt(node.Data[m[4]:m[5]], 10, 64)
opts.RepoName, opts.CommitID, opts.FilePath = node.Data[m[6]:m[7]], node.Data[m[8]:m[9]], node.Data[m[10]:m[11]]
} else {
opts.RepoName, opts.CommitID, opts.FilePath = node.Data[m[4]:m[5]], node.Data[m[6]:m[7]], node.Data[m[8]:m[9]]
}
if !httplib.IsCurrentGiteaSiteURL(ctx, opts.FullURL) {
return 0, 0, "", nil
}

View File

@ -23,6 +23,7 @@ import (
type RenderIssueIconTitleOptions struct {
OwnerName string
RepoName string
GroupID int64
LinkHref string
IssueIndex int64
}

View File

@ -82,8 +82,16 @@ type HookProcReceiveRefResult struct {
HeadBranch string
}
func newInternalRequestAPIForHooks(ctx context.Context, hookName, ownerName, repoName string, opts HookOptions) *httplib.Request {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/%s/%s/%s", hookName, url.PathEscape(ownerName), url.PathEscape(repoName))
func genGroupSegment(groupID int64) string {
var groupSegment string
if groupID > 0 {
groupSegment = fmt.Sprintf("group/%d/", groupID)
}
return groupSegment
}
func newInternalRequestAPIForHooks(ctx context.Context, hookName, ownerName, repoName string, groupID int64, opts HookOptions) *httplib.Request {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/%s/%s/%s%s", hookName, url.PathEscape(ownerName), genGroupSegment(groupID), url.PathEscape(repoName))
req := newInternalRequestAPI(ctx, reqURL, "POST", opts)
// This "timeout" applies to http.Client's timeout: A Timeout of zero means no timeout.
// This "timeout" was previously set to `time.Duration(60+len(opts.OldCommitIDs))` seconds, but it caused unnecessary timeout failures.
@ -93,28 +101,29 @@ func newInternalRequestAPIForHooks(ctx context.Context, hookName, ownerName, rep
}
// HookPreReceive check whether the provided commits are allowed
func HookPreReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) ResponseExtra {
req := newInternalRequestAPIForHooks(ctx, "pre-receive", ownerName, repoName, opts)
func HookPreReceive(ctx context.Context, ownerName, repoName string, groupID int64, opts HookOptions) ResponseExtra {
req := newInternalRequestAPIForHooks(ctx, "pre-receive", ownerName, repoName, groupID, opts)
_, extra := requestJSONResp(req, &ResponseText{})
return extra
}
// HookPostReceive updates services and users
func HookPostReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookPostReceiveResult, ResponseExtra) {
req := newInternalRequestAPIForHooks(ctx, "post-receive", ownerName, repoName, opts)
func HookPostReceive(ctx context.Context, ownerName, repoName string, groupID int64, opts HookOptions) (*HookPostReceiveResult, ResponseExtra) {
req := newInternalRequestAPIForHooks(ctx, "post-receive", ownerName, repoName, groupID, opts)
return requestJSONResp(req, &HookPostReceiveResult{})
}
// HookProcReceive proc-receive hook
func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookProcReceiveResult, ResponseExtra) {
req := newInternalRequestAPIForHooks(ctx, "proc-receive", ownerName, repoName, opts)
func HookProcReceive(ctx context.Context, ownerName, repoName string, groupID int64, opts HookOptions) (*HookProcReceiveResult, ResponseExtra) {
req := newInternalRequestAPIForHooks(ctx, "proc-receive", ownerName, repoName, groupID, opts)
return requestJSONResp(req, &HookProcReceiveResult{})
}
// SetDefaultBranch will set the default branch to the provided branch for the provided repository
func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/set-default-branch/%s/%s/%s",
func SetDefaultBranch(ctx context.Context, ownerName, repoName string, groupID int64, branch string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/set-default-branch/%s/%s%s/%s",
url.PathEscape(ownerName),
genGroupSegment(groupID),
url.PathEscape(repoName),
url.PathEscape(branch),
)

View File

@ -46,10 +46,15 @@ type ServCommandResults struct {
}
// ServCommand preps for a serv call
func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, mode perm.AccessMode, verb, lfsVerb string) (*ServCommandResults, ResponseExtra) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/command/%d/%s/%s?mode=%d",
func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, groupID int64, mode perm.AccessMode, verb, lfsVerb string) (*ServCommandResults, ResponseExtra) {
var groupSegment string
if groupID > 0 {
groupSegment = fmt.Sprintf("%d/", groupID)
}
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/command/%d/%s/%s%s?mode=%d",
keyID,
url.PathEscape(ownerName),
groupSegment,
url.PathEscape(repoName),
mode,
)

View File

@ -35,7 +35,7 @@ var (
issueAlphanumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[|\"|\')([A-Z]{1,10}-[1-9][0-9]*)(?:\s|$|\)|\]|:|\.(\s|$)|\"|\'|,)`)
// crossReferenceIssueNumericPattern matches string that references a numeric issue in a different repository
// e.g. org/repo#12345
crossReferenceIssueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-zA-Z-_\.]+/[0-9a-zA-Z-_\.]+[#!][0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
crossReferenceIssueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-zA-Z-_\.]+/(?:group/\d+/)?[0-9a-zA-Z-_\.]+[#!][0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
// crossReferenceCommitPattern matches a string that references a commit in a different repository
// e.g. go-gitea/gitea@d8a994ef, go-gitea/gitea@d8a994ef243349f321568f9e36d5c3f444b99cae (7-40 characters)
crossReferenceCommitPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-zA-Z-_\.]+)/([0-9a-zA-Z-_\.]+)@([0-9a-f]{7,64})(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
@ -81,6 +81,7 @@ func (a XRefAction) String() string {
type IssueReference struct {
Index int64
Owner string
GroupID int64
Name string
Action XRefAction
TimeLog string
@ -93,6 +94,7 @@ type IssueReference struct {
type RenderizableReference struct {
Issue string
Owner string
GroupID int64
Name string
CommitSha string
IsPull bool
@ -104,6 +106,7 @@ type RenderizableReference struct {
type rawReference struct {
index int64
owner string
groupID int64
name string
isPull bool
action XRefAction
@ -119,6 +122,7 @@ func rawToIssueReferenceList(reflist []*rawReference) []IssueReference {
refarr[i] = IssueReference{
Index: r.index,
Owner: r.owner,
GroupID: r.groupID,
Name: r.name,
Action: r.action,
TimeLog: r.timeLog,
@ -177,7 +181,7 @@ func getGiteaHostName() string {
giteaIssuePullPattern = regexp.MustCompile(
`(\s|^|\(|\[)` +
regexp.QuoteMeta(strings.TrimSpace(setting.AppURL)) +
`([0-9a-zA-Z-_\.]+/[0-9a-zA-Z-_\.]+)/` +
`([0-9a-zA-Z-_\.]+/(?:group/\d+/)?[0-9a-zA-Z-_\.]+)/` +
`((?:issues)|(?:pulls))/([0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
} else {
giteaHost = ""
@ -563,10 +567,19 @@ func getCrossReference(content []byte, start, end int, fromLink, prOnly bool) *r
}
}
parts := strings.Split(strings.ToLower(repo), "/")
if len(parts) != 2 {
var owner, rawGroup, name string
var gid int64
if len(parts) > 4 {
return nil
}
owner, name := parts[0], parts[1]
if len(parts) == 4 {
owner, rawGroup, name = parts[0], parts[2], parts[3]
} else {
owner, name = parts[0], parts[1]
}
if rawGroup != "" {
gid, _ = strconv.ParseInt(rawGroup, 10, 64)
}
if !validNamePattern.MatchString(owner) || !validNamePattern.MatchString(name) {
return nil
}
@ -574,6 +587,7 @@ func getCrossReference(content []byte, start, end int, fromLink, prOnly bool) *r
return &rawReference{
index: index,
owner: owner,
groupID: gid,
name: name,
action: action,
issue: issue,

View File

@ -18,6 +18,7 @@ import (
const (
EnvRepoName = "GITEA_REPO_NAME"
EnvRepoUsername = "GITEA_REPO_USER_NAME"
EnvRepoGroupID = "GITEA_REPO_GROUP_ID"
EnvRepoID = "GITEA_REPO_ID"
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
EnvPusherName = "GITEA_PUSHER_NAME"
@ -63,6 +64,7 @@ func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository,
EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
EnvRepoIsWiki + "=" + strconv.FormatBool(isWiki),
EnvPusherName + "=" + doer.Name,
EnvRepoGroupID + "=" + strconv.FormatInt(repo.GroupID, 10),
EnvPusherID + "=" + strconv.FormatInt(doer.ID, 10),
}
if !doer.KeepEmailPrivate {

View File

@ -12,6 +12,7 @@ type PushUpdateOptions struct {
PusherID int64
PusherName string
RepoUserName string
RepoGroupID int64
RepoName string
RefFullName git.RefName // branch, tag or other name to push
OldCommitID string

View File

@ -277,8 +277,9 @@ func (it IssueTemplate) Type() IssueTemplateType {
type IssueMeta struct {
Index int64 `json:"index"`
// owner of the issue's repo
Owner string `json:"owner"`
Name string `json:"repo"`
Owner string `json:"owner"`
Name string `json:"repo"`
GroupID int64 `json:"group_id"`
}
// LockIssueOption options to lock an issue

View File

@ -129,6 +129,9 @@ type Repository struct {
RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"`
Topics []string `json:"topics"`
Licenses []string `json:"licenses"`
GroupID int64 `json:"group_id"`
GroupSortOrder int `json:"group_sort_order"`
}
// CreateRepoOption options when creating repository
@ -162,6 +165,8 @@ type CreateRepoOption struct {
TrustModel string `json:"trust_model"`
// ObjectFormatName of the underlying git repository, empty string for default (sha1)
ObjectFormatName ObjectFormatName `json:"object_format_name" binding:"MaxSize(6)"`
// GroupID of the group which will contain this repository. ignored if the repo owner is not an organization.
GroupID int64 `json:"group_id"`
}
// EditRepoOption options when editing a repository's properties

View File

@ -0,0 +1,53 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
// Group represents a group of repositories and subgroups in an organization
type Group struct {
ID int64 `json:"id"`
Owner *User `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
ParentGroupID int64 `json:"parentGroupID"`
NumRepos int64 `json:"num_repos"`
NumSubgroups int64 `json:"num_subgroups"`
Link string `json:"link"`
SortOrder int `json:"sort_order"`
AvatarURL string `json:"avatar_url"`
}
// NewGroupOption represents options for creating a new group in an organization
// swagger:model
type NewGroupOption struct {
// the name for the newly created group
//
// required: true
Name string `json:"name" binding:"Required"`
// the description of the newly created group
Description string `json:"description"`
// the visibility of the newly created group
Visibility VisibleType `json:"visibility"`
}
// MoveGroupOption represents options for changing a group or repo's parent and sort order
// swagger:model
type MoveGroupOption struct {
// the new parent group. can be 0 to specify no parent
//
// required: true
NewParent int64 `json:"newParent"`
// the position of this group in its new parent
NewPos *int `json:"newPos,omitempty"`
}
// EditGroupOption represents options for editing a repository group
// swagger:model
type EditGroupOption struct {
// the new name of the group
Name *string `json:"name,omitempty"`
// the new description of the group
Description *string `json:"description,omitempty"`
// the new visibility of the group
Visibility *VisibleType `json:"visibility,omitempty"`
}

View File

@ -0,0 +1,13 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
// CreateOrUpdateRepoGroupTeamOption options for adding a team to a repo group
type CreateOrUpdateRepoGroupTeamOption struct {
// Whether the team can create repositories and subgroups in the group
CanCreateIn *bool `json:"can_create_in"`
// example: {"repo.code":"read","repo.issues":"write","repo.ext_issues":"none","repo.wiki":"admin","repo.pulls":"owner","repo.releases":"none","repo.projects":"none","repo.ext_wiki":"none"}
UnitsMap map[string]string `json:"units_map"`
Permission *RepoWritePermission `json:"permission"`
}

View File

@ -11,6 +11,7 @@ import (
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/avatars"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@ -55,6 +56,11 @@ func (au *AvatarUtils) Avatar(item any, others ...any) template.HTML {
if src != "" {
return AvatarHTML(src, size, class, t.AsUser().DisplayName())
}
case *group_model.Group:
src := t.AvatarLinkWithSize(size * setting.Avatar.RenderedSizeFactor)
if src != "" {
return AvatarHTML(src, size, class, t.Name)
}
}
return AvatarHTML(avatars.DefaultAvatarLink(), size, class, "")

View File

@ -58,6 +58,7 @@ type Actioner interface {
GetActUserName(ctx context.Context) string
GetRepoUserName(ctx context.Context) string
GetRepoName(ctx context.Context) string
GetRepoGroup(ctx context.Context) string
GetRepoPath(ctx context.Context) string
GetRepoLink(ctx context.Context) string
GetBranch() string

View File

@ -77,3 +77,11 @@ func SliceNilAsEmpty[T any](a []T) []T {
}
return a
}
func SliceMap[T, R any](slice []T, mapper func(it T) R) []R {
ret := make([]R, 0)
for _, it := range slice {
ret = append(ret, mapper(it))
}
return ret
}

View File

@ -55,6 +55,47 @@ func ListUnadoptedRepositories(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, repoNames)
}
func commonAdoptRepository(ctx *context.APIContext) {
ownerName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
groupID := ctx.PathParamInt64("group_id")
ctxUser, err := user_model.GetUserByName(ctx, ownerName)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.APIErrorNotFound()
return
}
ctx.APIErrorInternal(err)
return
}
// check not a repo
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName, groupID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName, groupID)))
if err != nil {
ctx.APIErrorInternal(err)
return
}
if has || !exist {
ctx.APIErrorNotFound()
return
}
if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{
Name: repoName,
IsPrivate: true,
}); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// AdoptRepository will adopt an unadopted repository
func AdoptRepository(ctx *context.APIContext) {
// swagger:operation POST /admin/unadopted/{owner}/{repo} admin adminAdoptRepository
@ -80,8 +121,46 @@ func AdoptRepository(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
// "403":
// "$ref": "#/responses/forbidden"
commonAdoptRepository(ctx)
}
func AdoptGroupRepository(ctx *context.APIContext) {
// swagger:operation POST /admin/unadopted/{owner}/group/{group_id}/{repo} admin adminAdoptRepositoryInGroup
// ---
// summary: Adopt unadopted files as a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: group_id
// in: path
// description: group ID of the repo
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "403":
// "$ref": "#/responses/forbidden"
commonAdoptRepository(ctx)
}
func commonDeleteUnadoptedRepo(ctx *context.APIContext) {
ownerName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
groupID := ctx.PathParamInt64("group_id")
ctxUser, err := user_model.GetUserByName(ctx, ownerName)
if err != nil {
@ -94,12 +173,12 @@ func AdoptRepository(ctx *context.APIContext) {
}
// check not a repo
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName)
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName, groupID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName)))
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName, groupID)))
if err != nil {
ctx.APIErrorInternal(err)
return
@ -108,10 +187,8 @@ func AdoptRepository(ctx *context.APIContext) {
ctx.APIErrorNotFound()
return
}
if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{
Name: repoName,
IsPrivate: true,
}); err != nil {
if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, repoName, groupID); err != nil {
ctx.APIErrorInternal(err)
return
}
@ -142,39 +219,36 @@ func DeleteUnadoptedRepository(ctx *context.APIContext) {
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
ownerName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
ctxUser, err := user_model.GetUserByName(ctx, ownerName)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.APIErrorNotFound()
return
}
ctx.APIErrorInternal(err)
return
}
// check not a repo
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName)
if err != nil {
ctx.APIErrorInternal(err)
return
}
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName)))
if err != nil {
ctx.APIErrorInternal(err)
return
}
if has || !exist {
ctx.APIErrorNotFound()
return
}
if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, repoName); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
commonDeleteUnadoptedRepo(ctx)
}
func DeleteUnadoptedRepositoryInGroup(ctx *context.APIContext) {
// swagger:operation DELETE /admin/unadopted/{owner}/group/{group_id}/{repo} admin adminDeleteUnadoptedRepositoryInGroup
// ---
// summary: Delete unadopted files
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: group_id
// in: path
// description: group ID of the repo
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
commonDeleteUnadoptedRepo(ctx)
}

View File

@ -66,6 +66,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
auth_model "code.gitea.io/gitea/models/auth"
@ -82,6 +83,7 @@ import (
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/activitypub"
"code.gitea.io/gitea/routers/api/v1/admin"
"code.gitea.io/gitea/routers/api/v1/group"
"code.gitea.io/gitea/routers/api/v1/misc"
"code.gitea.io/gitea/routers/api/v1/notify"
"code.gitea.io/gitea/routers/api/v1/org"
@ -135,7 +137,15 @@ func repoAssignment() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
userName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
var gid int64
groupParam := ctx.PathParam("group_id")
if groupParam != "" {
gid, _ = strconv.ParseInt(groupParam, 10, 64)
if gid == 0 {
ctx.Redirect(strings.Replace(ctx.Req.URL.RequestURI(), "/0/", "/", 1), 307)
return
}
}
var (
owner *user_model.User
err error
@ -165,7 +175,7 @@ func repoAssignment() func(ctx *context.APIContext) {
ctx.ContextUser = owner
// Get repository.
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, gid, repoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName)
@ -181,6 +191,10 @@ func repoAssignment() func(ctx *context.APIContext) {
}
return
}
if repo.GroupID != gid {
ctx.APIErrorNotFound()
return
}
repo.Owner = owner
ctx.Repo.Repository = repo
@ -424,7 +438,7 @@ func reqAdmin() func(ctx *context.APIContext) {
// reqRepoWriter user should have a permission to write to a repo, or be a site admin
func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if !ctx.IsUserRepoWriter(unitTypes) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() {
if !ctx.IsUserRepoWriter(unitTypes) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() && !(ctx.RepoGroup != nil && ctx.IsUserGroupWriter(unitTypes)) {
ctx.APIError(http.StatusForbidden, "user should have a permission to write to a repo")
return
}
@ -451,6 +465,37 @@ func reqAnyRepoReader() func(ctx *context.APIContext) {
}
}
// reqOrgAdmin user should be an organization or site-wide admin
func reqOrgAdmin() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if ctx.IsUserSiteAdmin() {
return
}
var orgID int64
if ctx.Org.Organization != nil {
orgID = ctx.Org.Organization.ID
} else if ctx.Org.Team != nil {
orgID = ctx.Org.Team.OrgID
} else {
setting.PanicInDevOrTesting("reqOrgOwnership: unprepared context")
ctx.APIErrorInternal(errors.New("reqOrgOwnership: unprepared context"))
return
}
isAdmin, err := organization.IsOrganizationAdmin(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if !isAdmin {
if ctx.Org.Organization != nil {
ctx.APIError(http.StatusForbidden, "Must be an organization owner")
} else {
ctx.APIErrorNotFound()
}
return
}
}
}
// reqOrgOwnership user should be an organization owner, or a site admin
func reqOrgOwnership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
@ -484,6 +529,55 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
}
}
// reqGroupMembership user should be organization owner,
// a member of a team with access to the group, or site admin
func reqGroupMembership(mode perm.AccessMode, needsCreatePerm bool) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if ctx.IsUserSiteAdmin() {
return
}
var err error
isOrgOwner := false
isOrgAdmin := false
g := ctx.RepoGroup.Group
if ctx.Doer != nil {
isOrgOwner, err = organization.IsOrganizationOwner(ctx, g.OwnerID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
isOrgAdmin, err = organization.IsOrganizationAdmin(ctx, g.OwnerID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if isOrgOwner || isOrgAdmin {
return
}
}
canAccess, err := ctx.RepoGroup.Group.CanAccessAtLevel(ctx, ctx.Doer, mode)
if err != nil {
ctx.APIErrorInternal(err)
return
}
igm := ctx.RepoGroup.IsMember
if !igm && !canAccess {
ctx.APIErrorNotFound()
return
}
if needsCreatePerm {
canCreateIn := ctx.RepoGroup.CanCreateRepoOrGroup
if !(canCreateIn || isOrgOwner || isOrgAdmin) {
ctx.APIError(http.StatusForbidden, fmt.Sprintf("User[%d] does not have permission to create new items in group[%d]", ctx.Doer.ID, g.ID))
return
}
}
}
}
// reqTeamMembership user should be an team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
@ -1094,11 +1188,13 @@ func Routes() *web.Router {
// (repo scope)
m.Group("/starred", func() {
m.Get("", user.GetMyStarredRepos)
m.Group("/{username}/{reponame}", func() {
fn := func() {
m.Get("", user.IsStarring)
m.Put("", user.Star)
m.Delete("", user.Unstar)
}, repoAssignment(), checkTokenPublicOnly())
}
m.Group("/{username}/{reponame}", fn, repoAssignment(), checkTokenPublicOnly())
m.Group("/{username}/group/{group_id}/{reponame}", fn, context.GroupAssignmentAPI(), repoAssignment(), reqGroupMembership(perm.AccessModeRead, false), checkTokenPublicOnly())
}, reqStarsEnabled(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
m.Get("/times", repo.ListMyTrackedTimes)
m.Get("/stopwatches", repo.GetStopwatches)
@ -1145,13 +1241,13 @@ func Routes() *web.Router {
// (repo scope)
m.Post("/migrate", reqToken(), bind(api.MigrateRepoOptions{}), repo.Migrate)
m.Group("/{username}/{reponame}", func() {
fn := func() {
m.Get("/compare/*", reqRepoReader(unit.TypeCode), repo.CompareDiff)
m.Combo("").Get(reqAnyRepoReader(), repo.Get).
Delete(reqToken(), reqOwner(), repo.Delete).
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit)
m.Post("/groups/move", reqToken(), bind(api.MoveGroupOption{}), reqOrgMembership(), reqGroupMembership(perm.AccessModeWrite, false), repo.MoveRepoToGroup)
m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate)
m.Group("/transfer", func() {
m.Post("", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer)
@ -1443,27 +1539,31 @@ func Routes() *web.Router {
}, reqAdmin(), reqToken())
m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.DownloadArchive)
}, repoAssignment(), checkTokenPublicOnly())
}
m.Group("/{username}/{reponame}", fn, repoAssignment(), checkTokenPublicOnly())
m.Group("/{username}/group/{group_id}/{reponame}", fn, context.GroupAssignmentAPI(), repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
// Artifacts direct download endpoint authenticates via signed url
// it is protected by the "sig" parameter (to help to access private repo), so no need to use other middlewares
m.Get("/repos/{username}/{reponame}/actions/artifacts/{artifact_id}/zip/raw", repo.DownloadArtifactRaw)
m.Get("/repos/{username}/group/{group_id}/{reponame}/actions/artifacts/{artifact_id}/zip/raw", repo.DownloadArtifactRaw)
// Notifications (requires notifications scope)
m.Group("/repos", func() {
m.Group("/{username}/{reponame}", func() {
fn := func() {
m.Combo("/notifications", reqToken()).
Get(notify.ListRepoNotifications).
Put(notify.ReadRepoNotifications)
}, repoAssignment(), checkTokenPublicOnly())
}
m.Group("/{username}/{reponame}", fn, repoAssignment(), checkTokenPublicOnly())
m.Group("/{username}/group/{group_id}/{reponame}", fn, repoAssignment(), context.GroupAssignmentAPI(), reqGroupMembership(perm.AccessModeRead, false), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification))
// Issue (requires issue scope)
m.Group("/repos", func() {
m.Get("/issues/search", repo.SearchIssues)
m.Group("/{username}/{reponame}", func() {
fn := func() {
m.Group("/issues", func() {
m.Combo("").Get(repo.ListIssues).
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue)
@ -1575,7 +1675,9 @@ 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)
})
}, repoAssignment(), checkTokenPublicOnly())
}
m.Group("/{username}/{reponame}", fn, repoAssignment(), checkTokenPublicOnly())
m.Group("/{username}/group/{group_id}/{reponame}", fn, context.GroupAssignmentAPI(), repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
// NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs
@ -1666,6 +1768,10 @@ func Routes() *web.Router {
m.Delete("", org.UnblockUser)
})
}, reqToken(), reqOrgOwnership())
m.Group("/groups", func() {
m.Get("", org.GetOrgGroups)
m.Post("/new", reqToken(), reqOrgAdmin(), bind(api.NewGroupOption{}), group.NewGroup)
})
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
m.Group("/teams/{teamid}", func() {
m.Combo("").Get(reqToken(), org.GetTeam).
@ -1680,6 +1786,10 @@ func Routes() *web.Router {
})
m.Group("/repos", func() {
m.Get("", reqToken(), org.GetTeamRepos)
m.Combo("/{org}/group/{group_id}/{reponame}").
Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository).
Get(reqToken(), org.GetTeamRepo)
m.Combo("/{org}/{reponame}").
Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository).
@ -1719,8 +1829,12 @@ func Routes() *web.Router {
})
m.Group("/unadopted", func() {
m.Get("", admin.ListUnadoptedRepositories)
m.Post("/{username}/{reponame}", admin.AdoptRepository)
m.Delete("/{username}/{reponame}", admin.DeleteUnadoptedRepository)
m.Group("/{username}", func() {
m.Post("/{reponame}", admin.AdoptRepository)
m.Delete("/{reponame}", admin.DeleteUnadoptedRepository)
m.Post("/group/{group_id}/{reponame}", admin.AdoptGroupRepository)
m.Delete("/group/{group_id}/{reponame}", admin.DeleteUnadoptedRepositoryInGroup)
})
})
m.Group("/hooks", func() {
m.Combo("").Get(admin.ListHooks).
@ -1746,6 +1860,25 @@ func Routes() *web.Router {
m.Get("/search", repo.TopicSearch)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
}, sudo())
m.Group("/groups", func() {
m.Group("/{group_id}", func() {
m.Combo("").
Get(reqGroupMembership(perm.AccessModeRead, false), group.GetGroup).
Patch(reqToken(), reqGroupMembership(perm.AccessModeWrite, false), bind(api.EditGroupOption{}), group.EditGroup).
Delete(reqToken(), reqGroupMembership(perm.AccessModeAdmin, false), group.DeleteGroup)
m.Post("/move", reqToken(), reqGroupMembership(perm.AccessModeWrite, false), bind(api.MoveGroupOption{}), group.MoveGroup)
m.Post("/new", reqToken(), reqGroupMembership(perm.AccessModeWrite, true), bind(api.NewGroupOption{}), group.NewSubGroup)
m.Get("/subgroups", reqGroupMembership(perm.AccessModeRead, false), group.GetGroupSubGroups)
m.Get("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), reqGroupMembership(perm.AccessModeRead, false), group.GetGroupRepos)
m.Group("/teams", func() {
m.Get("", group.ListTeams)
m.Combo("/{team}").
Get(group.IsTeam).
Put(reqGroupMembership(perm.AccessModeAdmin, false), bind(api.CreateOrUpdateRepoGroupTeamOption{}), group.AddTeam).
Patch(reqGroupMembership(perm.AccessModeAdmin, false), bind(api.CreateOrUpdateRepoGroupTeamOption{}), group.EditTeam).
Delete(reqGroupMembership(perm.AccessModeAdmin, false), group.DeleteTeam)
}, reqToken(), reqGroupMembership(perm.AccessModeRead, false))
}, context.GroupAssignmentAPI(), checkTokenPublicOnly())
})
return m
}

View File

@ -0,0 +1,392 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"errors"
"net/http"
"strings"
group_model "code.gitea.io/gitea/models/group"
org_model "code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
shared_group_model "code.gitea.io/gitea/models/shared/group"
"code.gitea.io/gitea/modules/optional"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
group_service "code.gitea.io/gitea/services/group"
)
func createCommonGroup(ctx *context.APIContext, parentGroupID, ownerID int64) *api.Group {
if ownerID < 1 {
if parentGroupID < 1 {
ctx.APIError(http.StatusUnprocessableEntity,
errors.New("cannot determine new group's owner"))
return nil
}
npg, err := group_model.GetGroupByID(ctx, parentGroupID)
if err != nil {
if group_model.IsErrGroupNotExist(err) {
ctx.APIErrorNotFound()
}
return nil
}
ownerID = npg.OwnerID
}
form := web.GetForm(ctx).(*api.NewGroupOption)
group := &group_model.Group{
Name: form.Name,
Description: form.Description,
OwnerID: ownerID,
LowerName: strings.ToLower(form.Name),
Visibility: form.Visibility,
ParentGroupID: parentGroupID,
}
if err := group_service.NewGroup(ctx, group); err != nil {
if group_model.IsErrGroupTooDeep(err) {
ctx.APIError(http.StatusUnprocessableEntity, err)
} else if org_model.IsErrOrgNotExist(err) {
ctx.APIErrorNotFound()
}
return nil
}
val, err := convert.ToAPIGroup(ctx, group, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return nil
}
return val
}
// NewGroup create a new root-level group in an organization
func NewGroup(ctx *context.APIContext) {
// swagger:operation POST /orgs/{org}/groups/new repository-group groupNew
// ---
// summary: create a root-level repository group for an organization
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/NewGroupOption"
// responses:
// "201":
// "$ref": "#/responses/Group"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
ag := createCommonGroup(ctx, 0, ctx.Org.Organization.ID)
ctx.JSON(http.StatusCreated, ag)
}
// NewSubGroup create a new subgroup inside a group
func NewSubGroup(ctx *context.APIContext) {
// swagger:operation POST /groups/{group_id}/new repository-group groupNewSubGroup
// ---
// summary: create a subgroup inside a group
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group to create a subgroup in
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/NewGroupOption"
// responses:
// "201":
// "$ref": "#/responses/Group"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
var (
group *api.Group
)
gid := ctx.PathParamInt64("group_id")
group = createCommonGroup(ctx, gid, 0)
ctx.JSON(http.StatusCreated, group)
}
// MoveGroup - move a group to a different group in the same organization, or to the root level if
func MoveGroup(ctx *context.APIContext) {
// swagger:operation POST /groups/{group_id}/move repository-group groupMove
// ---
// summary: move a group to a different parent group
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group to move
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/MoveGroupOption"
// responses:
// "200":
// "$ref": "#/responses/Group"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.MoveGroupOption)
id := ctx.PathParamInt64("group_id")
var err error
npos := -1
if form.NewPos != nil {
npos = *form.NewPos
}
err = group_service.MoveGroupItem(ctx, group_service.MoveGroupOptions{
NewParent: form.NewParent,
ItemID: id,
IsGroup: true,
NewPos: npos,
}, ctx.Doer)
if group_model.IsErrGroupNotExist(err) {
ctx.APIErrorNotFound()
return
}
if group_model.IsErrUserDoesNotHaveAccessToGroup(err) {
ctx.APIError(http.StatusForbidden, err)
return
}
if err != nil {
ctx.APIErrorInternal(err)
return
}
var (
ng *group_model.Group
apiGroup *api.Group
)
ng, err = group_model.GetGroupByID(ctx, id)
if group_model.IsErrGroupNotExist(err) {
ctx.APIErrorNotFound()
return
}
if err != nil {
ctx.APIErrorInternal(err)
return
}
apiGroup, err = convert.ToAPIGroup(ctx, ng, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
}
ctx.JSON(http.StatusOK, apiGroup)
}
// EditGroup - update a group in an organization
func EditGroup(ctx *context.APIContext) {
// swagger:operation PATCH /groups/{group_id} repository-group groupEdit
// ---
// summary: edits a group in an organization. only fields that are set will be changed.
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group to edit
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/EditGroupOption"
// responses:
// "200":
// "$ref": "#/responses/Group"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
var (
err error
group *group_model.Group
)
form := web.GetForm(ctx).(*api.EditGroupOption)
group = ctx.RepoGroup.Group
serviceOpts := &group_service.UpdateOptions{}
serviceOpts.Visibility = optional.FromPtr(form.Visibility)
serviceOpts.Description = optional.FromPtr(form.Description)
serviceOpts.Name = optional.FromPtr(form.Name)
err = group_service.UpdateGroup(ctx, group, serviceOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
var newAPIGroup *api.Group
newAPIGroup, err = convert.ToAPIGroup(ctx, group, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, newAPIGroup)
}
func GetGroup(ctx *context.APIContext) {
// swagger:operation GET /groups/{group_id} repository-group groupGet
// ---
// summary: gets a group in an organization
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group to retrieve
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/Group"
// "404":
// "$ref": "#/responses/notFound"
apiGroup, err := convert.ToAPIGroup(ctx, ctx.RepoGroup.Group, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, apiGroup)
}
func DeleteGroup(ctx *context.APIContext) {
// swagger:operation DELETE /groups/{group_id} repository-group groupDelete
// ---
// summary: Delete a repository group
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group to delete
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
err := group_service.DeleteGroup(ctx, ctx.PathParamInt64("group_id"))
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
func GetGroupRepos(ctx *context.APIContext) {
// swagger:operation GET /groups/{group_id}/repos repository-group groupGetRepos
// ---
// summary: gets the repos contained within a group
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group containing the repositories
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/RepositoryList"
// "404":
// "$ref": "#/responses/notFound"
gid := ctx.PathParamInt64("group_id")
_, err := group_model.GetGroupByID(ctx, gid)
if err != nil {
if group_model.IsErrGroupNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
groupRepos, err := shared_group_model.GetGroupRepos(ctx, gid, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
repos := make([]*api.Repository, len(groupRepos))
for i, repo := range groupRepos {
permission, err := access_model.GetIndividualUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
repos[i] = convert.ToRepo(ctx, repo, permission)
}
ctx.SetTotalCountHeader(int64(len(repos)))
ctx.JSON(http.StatusOK, repos)
}
func GetGroupSubGroups(ctx *context.APIContext) {
// swagger:operation GET /groups/{group_id}/subgroups repository-group groupGetSubGroups
// ---
// summary: gets the subgroups contained within a group
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the parent group
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/GroupList"
// "404":
// "$ref": "#/responses/notFound"
g := ctx.RepoGroup.Group
err := g.LoadAccessibleSubgroups(ctx, false, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
groups := make([]*api.Group, len(g.Subgroups))
for i, group := range g.Subgroups {
groups[i], err = convert.ToAPIGroup(ctx, group, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
ctx.SetTotalCountHeader(int64(len(groups)))
ctx.JSON(http.StatusOK, groups)
}

View File

@ -0,0 +1,302 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"fmt"
"net/http"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
org_model "code.gitea.io/gitea/models/organization"
perm_model "code.gitea.io/gitea/models/perm"
org_group_model "code.gitea.io/gitea/models/shared/group"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
group_service "code.gitea.io/gitea/services/group"
)
// ListTeams list a repository group's teams
func ListTeams(ctx *context.APIContext) {
// swagger:operation GET /groups/{group_id}/teams repository-group repoGroupListTeams
// ---
// summary: List a repository group's teams
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/TeamList"
// "404":
// "$ref": "#/responses/notFound"
var (
err error
group *group_model.Group
)
group, err = group_model.GetGroupByID(ctx, ctx.PathParamInt64("group_id"))
if group_model.IsErrGroupNotExist(err) {
ctx.APIErrorNotFound()
return
}
if err != nil {
ctx.APIErrorInternal(err)
return
}
teams, err := org_group_model.GetGroupTeams(ctx, group.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
apiTeams, err := convert.ToTeams(ctx, teams, false)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, apiTeams)
}
// AddTeam add a team to a repository group
func AddTeam(ctx *context.APIContext) {
// swagger:operation PUT /groups/{group_id}/teams/{team} repository-group repoGroupAddTeam
// ---
// summary: Add a team to a repository group
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group
// type: integer
// format: int64
// required: true
// - name: team
// in: path
// description: team name
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateOrUpdateRepoGroupTeamOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "422":
// "$ref": "#/responses/validationError"
// "404":
// "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.CreateOrUpdateRepoGroupTeamOption)
changeGroupTeam(ctx, form, true)
}
// EditTeam update a team assigned to a repository group
func EditTeam(ctx *context.APIContext) {
// swagger:operation PATCH /groups/{group_id}/teams/{team} repository-group repoGroupEditTeam
// ---
// summary: Update a team assigned to a repository group
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group
// type: integer
// format: int64
// required: true
// - name: team
// in: path
// description: team name
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateOrUpdateRepoGroupTeamOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "422":
// "$ref": "#/responses/validationError"
// "404":
// "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.CreateOrUpdateRepoGroupTeamOption)
group := ctx.RepoGroup.Group
team := getTeamFromGroup(ctx, group)
gt, err := group_model.FindGroupTeamByTeamID(ctx, group.ID, team.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if gt == nil {
ctx.APIErrorNotFound()
return
}
if form.CanCreateIn != nil {
gt.CanCreateIn = *form.CanCreateIn
}
if form.Permission != nil {
gt.AccessMode = perm_model.ParseAccessMode(string(*form.Permission))
}
err = group_service.UpdateGroupTeam(ctx, gt, form.UnitsMap)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// DeleteTeam delete a team from a repository group
func DeleteTeam(ctx *context.APIContext) {
// swagger:operation DELETE /groups/{group_id}/teams/{team} repository-group repoGroupDeleteTeam
// ---
// summary: Add a team to a repository group
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group
// type: integer
// format: int64
// required: true
// - name: team
// in: path
// description: team name
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "422":
// "$ref": "#/responses/validationError"
// "404":
// "$ref": "#/responses/notFound"
changeGroupTeam(ctx, nil, false)
}
// IsTeam check if a team is assigned to a repository
func IsTeam(ctx *context.APIContext) {
// swagger:operation GET /groups/{group_id}/teams/{team} repository-group repoGroupCheckTeam
// ---
// summary: Check if a team is assigned to a repository group
// produces:
// - application/json
// parameters:
// - name: group_id
// in: path
// description: id of the group
// type: integer
// format: int64
// required: true
// - name: team
// in: path
// description: team name
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/Team"
// "404":
// "$ref": "#/responses/notFound"
group := ctx.RepoGroup.Group
team := getTeamFromGroup(ctx, group)
if team == nil {
return
}
if group_model.HasTeamGroup(ctx, group.OwnerID, team.ID, group.ID) {
apiTeam, err := convert.ToTeam(ctx, team)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, apiTeam)
return
}
ctx.APIErrorNotFound()
}
func getTeamFromGroup(ctx *context.APIContext, group *group_model.Group) *org_model.Team {
teamName := ctx.PathParam("team")
team, err := org_model.GetTeam(ctx, group.OwnerID, teamName)
if err != nil {
if org_model.IsErrTeamNotExist(err) {
ctx.APIErrorNotFound()
return nil
}
ctx.APIErrorInternal(err)
return nil
}
return team
}
func changeGroupTeam(ctx *context.APIContext, options *api.CreateOrUpdateRepoGroupTeamOption, add bool) {
gid := ctx.PathParamInt64("group_id")
group, err := group_model.GetGroupByID(ctx, gid)
if err != nil {
if group_model.IsErrGroupNotExist(err) {
ctx.APIErrorNotFound()
return
}
ctx.APIErrorInternal(err)
return
}
err = group.LoadOwner(ctx)
if err != nil {
ctx.APIErrorInternal(err)
return
}
team := getTeamFromGroup(ctx, group)
if team == nil {
return
}
groupHasTeam := group_model.HasTeamGroup(ctx, group.OwnerID, team.ID, gid)
if add {
if groupHasTeam {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("team '%s' is already added to group", team.Name))
return
}
var accessModeArg *perm_model.AccessMode
if options.Permission != nil {
accessModeArg = new(perm_model.ParseAccessMode(string(*options.Permission)))
}
err = group_service.AddTeamToGroup(ctx, group, team.Name, options.UnitsMap, options.CanCreateIn, accessModeArg)
if err != nil {
ctx.APIErrorInternal(err)
return
}
} else {
err = group_service.DeleteTeamFromGroup(ctx, group, group.OwnerID, team.Name)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if _, err = db.GetEngine(ctx).Where("group_id = ?", gid).Delete(new(group_model.RepoGroupUnit)); err != nil {
ctx.APIErrorInternal(err)
return
}
}
ctx.Status(http.StatusNoContent)
}

View File

@ -0,0 +1,81 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"net/http"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
)
func GetOrgGroups(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/groups organization orgListGroups
// ---
// summary: List an organization's root-level groups
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// 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/GroupList"
// "404":
// "$ref": "#/responses/notFound"
var doerID int64
if ctx.Doer != nil {
doerID = ctx.Doer.ID
}
org, err := organization.GetOrgByName(ctx, ctx.PathParam("org"))
if err != nil {
if organization.IsErrOrgNotExist(err) {
ctx.APIError(http.StatusUnprocessableEntity, err)
} else {
ctx.APIErrorInternal(err)
}
return
}
if !organization.HasOrgOrUserVisible(ctx, org.AsUser(), ctx.Doer) {
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
return
}
groups, err := group_model.FindGroupsByCond(ctx, &group_model.FindGroupsOptions{
ParentGroupID: 0,
ActorID: doerID,
OwnerID: org.ID,
}, group_model.
AccessibleGroupCondition(ctx.Doer, org.ID, unit.TypeInvalid, perm.AccessModeRead))
if err != nil {
ctx.APIErrorInternal(err)
return
}
apiGroups := make([]*api.Group, len(groups))
for i, group := range groups {
apiGroups[i], err = convert.ToAPIGroup(ctx, group, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
ctx.SetTotalCountHeader(int64(len(groups)))
ctx.JSON(http.StatusOK, apiGroups)
}

View File

@ -639,7 +639,7 @@ func GetTeamRepo(ctx *context.APIContext) {
// getRepositoryByParams get repository by a team's organization ID and repo name
func getRepositoryByParams(ctx *context.APIContext) *repo_model.Repository {
repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Team.OrgID, ctx.PathParam("reponame"))
repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Team.OrgID, ctx.PathParamInt64("group_id"), ctx.PathParam("reponame"))
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.APIErrorNotFound()

View File

@ -390,7 +390,7 @@ func LinkPackage(ctx *context.APIContext) {
return
}
repo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ctx.PathParam("repo_name"))
repo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ctx.PathParamInt64("group_id"), ctx.PathParam("repo_name"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)

View File

@ -1946,7 +1946,11 @@ func buildSignature(endp string, expires, artifactID int64) []byte {
}
func buildDownloadRawEndpoint(repo *repo_model.Repository, artifactID int64) string {
return fmt.Sprintf("api/v1/repos/%s/%s/actions/artifacts/%d/zip/raw", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), artifactID)
var groupSegment string
if repo.GroupID > 0 {
groupSegment = fmt.Sprintf("%d/", repo.GroupID)
}
return fmt.Sprintf("api/v1/repos/%s/%s%s/actions/artifacts/%d/zip/raw", url.PathEscape(repo.OwnerName), groupSegment, url.PathEscape(repo.Name), artifactID)
}
func buildSigURL(ctx go_context.Context, endPoint string, artifactID int64) string {
@ -2018,7 +2022,7 @@ func DownloadArtifact(ctx *context.APIContext) {
// DownloadArtifactRaw Downloads a specific artifact for a workflow run directly.
func DownloadArtifactRaw(ctx *context.APIContext) {
// it doesn't use repoAssignment middleware, so it needs to prepare the repo and check permission (sig) by itself
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ctx.PathParam("username"), ctx.PathParam("reponame"))
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ctx.PathParam("username"), ctx.PathParam("reponame"), ctx.PathParamInt64("group_id"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound()

View File

@ -506,7 +506,7 @@ func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Is
return nil
}
var err error
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, form.Owner, form.Name)
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, form.Owner, form.Name, form.GroupID)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.APIErrorNotFound("IsErrRepoNotExist", err)

View File

@ -256,15 +256,22 @@ func GetPullRequestByBaseHead(ctx *context.APIContext) {
split := strings.SplitN(head, ":", 2)
headBranch = split[1]
var owner, name string
var gid int64
if strings.Contains(split[0], "/") {
split = strings.Split(split[0], "/")
owner = split[0]
name = split[1]
if len(split) == 3 {
owner = split[0]
gid, _ = strconv.ParseInt(split[1], 10, 64)
name = split[2]
} else {
owner, name = split[0], split[1]
}
} else {
owner = split[0]
gid = ctx.Repo.Repository.GroupID
name = ctx.Repo.Repository.Name
}
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, name)
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, name, gid)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.APIErrorNotFound()

View File

@ -15,6 +15,7 @@ import (
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
@ -37,6 +38,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
feed_service "code.gitea.io/gitea/services/feed"
group_service "code.gitea.io/gitea/services/group"
"code.gitea.io/gitea/services/issue"
"code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror"
@ -266,6 +268,7 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre
TrustModel: repo_model.ToTrustModel(opt.TrustModel),
IsTemplate: opt.Template,
ObjectFormatName: string(opt.ObjectFormatName),
GroupID: opt.GroupID,
})
if err != nil {
if repo_model.IsErrRepoAlreadyExist(err) {
@ -1322,3 +1325,60 @@ func ListRepoActivityFeeds(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
}
func MoveRepoToGroup(ctx *context.APIContext) {
// swagger:operation POST /repo/{owner}/{repo}/move
// ---
// summary: move a repository to another group
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true // - name: body
// in: body
// schema:
// "$ref": "#/definitions/MoveGroupOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.MoveGroupOption)
npos := -1
if form.NewPos != nil {
npos = *form.NewPos
}
err := group_service.MoveGroupItem(ctx, group_service.MoveGroupOptions{
IsGroup: false,
NewPos: npos,
ItemID: ctx.Repo.Repository.ID,
NewParent: form.NewParent,
}, ctx.Doer)
if err != nil {
if group_model.IsErrUserDoesNotHaveAccessToGroup(err) {
ctx.APIError(http.StatusForbidden, err)
return
}
if group_model.IsErrGroupNotExist(err) {
ctx.APIErrorNotFound()
return
}
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@ -233,4 +233,16 @@ type swaggerParameterBodies struct {
// in:body
LockIssueOption api.LockIssueOption
// in:body
NewGroupOption api.NewGroupOption
// in:body
EditGroupOption api.EditGroupOption
// in:body
MoveGroupOption api.MoveGroupOption
// in:body
CreateOrUpdateGroupTeamOption api.CreateOrUpdateRepoGroupTeamOption
}

View File

@ -0,0 +1,20 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package swagger
import api "code.gitea.io/gitea/modules/structs"
// Group
// swagger:response Group
type swaggerResponseGroup struct {
// in:body
Body api.Group `json:"body"`
}
// GroupList
// swagger:response GroupList
type swaggerResponseGroupList struct {
// in:body
Body []api.Group `json:"body"`
}

View File

@ -5,6 +5,7 @@ package common
import (
"context"
"strconv"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
@ -20,6 +21,7 @@ type CompareRouterReq struct {
CompareSeparator string
HeadOwner string
HeadGroupID int64
HeadRepoName string
HeadOriRef string
}
@ -30,16 +32,25 @@ func (cr *CompareRouterReq) DirectComparison() bool {
return cr.CompareSeparator == ".."
}
func parseHead(head string) (headOwnerName, headRepoName, headRef string) {
func parseHead(head string) (headOwnerName, headRepoName string, headGroupID int64, headRef string) {
paths := strings.SplitN(head, ":", 2)
if len(paths) == 1 {
return "", "", paths[0]
/*var gid int64
_, rawGid, _ := strings.Cut(paths[0], "group/")
gid, _ = strconv.ParseInt(rawGid, 10, 64)*/
return "", "", 0, paths[0]
}
ownerRepo := strings.SplitN(paths[0], "/", 2)
if len(ownerRepo) == 1 {
return paths[0], "", paths[1]
// -1 means use base repo's group ID
return paths[0], "", -1, paths[1]
}
return ownerRepo[0], ownerRepo[1], paths[1]
var gid int64
_, rawGid, _ := strings.Cut(paths[0], "group/")
gid, _ = strconv.ParseInt(rawGid, 10, 64)
return ownerRepo[0], ownerRepo[1], gid, paths[1]
}
// ParseCompareRouterParam Get compare information from the router parameter.
@ -79,9 +90,10 @@ func ParseCompareRouterParam(routerParam string) *CompareRouterReq {
sep = ".."
basePart, headPart, ok = strings.Cut(routerParam, sep)
if !ok {
headOwnerName, headRepoName, headRef := parseHead(routerParam)
headOwnerName, headRepoName, headGid, headRef := parseHead(routerParam)
return &CompareRouterReq{
HeadOriRef: headRef,
HeadGroupID: headGid,
HeadOwner: headOwnerName,
HeadRepoName: headRepoName,
CompareSeparator: "...",
@ -91,7 +103,7 @@ func ParseCompareRouterParam(routerParam string) *CompareRouterReq {
ci := &CompareRouterReq{CompareSeparator: sep}
ci.BaseOriRef, ci.BaseOriRefSuffix = git.ParseRefSuffix(basePart)
ci.HeadOwner, ci.HeadRepoName, ci.HeadOriRef = parseHead(headPart)
ci.HeadOwner, ci.HeadRepoName, ci.HeadGroupID, ci.HeadOriRef = parseHead(headPart)
return ci
}
@ -150,7 +162,8 @@ func GetHeadOwnerAndRepo(ctx context.Context, baseRepo *repo_model.Repository, c
if compareReq.HeadOwner == baseRepo.Owner.Name && compareReq.HeadRepoName == baseRepo.Name {
headRepo = baseRepo
} else {
headRepo, err = repo_model.GetRepositoryByName(ctx, headOwner.ID, compareReq.HeadRepoName)
gid := util.Iif(compareReq.HeadGroupID == -1, baseRepo.GroupID, compareReq.HeadGroupID)
headRepo, err = repo_model.GetRepositoryByName(ctx, headOwner.ID, gid, compareReq.HeadRepoName)
if err != nil {
return nil, nil, err
}

View File

@ -65,6 +65,7 @@ func TestCompareRouterReq(t *testing.T) {
CompareSeparator: "...",
HeadOwner: "teabot",
HeadOriRef: "feature1",
HeadGroupID: -1,
},
},
{

View File

@ -14,7 +14,7 @@ const RouterMockPointCommonLFS = "common-lfs"
func AddOwnerRepoGitLFSRoutes(m *web.Router, middlewares ...any) {
// shared by web and internal routers
m.Group("/{username}/{reponame}/info/lfs", func() {
fn := func() {
m.Post("/objects/batch", lfs.CheckAcceptMediaType, lfs.BatchHandler)
m.Put("/objects/{oid}/{size}", lfs.UploadHandler)
m.Get("/objects/{oid}/{filename}", lfs.DownloadHandler)
@ -27,5 +27,7 @@ func AddOwnerRepoGitLFSRoutes(m *web.Router, middlewares ...any) {
m.Post("/{lid}/unlock", lfs.UnLockHandler)
}, lfs.CheckAcceptMediaType)
m.Any("/*", http.NotFound)
}, append([]any{web.RouterMockPoint(RouterMockPointCommonLFS)}, middlewares...)...)
}
m.Group("/{username}/{reponame}/info/lfs", fn, append([]any{web.RouterMockPoint(RouterMockPointCommonLFS)}, middlewares...)...)
m.Group("/{username}/group/{group_id}/{reponame}/info/lfs", fn, append([]any{web.RouterMockPoint(RouterMockPointCommonLFS)}, middlewares...)...)
}

View File

@ -83,7 +83,7 @@ func parseScope(ctx *context.PrivateContext, scope string) (ownerID, repoID int6
return ownerID, repoID, nil
}
r, err := repo_model.GetRepositoryByName(ctx, u.ID, repoName)
r, err := repo_model.GetRepositoryByName(ctx, u.ID, ctx.PathParamInt64("group_id"), repoName)
if err != nil {
return ownerID, repoID, err
}

View File

@ -41,6 +41,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
ownerName := ctx.PathParam("owner")
repoName := ctx.PathParam("repo")
groupID := ctx.PathParamInt64("group_id")
// defer getting the repository at this point - as we should only retrieve it if we're going to call update
var (
@ -61,7 +62,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
// may be a very large number of them).
if refFullName.IsBranch() || refFullName.IsTag() {
if repo == nil {
repo = loadRepository(ctx, ownerName, repoName)
repo = loadRepository(ctx, ownerName, repoName, groupID)
if ctx.Written() {
// Error handled in loadRepository
return
@ -75,6 +76,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
NewCommitID: opts.NewCommitIDs[i],
PusherID: opts.UserID,
PusherName: opts.UserName,
RepoGroupID: groupID,
RepoUserName: ownerName,
RepoName: repoName,
}
@ -98,7 +100,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
continue
}
if repo == nil {
repo = loadRepository(ctx, ownerName, repoName)
repo = loadRepository(ctx, ownerName, repoName, groupID)
if ctx.Written() {
return
}
@ -176,7 +178,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
if isPrivate.Has() || isTemplate.Has() {
// load the repository
if repo == nil {
repo = loadRepository(ctx, ownerName, repoName)
repo = loadRepository(ctx, ownerName, repoName, groupID)
if ctx.Written() {
// Error handled in loadRepository
return
@ -239,7 +241,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
if !git.IsEmptyCommitID(newCommitID) && refFullName.IsBranch() {
// First ensure we have the repository loaded, we're allowed pulls requests and we can get the base repo
if repo == nil {
repo = loadRepository(ctx, ownerName, repoName)
repo = loadRepository(ctx, ownerName, repoName, groupID)
if ctx.Written() {
return
}

View File

@ -64,11 +64,16 @@ func Routes() *web.Router {
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
r.Post("/hook/pre-receive/{owner}/group/{group_id}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
r.Post("/hook/post-receive/{owner}/group/{group_id}/{repo}", context.OverrideContext(), bind(private.HookOptions{}), HookPostReceive)
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), bind(private.HookOptions{}), HookPostReceive)
r.Post("/hook/proc-receive/{owner}/group/{group_id}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
r.Post("/hook/set-default-branch/{owner}/group/{group_id}/{repo}/{branch}", RepoAssignment, SetDefaultBranch)
r.Post("/hook/set-default-branch/{owner}/{repo}/{branch}", RepoAssignment, SetDefaultBranch)
r.Get("/serv/none/{keyid}", ServNoCommand)
r.Get("/serv/command/{keyid}/{owner}/group/{group_id}/{repo}", ServCommand)
r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)
r.Post("/manager/shutdown", Shutdown)
r.Post("/manager/restart", Restart)

View File

@ -20,8 +20,9 @@ import (
func RepoAssignment(ctx *gitea_context.PrivateContext) {
ownerName := ctx.PathParam("owner")
repoName := ctx.PathParam("repo")
gid := ctx.PathParamInt64("group_id")
repo := loadRepository(ctx, ownerName, repoName)
repo := loadRepository(ctx, ownerName, repoName, gid)
if ctx.Written() {
// Error handled in loadRepository
return
@ -41,8 +42,8 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) {
}
}
func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository {
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string, groupID int64) *repo_model.Repository {
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName, groupID)
if err != nil {
log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err)
ctx.JSON(http.StatusInternalServerError, private.Response{

View File

@ -152,7 +152,7 @@ func ServCommand(ctx *context.PrivateContext) {
// Now get the Repository and set the results section
repoExist := true
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, results.RepoName)
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, ctx.PathParamInt64("group_id"), results.RepoName)
if err != nil {
if !repo_model.IsErrRepoNotExist(err) {
log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err)

View File

@ -6,6 +6,7 @@ package admin
import (
"net/http"
"net/url"
"strconv"
"strings"
"code.gitea.io/gitea/models/db"
@ -127,14 +128,18 @@ func AdoptOrDeleteRepository(ctx *context.Context) {
}
repoName := dirSplit[1]
var groupID int64
if len(dirSplit) >= 3 {
groupID, _ = strconv.ParseInt(dirSplit[2], 10, 64)
}
// check not a repo
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName)
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName, groupID)
if err != nil {
ctx.ServerError("IsRepositoryExist", err)
return
}
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName)))
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName, groupID)))
if err != nil {
ctx.ServerError("IsDir", err)
return
@ -151,7 +156,7 @@ func AdoptOrDeleteRepository(ctx *context.Context) {
}
ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
} else if action == "delete" {
if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, dirSplit[1]); err != nil {
if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, dirSplit[1], groupID); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}

View File

@ -9,7 +9,7 @@ import (
)
func addOwnerRepoGitHTTPRouters(m *web.Router, middlewares ...any) {
m.Group("/{username}/{reponame}", func() {
fn := func() {
m.Methods("POST,OPTIONS", "/git-upload-pack", repo.ServiceUploadPack)
m.Methods("POST,OPTIONS", "/git-receive-pack", repo.ServiceReceivePack)
m.Methods("POST,OPTIONS", "/git-upload-archive", repo.ServiceUploadArchive)
@ -22,5 +22,7 @@ func addOwnerRepoGitHTTPRouters(m *web.Router, middlewares ...any) {
m.Methods("GET,OPTIONS", "/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38,62}}", repo.GetLooseObject)
m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.pack", repo.GetPackFile)
m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.idx", repo.GetIdxFile)
}, middlewares...)
}
m.Group("/{username}/{reponame}", fn, middlewares...)
m.Group("/{username}/group/{group_id}/{reponame}", fn, middlewares...)
}

View File

@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"path"
"strconv"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
@ -22,14 +23,18 @@ func goGet(ctx *context.Context) {
return
}
parts := strings.SplitN(ctx.Req.URL.EscapedPath(), "/", 4)
parts := strings.SplitN(ctx.Req.URL.EscapedPath(), "/", 6)
if len(parts) < 3 {
return
}
var group string
ownerName := parts[1]
repoName := parts[2]
if len(parts) > 4 {
repoName = parts[4]
group = parts[3]
}
// Quick responses appropriate go-get meta with status 200
// regardless of if user have access to the repository,
@ -51,12 +56,16 @@ func goGet(ctx *context.Context) {
return
}
branchName := setting.Repository.DefaultBranch
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
gid, _ := strconv.ParseInt(group, 10, 64)
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName, gid)
if err == nil && len(repo.DefaultBranch) > 0 {
branchName = repo.DefaultBranch
}
prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName))
prefix := setting.AppURL + url.PathEscape(ownerName)
if group != "" {
prefix = prefix + "/" + group
}
prefix = prefix + "/" + path.Join(url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName))
appURL, _ := url.Parse(setting.AppURL)
@ -69,9 +78,9 @@ func goGet(ctx *context.Context) {
var cloneURL string
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, ownerName, repoName)
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, ownerName, repoName, gid)
} else {
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, ownerName, repoName)
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, ownerName, repoName, gid)
}
goImportContent := fmt.Sprintf("%s git %s", goGetImport, cloneURL /*CloneLink*/)
goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/)

View File

@ -278,7 +278,7 @@ func TeamsRepoAction(ctx *context.Context) {
case "add":
repoName := path.Base(ctx.FormString("repo_name"))
var repo *repo_model.Repository
repo, err = repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName)
repo, err = repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("group_id"), repoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.Flash.Error(ctx.Tr("org.teams.add_nonexistent_repo"))

View File

@ -161,6 +161,7 @@ func RestoreBranchPost(ctx *context.Context) {
PusherName: ctx.Doer.Name,
RepoUserName: ctx.Repo.Owner.Name,
RepoName: ctx.Repo.Repository.Name,
RepoGroupID: ctx.Repo.Repository.GroupID,
}); err != nil {
log.Error("RestoreBranch: Update: %v", err)
}

View File

@ -265,6 +265,7 @@ func Diff(ctx *context.Context) {
userName := ctx.Repo.Owner.Name
repoName := ctx.Repo.Repository.Name
repoGroup := ctx.Repo.Repository.GroupID
commitID := ctx.PathParam("sha")
diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{
@ -348,7 +349,7 @@ func Diff(ctx *context.Context) {
}
parentCommitID = parentCommit.ID.String()
}
setCompareContext(ctx, parentCommit, commit, userName, repoName)
setCompareContext(ctx, parentCommit, commit, userName, repoName, repoGroup)
ctx.Data["Title"] = commit.MessageTitle() + " · " + base.ShortSha(commitID)
ctx.Data["Commit"] = commit
ctx.Data["Diff"] = diff

View File

@ -7,6 +7,7 @@ import (
gocontext "context"
"encoding/csv"
"errors"
"fmt"
"io"
"net/http"
"net/url"
@ -52,7 +53,7 @@ const (
)
// setCompareContext sets context data.
func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner, headName string) {
func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner, headName string, headGID int64) {
ctx.Data["BeforeCommit"] = before
ctx.Data["HeadCommit"] = head
@ -83,28 +84,36 @@ func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner
return st
}
setPathsCompareContext(ctx, before, head, headOwner, headName)
setPathsCompareContext(ctx, before, head, headOwner, headName, headGID)
setImageCompareContext(ctx)
setCsvCompareContext(ctx)
}
// SourceCommitURL creates a relative URL for a commit in the given repository
func SourceCommitURL(owner, name string, commit *git.Commit) string {
return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/src/commit/" + url.PathEscape(commit.ID.String())
func SourceCommitURL(owner, name string, gid int64, commit *git.Commit) string {
var groupSegment string
if gid > 0 {
groupSegment = fmt.Sprintf("group/%d/", gid)
}
return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + groupSegment + url.PathEscape(name) + "/src/commit/" + url.PathEscape(commit.ID.String())
}
// RawCommitURL creates a relative URL for the raw commit in the given repository
func RawCommitURL(owner, name string, commit *git.Commit) string {
return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/raw/commit/" + url.PathEscape(commit.ID.String())
func RawCommitURL(owner, name string, gid int64, commit *git.Commit) string {
var groupSegment string
if gid > 0 {
groupSegment = fmt.Sprintf("group/%d/", gid)
}
return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + groupSegment + url.PathEscape(name) + "/raw/commit/" + url.PathEscape(commit.ID.String())
}
// setPathsCompareContext sets context data for source and raw paths
func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOwner, headName string) {
ctx.Data["SourcePath"] = SourceCommitURL(headOwner, headName, head)
ctx.Data["RawPath"] = RawCommitURL(headOwner, headName, head)
func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOwner, headName string, headGID int64) {
ctx.Data["SourcePath"] = SourceCommitURL(headOwner, headName, headGID, head)
ctx.Data["RawPath"] = RawCommitURL(headOwner, headName, headGID, head)
if base != nil {
ctx.Data["BeforeSourcePath"] = SourceCommitURL(headOwner, headName, base)
ctx.Data["BeforeRawPath"] = RawCommitURL(headOwner, headName, base)
ctx.Data["BeforeSourcePath"] = SourceCommitURL(headOwner, headName, headGID, base)
ctx.Data["BeforeRawPath"] = RawCommitURL(headOwner, headName, headGID, base)
}
}
@ -523,7 +532,7 @@ func (cpi *comparePageInfoType) prepareCompareDiff(ctx *context.Context, whitesp
ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits, setting.Repository.PullRequest.DefaultTitleSource)
setCompareContext(ctx, beforeCommit, headCommit, ci.HeadRepo.OwnerName, repo.Name)
setCompareContext(ctx, beforeCommit, headCommit, ci.HeadRepo.OwnerName, repo.Name, repo.GroupID)
}
func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repository) (branches, tags []string, err error) {

View File

@ -20,7 +20,7 @@ func ForkToEdit(ctx *context.Context) {
func ForkToEditPost(ctx *context.Context) {
ForkRepoTo(ctx, ctx.Doer, repo_service.ForkRepoOptions{
BaseRepo: ctx.Repo.Repository,
Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, ctx.Repo.Repository.Name),
Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, 0, ctx.Repo.Repository.Name),
Description: ctx.Repo.Repository.Description,
SingleBranch: ctx.Repo.Repository.DefaultBranch, // maybe we only need the default branch in the fork?
})

View File

@ -114,10 +114,10 @@ func getParentTreeFields(treePath string) (treeNames, treePaths []string) {
// getUniqueRepositoryName Gets a unique repository name for a user
// It will append a -<num> postfix if the name is already taken
func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) string {
func getUniqueRepositoryName(ctx context.Context, ownerID, groupID int64, name string) string {
uniqueName := name
for i := 1; i < 1000; i++ {
_, err := repo_model.GetRepositoryByName(ctx, ownerID, uniqueName)
_, err := repo_model.GetRepositoryByName(ctx, ownerID, groupID, uniqueName)
if err != nil || repo_model.IsErrRepoNotExist(err) {
return uniqueName
}

View File

@ -107,7 +107,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
}
repoExist := true
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame)
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, ctx.PathParamInt64("group_id"), reponame)
if err != nil {
if !repo_model.IsErrRepoNotExist(err) {
ctx.ServerError("GetRepositoryByName", err)

View File

@ -238,7 +238,7 @@ func MigratePost(ctx *context.Context) {
opts.AWSSecretAccessKey = form.AWSSecretAccessKey
}
err = repo_service.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false)
err = repo_service.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, 0, false)
if err != nil {
handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form)
return

View File

@ -857,7 +857,7 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
return
}
setCompareContext(ctx, beforeCommit, afterCommit, ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
setCompareContext(ctx, beforeCommit, afterCommit, ctx.Repo.Owner.Name, ctx.Repo.Repository.Name, ctx.Repo.Repository.GroupID)
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
if err != nil {

View File

@ -21,7 +21,7 @@ func ActionStar(ctx *context.Context) {
}
ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.GroupID, ctx.Repo.Repository.Name)
if err != nil {
ctx.ServerError("GetRepositoryByName", err)
return

View File

@ -21,7 +21,7 @@ func ActionWatch(ctx *context.Context) {
}
ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.GroupID, ctx.Repo.Repository.Name)
if err != nil {
ctx.ServerError("GetRepositoryByName", err)
return

View File

@ -118,7 +118,7 @@ func UpdateGeneralSettings(ctx *context.Context) {
if ctx.FormBool("cross_repo_add_target") {
targetRepoName := ctx.FormString("cross_repo_add_target_name")
if targetRepoName != "" {
targetRepo, err := repo_model.GetRepositoryByName(ctx, rCtx.OwnerID, targetRepoName)
targetRepo, err := repo_model.GetRepositoryByName(ctx, rCtx.OwnerID, rCtx.GroupID, targetRepoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.JSONError("Repository doesn't exist")

View File

@ -36,6 +36,7 @@ const (
type runnersCtx struct {
OwnerID int64
RepoID int64
GroupID int64
IsRepo bool
IsOrg bool
IsAdmin bool
@ -49,6 +50,7 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
if ctx.Data["PageIsRepoSettings"] == true {
return &runnersCtx{
RepoID: ctx.Repo.Repository.ID,
GroupID: ctx.Repo.Repository.GroupID,
OwnerID: 0,
IsRepo: true,
RunnersTemplate: tplRepoRunners,

View File

@ -93,7 +93,7 @@ func prepareContextForProfileBigAvatar(ctx *context.Context) {
func FindOwnerProfileReadme(ctx *context.Context, doer *user_model.User, optProfileRepoName ...string) (profileDbRepo *repo_model.Repository, profileReadmeBlob *git.Blob) {
profileRepoName := util.OptionalArg(optProfileRepoName, RepoNameProfile)
profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, profileRepoName)
profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ctx.PathParamInt64("group_id"), profileRepoName)
if err != nil {
if !repo_model.IsErrRepoNotExist(err) {
log.Error("FindOwnerProfileReadme failed to GetRepositoryByName: %v", err)

View File

@ -479,7 +479,7 @@ func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSett
return
}
repo, err := repo_model.GetRepositoryByName(ctx, pd.Owner.ID, form.RepoName)
repo, err := repo_model.GetRepositoryByName(ctx, pd.Owner.ID, form.RepoGroup, form.RepoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.JSONError(ctx.Tr("packages.settings.link.repo_not_found", form.RepoName))

View File

@ -4,6 +4,9 @@
package setting
import (
"strconv"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting"
@ -21,18 +24,24 @@ func AdoptOrDeleteRepository(ctx *context.Context) {
ctx.Data["allowDelete"] = allowDelete
dir := ctx.FormString("id")
var gid int64
if len(strings.Split(dir, "/")) > 1 {
split := strings.Split(dir, "/")
dir = split[0]
gid, _ = strconv.ParseInt(split[1], 10, 64)
}
action := ctx.FormString("action")
ctxUser := ctx.Doer
// check not a repo
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, dir)
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, dir, 0)
if err != nil {
ctx.ServerError("IsRepositoryExist", err)
return
}
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, dir)))
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, dir, gid)))
if err != nil {
ctx.ServerError("IsDir", err)
return
@ -49,7 +58,7 @@ func AdoptOrDeleteRepository(ctx *context.Context) {
}
ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
} else if action == "delete" && allowDelete {
if err := repo_service.DeleteUnadoptedRepository(ctx, ctxUser, ctxUser, dir); err != nil {
if err := repo_service.DeleteUnadoptedRepository(ctx, ctxUser, ctxUser, dir, gid); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}

View File

@ -1134,19 +1134,23 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, optSignIn, context.UserAssignmentWeb(), context.OrgAssignment(context.OrgAssignmentOptions{}))
// end "/{username}/-": packages, projects, code
m.Group("/{username}/{reponame}/-", func() {
repoDashFn := func() {
m.Group("/migrate", func() {
m.Get("/status", repo.MigrateStatus)
})
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}/-": migrate
}
m.Group("/{username}/{reponame}/-", repoDashFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
m.Group("/{username}/group/{group_id}/{reponame}/-", repoDashFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{group_id}/{reponame}/-": migrate
m.Group("/{username}/{reponame}/-", func() {
mentionsFn := func() {
m.Get("/mentions-in-repo", repo.GetMentionsInRepo)
}, optSignIn, context.RepoAssignment, reqUnitsWithMentions)
}
m.Group("/{username}/{reponame}/-", mentionsFn, optSignIn, context.RepoAssignment, reqUnitsWithMentions)
m.Group("/{username}/{group_id}/{reponame}/-", mentionsFn, optSignIn, context.RepoAssignment, reqUnitsWithMentions)
// end "/{username}/{reponame}/-": mentions
m.Group("/{username}/{reponame}/settings", func() {
settingsFn := func() {
m.Group("", func() {
m.Combo("").Get(repo_setting.Settings).
Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
@ -1245,7 +1249,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/retry", repo.MigrateRetryPost)
m.Post("/cancel", repo.MigrateCancelPost)
})
},
}
m.Group("/{username}/{reponame}/settings", settingsFn,
reqSignIn, context.RepoAssignment, reqRepoAdmin,
ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer),
)
m.Group("/{username}/group/{group_id}/{reponame}/settings", settingsFn,
reqSignIn, context.RepoAssignment, reqRepoAdmin,
ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer),
)
@ -1253,10 +1262,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// user/org home, including rss feeds like "/{username}/{reponame}.rss"
m.Get("/{username}/{reponame}", optSignIn, webAuth.AllowBasic, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home)
m.Get("/{username}/group/{group_id}/{reponame}", optSignIn, webAuth.AllowBasic, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home)
m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Group("/{username}/{reponame}", func() {
m.Post("/{username}/group/{group_id}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
rootRepoFn := func() {
m.Group("/tree-list", func() {
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList)
m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList)
@ -1272,11 +1282,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
m.Get("/pulls/new/*", repo.PullsNewRedirect)
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}": repo code: find, compare, list
}
m.Group("/{username}/{reponame}", rootRepoFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
m.Group("/{username}/group/{group_id}/{reponame}", rootRepoFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{group_id}/{reponame}": repo code: find, compare, list
addIssuesPullsViewRoutes := func() {
// for /{username}/{reponame}/issues" or "/{username}/{reponame}/pulls"
// for /{username}/{group_id}/{reponame}/issues" or "/{username}/{group_id}/{reponame}/pulls"
m.Get("/posters", repo.IssuePullPosters)
m.Group("/{index}", func() {
m.Get("/info", repo.GetIssueInfo)
@ -1290,26 +1302,32 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
})
}
// FIXME: many "pulls" requests are sent to "issues" endpoints correctly, so the issue endpoints have to tolerate pull request permissions at the moment
m.Group("/{username}/group/{group_id}/{reponame}/{type:issues}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests))
m.Group("/{username}/{reponame}/{type:issues}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests))
m.Group("/{username}/group/{group_id}/{reponame}/{type:pulls}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitPullsReader)
m.Group("/{username}/{reponame}/{type:pulls}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitPullsReader)
m.Group("/{username}/{reponame}", func() {
repoIssueAttachmentFn := func() {
m.Get("/comments/{id}/attachments", repo.GetCommentAttachments)
m.Get("/labels", repo.RetrieveLabelsForList, repo.Labels)
m.Get("/milestones", repo.Milestones)
m.Get("/milestone/{id}", repo.MilestoneIssuesAndPulls)
m.Get("/issues/suggestions", repo.IssueSuggestions)
}, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // issue/pull attachments, labels, milestones
// end "/{username}/{reponame}": view milestone, label, issue, pull, etc
}
m.Group("/{username}/{reponame}/{type:issues}", func() {
// these handlers also check unit permissions internally
m.Group("/{username}/group/{group_id}/{reponame}", repoIssueAttachmentFn, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // issue/pull attachments, labels, milestones
m.Group("/{username}/{reponame}", repoIssueAttachmentFn, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // issue/pull attachments, labels, milestones
// end "/{username}/{group_id}/{reponame}": view milestone, label, issue, pull, etc
issueViewFn := func() {
m.Get("", repo.Issues)
m.Get("/{index}", repo.ViewIssue) // also do pull-request redirection (".../issues/{PR-number}" -> ".../pulls/{PR-number}")
}, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests, unit.TypeExternalTracker))
// end "/{username}/{reponame}": issue list, issue view (pull-request redirection), external tracker
m.Get("/{index}", repo.ViewIssue)
}
m.Group("/{username}/group/{group_id}/{reponame}/{type:issues}", issueViewFn, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests, unit.TypeExternalTracker))
m.Group("/{username}/{reponame}/{type:issues}", issueViewFn, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests, unit.TypeExternalTracker))
// end "/{username}/{group_id}/{reponame}": issue/pull list, issue/pull view, external tracker
m.Group("/{username}/{reponame}", func() { // edit issues, pulls, labels, milestones, etc
editIssueFn := func() { // edit issues, pulls, labels, milestones, etc
m.Group("/issues", func() {
m.Group("/new", func() {
m.Combo("").Get(repo.NewIssue).
@ -1320,7 +1338,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, reqUnitIssuesReader)
addIssuesPullsUpdateRoutes := func() {
// for "/{username}/{reponame}/issues" or "/{username}/{reponame}/pulls"
// for "/{username}/{group_id}/{reponame}/issues" or "/{username}/{group_id}/{reponame}/pulls"
m.Group("/{index}", func() {
m.Post("/title", repo.UpdateIssueTitle)
m.Post("/content", repo.UpdateIssueContent)
@ -1397,10 +1415,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/resolve_conversation", repo.SetShowOutdatedComments, repo.UpdateResolveConversation)
}, reqUnitPullsReader)
m.Post("/pull/{index}/target_branch", reqUnitPullsReader, repo.UpdatePullRequestTarget)
}, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
// end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones
}
m.Group("/{username}/group/{group_id}/{reponame}", editIssueFn, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
m.Group("/{username}/{reponame}", editIssueFn, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
// end "/{username}/{group_id}/{reponame}": create or edit issues, pulls, labels, milestones
m.Group("/{username}/{reponame}", func() { // repo code (at least "code reader")
codeFn := func() { // repo code (at least "code reader")
m.Group("", func() {
m.Group("", func() {
// "GET" requests only need "code reader" permission, "POST" requests need "code writer" permission.
@ -1448,10 +1468,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty)
m.Combo("/fork").Get(repo.Fork).Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost)
}, reqSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}": repo code
}
m.Group("/{username}/group/{group_id}/{reponame}", codeFn, reqSignIn, context.RepoAssignment, reqUnitCodeReader)
m.Group("/{username}/{reponame}", codeFn, reqSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{group_id}/{reponame}": repo code
m.Group("/{username}/{reponame}", func() { // repo tags
repoTagFn := func() { // repo tags
m.Group("/tags", func() {
m.Get("", context.RepoRefByDefaultBranch() /* for the "commits" tab */, repo.TagsList)
m.Get(".rss", webAuth.AllowBasic, feedEnabled, repo.TagsListFeedRSS)
@ -1459,10 +1481,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/list", repo.GetTagList)
}, ctxDataSet("EnableFeed", setting.Other.EnableFeed))
m.Post("/tags/delete", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.DeleteTag)
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader)
// end "/{username}/{reponame}": repo tags
}
m.Group("/{username}/group/{group_id}/{reponame}", repoTagFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader)
m.Group("/{username}/{reponame}", repoTagFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader)
// end "/{username}/{group_id}/{reponame}": repo tags
m.Group("/{username}/{reponame}", func() { // repo releases
repoReleaseFn := func() { // repo releases
m.Group("/releases", func() {
m.Get("", repo.Releases)
m.Get(".rss", webAuth.AllowBasic, feedEnabled, repo.ReleasesFeedRSS)
@ -1484,25 +1508,33 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/edit/*", repo.EditRelease)
m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost)
}, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache)
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
// end "/{username}/{reponame}": repo releases
}
m.Group("/{username}/group/{group_id}/{reponame}", repoReleaseFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
m.Group("/{username}/{reponame}", repoReleaseFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
// end "/{username}/{group_id}/{reponame}": repo releases
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
repoAttachmentsFn := func() { // to maintain compatibility with old attachments
m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn, context.RepoAssignment)
// end "/{username}/{reponame}": compatibility with old attachments
}
m.Group("/{username}/group/{group_id}/{reponame}", repoAttachmentsFn, optSignIn, context.RepoAssignment)
m.Group("/{username}/{reponame}", repoAttachmentsFn, optSignIn, context.RepoAssignment)
// end "/{username}/{group_id}/{reponame}": compatibility with old attachments
m.Group("/{username}/{reponame}", func() {
repoTopicFn := func() {
m.Post("/topics", repo.TopicsPost)
}, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())
}
m.Group("/{username}/group/{group_id}/{reponame}", repoTopicFn, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())
m.Group("/{username}/{reponame}", repoTopicFn, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())
m.Group("/{username}/{reponame}", func() {
repoPackageFn := func() {
if setting.Packages.Enabled {
m.Get("/packages", repo.Packages)
}
}, optSignIn, context.RepoAssignment)
}
m.Group("/{username}/group/{group_id}/{reponame}", repoPackageFn, optSignIn, context.RepoAssignment)
m.Group("/{username}/{reponame}", repoPackageFn, optSignIn, context.RepoAssignment)
m.Group("/{username}/{reponame}/projects", func() {
repoProjectsFn := func() {
m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054
@ -1526,10 +1558,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
})
})
}, reqRepoProjectsWriter, context.RepoMustNotBeArchived())
}, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)
// end "/{username}/{reponame}/projects"
}
m.Group("/{username}/group/{group_id}/{reponame}/projects", repoProjectsFn, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)
m.Group("/{username}/{reponame}/projects", repoProjectsFn, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)
// end "/{username}/{group_id}/{reponame}/projects"
m.Group("/{username}/{reponame}/actions", func() {
repoActionsFn := func() {
m.Get("", actions.List)
m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile)
m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile)
@ -1565,10 +1599,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/workflows/{workflow_name}", func() {
m.Get("/badge.svg", webAuth.AllowBasic, webAuth.AllowOAuth2, actions.GetWorkflowBadge)
})
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
// end "/{username}/{reponame}/actions"
}
m.Group("/{username}/group/{group_id}/{reponame}/actions", repoActionsFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
m.Group("/{username}/{reponame}/actions", repoActionsFn, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
// end "/{username}/{group_id}/{reponame}/actions"
m.Group("/{username}/{reponame}/wiki", func() {
repoWikiFn := func() {
m.Combo("").
Get(repo.Wiki).
Post(context.RepoMustNotBeArchived(), reqSignIn, reqUnitWikiWriter, web.Bind(forms.NewWikiForm{}), repo.WikiPost)
@ -1579,13 +1615,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/commit/{sha:[a-f0-9]{7,64}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff)
m.Get("/commit/{sha:[a-f0-9]{7,64}}.{ext:patch|diff}", repo.RawDiff)
m.Get("/raw/*", repo.WikiRaw)
}, optSignIn, context.RepoAssignment, repo.MustEnableWiki, reqUnitWikiReader, func(ctx *context.Context) {
}
m.Group("/{username}/group/{group_id}/{reponame}/wiki", repoWikiFn, optSignIn, context.RepoAssignment, repo.MustEnableWiki, reqUnitWikiReader, func(ctx *context.Context) {
ctx.Data["PageIsWiki"] = true
ctx.Data["CloneButtonOriginLink"] = ctx.Repo.Repository.WikiCloneLink(ctx, ctx.Doer)
})
// end "/{username}/{reponame}/wiki"
m.Group("/{username}/{reponame}/wiki", repoWikiFn, optSignIn, context.RepoAssignment, repo.MustEnableWiki, reqUnitWikiReader, func(ctx *context.Context) {
ctx.Data["PageIsWiki"] = true
ctx.Data["CloneButtonOriginLink"] = ctx.Repo.Repository.WikiCloneLink(ctx, ctx.Doer)
})
// end "/{username}/{group_id}/{reponame}/wiki"
m.Group("/{username}/{reponame}/activity", func() {
activityFn := func() {
// activity has its own permission checks
m.Get("", repo.Activity)
m.Get("/{period}", repo.Activity)
@ -1604,13 +1645,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/data", repo.CodeFrequencyData) // "recent-commits" also uses the same data as "code-frequency"
})
}, reqUnitCodeReader)
},
}
m.Group("/{username}/group/{group_id}/{reponame}/activity", activityFn,
optSignIn, context.RepoAssignment, repo.MustBeNotEmpty,
context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases),
)
// end "/{username}/{reponame}/activity"
m.Group("/{username}/{reponame}/activity", activityFn,
optSignIn, context.RepoAssignment, repo.MustBeNotEmpty,
context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases),
)
// end "/{username}/{group_id}/{reponame}/activity"
m.Group("/{username}/{reponame}", func() {
repoPullFn := func() {
m.Get("/{type:pulls}", repo.Issues)
m.Group("/{type:pulls}/{index}", func() {
m.Get("", repo.SetEditorconfigIfExists, repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue)
@ -1637,10 +1683,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, context.RepoMustNotBeArchived())
})
})
}, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader)
// end "/{username}/{reponame}/pulls/{index}": repo pull request
}
m.Group("/{username}/group/{group_id}/{reponame}", repoPullFn, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader)
m.Group("/{username}/{reponame}", repoPullFn, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader)
// end "/{username}/{group_id}/{reponame}/pulls/{index}": repo pull request
m.Group("/{username}/{reponame}", func() {
repoCodeFn := func() {
m.Group("/activity_author_data", func() {
m.Get("", repo.ActivityAuthors)
m.Get("/{period}", repo.ActivityAuthors)
@ -1719,17 +1767,21 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/forks", repo.Forks)
m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff)
m.Get("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit)
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}": repo code
}
m.Group("/{username}/group/{group_id}/{reponame}", repoCodeFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
m.Group("/{username}/{reponame}", repoCodeFn, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{group_id}/{reponame}": repo code
m.Group("/{username}/{reponame}", func() {
fn := func() {
m.Get("/stars", starsEnabled, repo.Stars)
m.Get("/watchers", repo.Watchers)
m.Get("/search", reqUnitCodeReader, repo.Search)
m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar)
m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch)
m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
}, optSignIn, context.RepoAssignment)
}
m.Group("/{username}/group/{group_id}/{reponame}", fn, optSignIn, context.RepoAssignment)
m.Group("/{username}/{reponame}", fn, optSignIn, context.RepoAssignment)
// git lfs uses its own jwt key, and it handles the token & auth by itself, it conflicts with the general "OAuth2" auth method
// pattern: "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters

View File

@ -43,6 +43,7 @@ type APIContext struct {
Repo *Repository
Org *APIOrganization
RepoGroup *RepoGroup
Package *Package
PublicOnly bool // Whether the request is for a public endpoint
}
@ -347,3 +348,9 @@ func (ctx *APIContext) IsUserRepoAdmin() bool {
func (ctx *APIContext) IsUserRepoWriter(unitTypes []unit.Type) bool {
return slices.ContainsFunc(unitTypes, ctx.Repo.Permission.CanWrite)
}
func (ctx *APIContext) IsUserGroupWriter(unitTypes []unit.Type) bool {
return slices.ContainsFunc(unitTypes, func(u unit.Type) bool {
return ctx.RepoGroup.CanWriteUnit(ctx, ctx.Doer, u)
})
}

View File

@ -58,9 +58,10 @@ type Context struct {
ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
Repo *Repository
Org *Organization
Package *Package
RepoGroup *RepoGroup
Repo *Repository
Org *Organization
Package *Package
}
func init() {

392
services/context/group.go Normal file
View File

@ -0,0 +1,392 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"strings"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
shared_group "code.gitea.io/gitea/models/shared/group"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
)
// commonCtx contains some common functions between APIContext and Context
type commonCtx interface {
context.Context
PathParamInt64(p string) int64
PathParam(p string) string
Written() bool
}
type RepoGroup struct {
IsOwner bool
IsMember bool
IsGroupAdmin bool
Group *group_model.Group
GroupLink string
OrgGroupLink string
CanCreateRepoOrGroup bool
Team *organization.Team
Teams []*organization.Team
GroupTeam *group_model.RepoGroupTeam
}
func (g *RepoGroup) CanWriteUnit(ctx context.Context, doer *user_model.User, unitType unit.Type) bool {
return g.UnitPermission(ctx, doer, unitType) >= perm.AccessModeWrite
}
func (g *RepoGroup) CanReadUnit(ctx context.Context, doer *user_model.User, unitType unit.Type) bool {
return g.UnitPermission(ctx, doer, unitType) >= perm.AccessModeRead
}
func (g *RepoGroup) UnitPermission(ctx context.Context, doer *user_model.User, unitType unit.Type) perm.AccessMode {
if doer != nil {
teams, err := organization.GetUserGroupTeams(ctx, g.Group.ID, doer.ID)
if err != nil {
log.Error("GetUserOrgTeams: %v", err)
return perm.AccessModeNone
}
if err := teams.LoadUnits(ctx); err != nil {
log.Error("LoadUnits: %v", err)
return perm.AccessModeNone
}
if len(teams) > 0 {
return teams.UnitMaxAccess(unitType)
}
}
if g.Group.Visibility.IsPublic() {
return perm.AccessModeRead
}
return perm.AccessModeNone
}
func getGroupByParams(ctx commonCtx, repoGroup *RepoGroup, handleNotFound func(error), handleOtherError func(string, error)) (err error) {
groupID := ctx.PathParamInt64("group_id")
repoGroup.Group, err = group_model.GetGroupByID(ctx, groupID)
if err != nil {
if group_model.IsErrGroupNotExist(err) {
handleNotFound(err)
} else {
handleOtherError("GetGroupByID", err)
}
return err
}
if err = repoGroup.Group.LoadAttributes(ctx); err != nil {
handleOtherError("LoadAttributes", err)
}
return err
}
func GetGroupByParams(ctx *Context) (err error) {
if ctx.RepoGroup == nil {
ctx.RepoGroup = &RepoGroup{}
}
return getGroupByParams(ctx, ctx.RepoGroup, ctx.NotFound, ctx.ServerError)
}
type GroupAssignmentOptions struct {
RequireMember bool
RequireOwner bool
RequireGroupAdmin bool
}
func groupAssignment(ctx commonCtx, doer *user_model.User, isSigned bool, handleNotFound func(error), handleOtherError func(string, error), assign func(repoGroup *RepoGroup, canAccess bool)) {
var err error
repoGroup := new(RepoGroup)
if repoGroup.Group == nil {
err = getGroupByParams(ctx, repoGroup, handleNotFound, handleOtherError)
}
if err != nil {
handleOtherError("GetGroupByParams", err)
return
}
if ctx.Written() {
return
}
group := repoGroup.Group
canAccess, err := group.CanAccess(ctx, doer)
if err != nil {
handleOtherError("error checking group access", err)
return
}
privateBecauseOfParent, err := group.IsPrivateBecauseOfParentPermissions(ctx, doer)
if err != nil {
handleOtherError("error checking group access", err)
return
}
if group.Owner == nil {
err = group.LoadOwner(ctx)
if err != nil {
handleOtherError("LoadOwner", err)
return
}
}
ownerAsOrg := (*organization.Organization)(group.Owner)
var orgWideAdmin, orgWideOwner, isOwnedBy bool
if isSigned {
if orgWideAdmin, err = ownerAsOrg.IsOrgAdmin(ctx, doer.ID); err != nil {
handleOtherError("IsOrgAdmin", err)
return
}
if orgWideOwner, err = ownerAsOrg.IsOwnedBy(ctx, doer.ID); err != nil {
handleOtherError("IsOwnedBy", err)
return
}
}
if orgWideOwner {
repoGroup.IsOwner = true
}
if orgWideAdmin {
repoGroup.IsGroupAdmin = true
}
if isSigned && doer.IsAdmin {
repoGroup.IsOwner = true
repoGroup.IsMember = true
repoGroup.IsGroupAdmin = true
repoGroup.CanCreateRepoOrGroup = true
} else if isSigned {
isOwnedBy, err = group.IsOwnedBy(ctx, doer.ID)
if err != nil {
handleOtherError("IsOwnedBy", err)
return
}
repoGroup.IsOwner = repoGroup.IsOwner || isOwnedBy
if repoGroup.IsOwner {
repoGroup.IsMember = true
repoGroup.IsGroupAdmin = true
repoGroup.CanCreateRepoOrGroup = true
} else {
repoGroup.IsMember, err = shared_group.IsGroupMember(ctx, group.ID, doer)
if err != nil {
handleOtherError("IsOrgMember", err)
return
}
repoGroup.CanCreateRepoOrGroup, err = group.CanCreateIn(ctx, doer.ID)
if err != nil {
handleOtherError("CanCreateIn", err)
return
}
}
} else {
//ctx.Data["SignedUser"] = &user_model.User{}
}
repoGroup.GroupLink = group.GroupLink()
repoGroup.OrgGroupLink = group.OrgGroupLink()
if repoGroup.IsMember {
shouldSeeAllTeams := false
if repoGroup.IsOwner {
shouldSeeAllTeams = true
} else {
teams, err := organization.GetUserGroupTeams(ctx, group.ID, doer.ID)
if err != nil {
handleOtherError("GetUserTeams", err)
return
}
for _, team := range teams {
if team.IncludesAllRepositories && team.AccessMode >= perm.AccessModeAdmin {
shouldSeeAllTeams = true
break
}
}
}
if shouldSeeAllTeams {
repoGroup.Teams, err = shared_group.GetGroupTeams(ctx, group.ID)
if err != nil {
handleOtherError("LoadTeams", err)
return
}
} else {
repoGroup.Teams, err = organization.GetUserGroupTeams(ctx, group.ID, doer.ID)
if err != nil {
handleOtherError("GetUserTeams", err)
return
}
}
//ctx.Data["NumTeams"] = len(repoGroup.Teams)
}
teamName := ctx.PathParam("team")
if len(teamName) > 0 {
teamExists := false
for _, team := range repoGroup.Teams {
if strings.EqualFold(team.LowerName, strings.ToLower(teamName)) {
teamExists = true
var groupTeam *group_model.RepoGroupTeam
groupTeam, err = group_model.FindGroupTeamByTeamID(ctx, group.ID, team.ID)
if err != nil {
handleOtherError("FindGroupTeamByTeamID", err)
return
}
repoGroup.GroupTeam = groupTeam
repoGroup.Team = team
repoGroup.IsMember = true
break
}
}
if !teamExists {
handleNotFound(err)
return
}
repoGroup.IsGroupAdmin = repoGroup.Team.IsOwnerTeam() || repoGroup.Team.AccessMode >= perm.AccessModeAdmin
} else {
for _, team := range repoGroup.Teams {
if team.AccessMode >= perm.AccessModeAdmin {
repoGroup.IsGroupAdmin = true
break
}
}
}
if isSigned {
isAdmin, err := group.IsAdminOf(ctx, doer.ID)
if err != nil {
handleOtherError("IsAdminOf", err)
return
}
repoGroup.IsGroupAdmin = repoGroup.IsGroupAdmin || isAdmin
}
if !repoGroup.IsOwner && !repoGroup.IsGroupAdmin {
canAccess = canAccess && !privateBecauseOfParent
}
assign(repoGroup, canAccess)
}
func GroupAssignmentWeb(args GroupAssignmentOptions) func(ctx *Context) {
return func(ctx *Context) {
opts := args
var err error
groupAssignment(ctx, ctx.Doer, ctx.IsSigned, ctx.NotFound, ctx.ServerError, func(repoGroup *RepoGroup, canAccess bool) {
if ctx.Written() {
return
}
group := repoGroup.Group
if group.Visibility != structs.VisibleTypePublic && !ctx.IsSigned {
ctx.NotFound(nil)
return
}
if group.Visibility == structs.VisibleTypePrivate {
opts.RequireMember = true
} else if !canAccess && group.Visibility != structs.VisibleTypePublic {
ctx.NotFound(nil)
return
}
if (opts.RequireMember && !repoGroup.IsMember) ||
(opts.RequireOwner && !repoGroup.IsOwner) {
ctx.NotFound(nil)
return
}
ctx.Data["EnableFeed"] = setting.Other.EnableFeed
ctx.Data["FeedURL"] = group.GroupLink()
ctx.Data["IsGroupOwner"] = repoGroup.IsOwner
ctx.Data["IsGroupMember"] = repoGroup.IsMember
ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
ctx.Data["IsPublicMember"] = func(uid int64) bool {
is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid)
return is
}
ctx.Data["CanReadProjects"] = repoGroup.CanReadUnit(ctx, ctx.Doer, unit.TypeProjects)
ctx.Data["CanCreateOrgRepo"] = repoGroup.CanCreateRepoOrGroup
ctx.Data["IsGroupAdmin"] = repoGroup.IsGroupAdmin
if opts.RequireGroupAdmin && !repoGroup.IsGroupAdmin {
ctx.NotFound(nil)
return
}
if len(group.Description) != 0 {
ctx.Data["RenderedDescription"], err = markdown.RenderString(markup.NewRenderContext(ctx), group.Description)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
}
ctx.Data["Group"] = group
ctx.Data["ContextGroup"] = repoGroup
ctx.Data["Doer"] = ctx.Doer
ctx.Data["GroupLink"] = group.GroupLink()
ctx.Data["OrgGroupLink"] = repoGroup.OrgGroupLink
ctx.Data["Breadcrumbs"], err = group_model.GetParentGroupChain(ctx, group.ID)
if err != nil {
ctx.ServerError("GetParentGroupChain", err)
return
}
if repoGroup == nil {
repoGroup = &RepoGroup{}
}
if !ctx.IsSigned {
ctx.Data["SignedUser"] = &user_model.User{}
}
if repoGroup.IsMember {
ctx.Data["NumTeams"] = len(repoGroup.Teams)
}
if repoGroup.Team != nil {
ctx.Data["Team"] = repoGroup.Team
ctx.Data["IsTeamMember"] = repoGroup.IsMember
}
ctx.RepoGroup = repoGroup
})
}
}
func GroupAssignmentAPI() func(ctx *APIContext) {
return func(ctx *APIContext) {
groupAssignment(ctx, ctx.Doer, ctx.IsSigned, func(err error) {
ctx.APIErrorNotFound(err)
}, func(_ string, err error) {
ctx.APIErrorInternal(err)
}, func(repoGroup *RepoGroup, canAccess bool) {
if ctx.Written() {
return
}
group := repoGroup.Group
if group.Visibility != structs.VisibleTypePublic && !ctx.IsSigned {
ctx.APIErrorNotFound(nil)
return
}
if ctx.IsSigned {
if !canAccess && group.Visibility != structs.VisibleTypePublic {
ctx.APIErrorNotFound(nil)
return
}
}
if !canAccess {
ctx.APIErrorNotFound(nil)
}
ctx.RepoGroup = repoGroup
})
}
}
func groupIsCurrent(ctx *Context) func(groupID int64) bool {
return func(groupID int64) bool {
if ctx.RepoGroup.Group == nil {
return false
}
return ctx.RepoGroup.Group.ID == groupID
}
}

View File

@ -12,6 +12,8 @@ import (
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"strings"
asymkey_model "code.gitea.io/gitea/models/asymkey"
@ -369,6 +371,7 @@ func ComposeGoGetImport(ctx context.Context, owner, repo string) string {
func EarlyResponseForGoGetMeta(ctx *Context) {
username := ctx.PathParam("username")
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
groupID := ctx.PathParamInt64("group_id")
if username == "" || reponame == "" {
ctx.PlainText(http.StatusBadRequest, "invalid repository path")
return
@ -376,15 +379,17 @@ func EarlyResponseForGoGetMeta(ctx *Context) {
var cloneURL string
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, username, reponame)
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, username, reponame, groupID)
} else {
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, username, reponame)
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, username, reponame, groupID)
}
goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(ctx, username, reponame), cloneURL)
htmlMeta := fmt.Sprintf(`<meta name="go-import" content="%s">`, html.EscapeString(goImportContent))
ctx.PlainText(http.StatusOK, htmlMeta)
}
var pathRegex = regexp.MustCompile(`(?i).*/[a-z\-0-9_]+/(\d+/)?[a-z\-0-9_]`)
// RedirectToRepo redirect to a differently-named repository
func RedirectToRepo(ctx *Base, redirectRepoID int64) {
ownerName := ctx.PathParam("username")
@ -396,6 +401,8 @@ func RedirectToRepo(ctx *Base, redirectRepoID int64) {
ctx.HTTPError(http.StatusInternalServerError, "GetRepositoryByID")
return
}
pathRegex.ReplaceAllString(ctx.Req.URL.EscapedPath(),
url.PathEscape(repo.OwnerName)+"/$1"+url.PathEscape(repo.Name))
redirectPath := strings.Replace(
ctx.Req.URL.EscapedPath(),
@ -461,9 +468,10 @@ func InitRepoPullRequestCtx(ctx *Context, base, head *repo_model.Repository) {
}
type repoAssignmentPrepareDataStruct struct {
ownerName string
repoName string
repo *repo_model.Repository
ownerName string
repoName string
rawGroupID string
repo *repo_model.Repository
}
func repoAssignmentPreCheck(ctx *Context) {
@ -481,13 +489,15 @@ func repoAssignmentPrepareData(ctx *Context) *repoAssignmentPrepareDataStruct {
// HINT: here it doesn't handle ".wiki" extension, it is handled in repoAssignmentAutoRedirectWiki, need to be refactored in the future
userName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
group := ctx.PathParam("group_id")
repoName = strings.TrimSuffix(repoName, ".git")
if setting.Other.EnableFeed {
ctx.Data["EnableFeed"] = true
repoName = strings.TrimSuffix(repoName, ".rss")
repoName = strings.TrimSuffix(repoName, ".atom")
}
return &repoAssignmentPrepareDataStruct{ownerName: userName, repoName: repoName}
return &repoAssignmentPrepareDataStruct{ownerName: userName, repoName: repoName, rawGroupID: group}
}
func repoAssignmentPrepareOwner(ctx *Context, data *repoAssignmentPrepareDataStruct) {
@ -525,7 +535,21 @@ func repoAssignmentPrepareOwner(ctx *Context, data *repoAssignmentPrepareDataStr
}
func repoAssignmentAutoRedirectWiki(ctx *Context, data *repoAssignmentPrepareDataStruct) {
userName, repoName := data.ownerName, data.repoName
userName, repoName, rawGroupID := data.ownerName, data.repoName, data.rawGroupID
var group string
if rawGroupID != "" {
gid, _ := strconv.ParseInt(rawGroupID, 10, 64)
if gid == 0 {
q := ctx.Req.URL.RawQuery
if q != "" {
q = "?" + q
}
ctx.Redirect(strings.Replace(ctx.Link, "/0/", "/", 1)+q, 307)
return
}
group += "/"
}
// redirect link to wiki
if strings.HasSuffix(repoName, ".wiki") {
// ctx.Req.URL.Path does not have the preceding appSubURL - any redirect must have this added
@ -535,7 +559,7 @@ func repoAssignmentAutoRedirectWiki(ctx *Context, data *repoAssignmentPrepareDat
redirectRepoName += originalRepoName[len(redirectRepoName)+5:]
redirectPath := strings.Replace(
ctx.Req.URL.EscapedPath(),
url.PathEscape(userName)+"/"+url.PathEscape(originalRepoName),
url.PathEscape(userName)+"/"+group+url.PathEscape(originalRepoName),
url.PathEscape(userName)+"/"+url.PathEscape(redirectRepoName)+"/wiki",
1,
)
@ -550,7 +574,8 @@ func repoAssignmentAutoRedirectWiki(ctx *Context, data *repoAssignmentPrepareDat
func repoAssignmentPrepareRepo(ctx *Context, data *repoAssignmentPrepareDataStruct) {
repoName := data.repoName
// Get repository.
repo, err := repo_model.GetRepositoryByName(ctx, ctx.Repo.Owner.ID, repoName)
gid, _ := strconv.ParseInt(data.rawGroupID, 10, 64)
repo, err := repo_model.GetRepositoryByName(ctx, ctx.Repo.Owner.ID, gid, repoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
redirectRepoID, err := repo_model.LookupRedirect(ctx, ctx.Repo.Owner.ID, repoName)
@ -570,6 +595,18 @@ func repoAssignmentPrepareRepo(ctx *Context, data *repoAssignmentPrepareDataStru
}
return
}
if repo.GroupID != gid {
ctx.NotFound(nil)
}
if gid > 0 {
GroupAssignmentWeb(GroupAssignmentOptions{
RequireMember: true,
})(ctx)
}
if ctx.Written() {
return
}
repo.Owner = ctx.Repo.Owner
data.repo = repo
}

View File

@ -0,0 +1,44 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package convert
import (
"context"
group_model "code.gitea.io/gitea/models/group"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
)
func ToAPIGroup(ctx context.Context, g *group_model.Group, actor *user_model.User) (*api.Group, error) {
err := g.LoadAttributes(ctx)
if err != nil {
return nil, err
}
apiGroup := &api.Group{
ID: g.ID,
Owner: ToUser(ctx, g.Owner, actor),
Name: g.Name,
Description: g.Description,
ParentGroupID: g.ParentGroupID,
Link: g.GroupLink(),
SortOrder: g.SortOrder,
AvatarURL: g.AvatarLink(ctx),
}
if apiGroup.NumSubgroups, err = group_model.CountGroups(ctx, &group_model.FindGroupsOptions{
ParentGroupID: g.ID,
}); err != nil {
return nil, err
}
if _, apiGroup.NumRepos, err = repo_model.SearchRepositoryByCondition(ctx, repo_model.SearchRepoOptions{
GroupID: g.ID,
Actor: actor,
OwnerID: g.OwnerID,
}, repo_model.AccessibleRepositoryCondition(actor, unit.TypeInvalid), true); err != nil {
return nil, err
}
return apiGroup, nil
}

View File

@ -253,6 +253,8 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
Topics: util.SliceNilAsEmpty(repo.Topics),
ObjectFormatName: api.ObjectFormatName(repo.ObjectFormatName),
Licenses: util.SliceNilAsEmpty(repoLicenses.StringList()),
GroupID: repo.GroupID,
GroupSortOrder: repo.GroupSortOrder,
}
}

View File

@ -432,8 +432,9 @@ func (f *WebauthnDeleteForm) Validate(req *http.Request, errs binding.Errors) bi
// PackageSettingForm form for package settings
type PackageSettingForm struct {
Action string
RepoName string `form:"repo_name"`
Action string
RepoName string `form:"repo_name"`
RepoGroup int64 `form:"repo_group"`
}
// Validate validates the fields

71
services/group/avatar.go Normal file
View File

@ -0,0 +1,71 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"errors"
"fmt"
"io"
"os"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
"code.gitea.io/gitea/modules/avatar"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/storage"
)
// UploadAvatar saves custom icon for group.
func UploadAvatar(ctx context.Context, g *group_model.Group, data []byte) error {
avatarData, err := avatar.ProcessAvatarImage(data)
if err != nil {
return err
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
g.Avatar = avatar.HashAvatar(g.ID, data)
if err = UpdateGroup(ctx, g, &UpdateOptions{}); err != nil {
return fmt.Errorf("updateGroup: %w", err)
}
if err = storage.SaveFrom(storage.Avatars, g.CustomAvatarRelativePath(), func(w io.Writer) error {
_, err = w.Write(avatarData)
return err
}); err != nil {
return fmt.Errorf("Failed to create dir %s: %w", g.CustomAvatarRelativePath(), err)
}
return committer.Commit()
}
// DeleteAvatar deletes the user's custom avatar.
func DeleteAvatar(ctx context.Context, g *group_model.Group) error {
aPath := g.CustomAvatarRelativePath()
log.Trace("DeleteAvatar[%d]: %s", g.ID, aPath)
return db.WithTx(ctx, func(ctx context.Context) error {
hasAvatar := len(g.Avatar) > 0
g.Avatar = ""
if _, err := db.GetEngine(ctx).ID(g.ID).Cols("avatar, use_custom_avatar").Update(g); err != nil {
return fmt.Errorf("DeleteAvatar: %w", err)
}
if hasAvatar {
if err := storage.Avatars.Delete(aPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove %s: %w", aPath, err)
}
log.Warn("Deleting avatar %s but it doesn't exist", aPath)
}
}
return nil
})
}

87
services/group/delete.go Normal file
View File

@ -0,0 +1,87 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package group
import (
"context"
"code.gitea.io/gitea/models/db"
group_model "code.gitea.io/gitea/models/group"
repo_model "code.gitea.io/gitea/models/repo"
)
func DeleteGroup(ctx context.Context, gid int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
sess := db.GetEngine(ctx)
toDelete, err := group_model.GetGroupByID(ctx, gid)
if err != nil {
return err
}
// remove team permissions and units for deleted group
if _, err = sess.Where("group_id = ?", gid).Delete(new(group_model.RepoGroupTeam)); err != nil {
return err
}
if _, err = sess.Where("group_id = ?", gid).Delete(new(group_model.RepoGroupUnit)); err != nil {
return err
}
// move all repos in the deleted group to its immediate parent
repos, cnt, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
GroupID: gid,
})
if err != nil {
return err
}
_, inParent, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
GroupID: toDelete.ParentGroupID,
})
if err != nil {
return err
}
if cnt > 0 {
for i, repo := range repos {
repo.GroupID = toDelete.ParentGroupID
repo.GroupSortOrder = int(inParent + int64(i) + 1)
}
if _, err = sess.Where("group_id = ?", gid).Update(&repos); err != nil {
return err
}
}
// move all child groups to the deleted group's immediate parent
childGroups, err := group_model.FindGroups(ctx, &group_model.FindGroupsOptions{
ParentGroupID: gid,
})
if err != nil {
return err
}
if len(childGroups) > 0 {
inParent, err = group_model.CountGroups(ctx, &group_model.FindGroupsOptions{
ParentGroupID: toDelete.ParentGroupID,
})
if err != nil {
return err
}
for i, group := range childGroups {
group.ParentGroupID = toDelete.ParentGroupID
group.SortOrder = int(inParent) + i + 1
}
if _, err = sess.Where("parent_group_id = ?", gid).Update(&childGroups); err != nil {
return err
}
}
// finally, delete the group itself
if _, err = sess.ID(gid).Delete(new(group_model.Group)); err != nil {
return err
}
return committer.Commit()
}

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