mirror of
https://gitea.com/gitea/tea.git
synced 2026-05-16 04:39:23 +02:00
Compare commits
24 Commits
v0.14.0
...
lunny/add_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09bba53aec | ||
|
|
6afe288e4b | ||
|
|
4ff3775934 | ||
|
|
f617f26da0 | ||
|
|
a5ecf06c2a | ||
|
|
6af01bb13d | ||
|
|
e686e8d0bd | ||
|
|
22ff601988 | ||
|
|
9d6ae4bf02 | ||
|
|
2985824ab0 | ||
|
|
83b718ac34 | ||
|
|
1f6fd97fc1 | ||
|
|
27e6083e23 | ||
|
|
5d2d1a6e0c | ||
|
|
88421bb888 | ||
|
|
dd81b33cec | ||
|
|
b100d4c939 | ||
|
|
892905d482 | ||
|
|
5103496232 | ||
|
|
a58c35c3e2 | ||
|
|
783ac7684a | ||
|
|
d0b7ea09e8 | ||
|
|
20914a1375 | ||
|
|
3c1c9b2904 |
@@ -12,13 +12,9 @@ jobs:
|
|||||||
# uses: golang/govulncheck-action@v1
|
# uses: golang/govulncheck-action@v1
|
||||||
# with:
|
# with:
|
||||||
# go-version-file: 'go.mod'
|
# go-version-file: 'go.mod'
|
||||||
check-and-test:
|
check-and-unit:
|
||||||
|
name: Lint Build And Unit Coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
HTTP_PROXY: ""
|
|
||||||
GITEA_TEA_TEST_URL: "http://gitea:3000"
|
|
||||||
GITEA_TEA_TEST_USERNAME: "test01"
|
|
||||||
GITEA_TEA_TEST_PASSWORD: "test01"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
@@ -32,14 +28,30 @@ jobs:
|
|||||||
make fmt-check
|
make fmt-check
|
||||||
make docs-check
|
make docs-check
|
||||||
make build
|
make build
|
||||||
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
|
- name: unit test and coverage
|
||||||
- name: test and coverage
|
|
||||||
run: |
|
run: |
|
||||||
make test
|
|
||||||
make unit-test-coverage
|
make unit-test-coverage
|
||||||
|
|
||||||
|
integration-test:
|
||||||
|
name: Integration Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
GITEA_TEA_TEST_URL: "http://gitea:3000"
|
||||||
|
GITEA_TEA_TEST_USERNAME: "test01"
|
||||||
|
GITEA_TEA_TEST_PASSWORD: "test01"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: 'go.mod'
|
||||||
|
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
|
||||||
|
- name: integration test
|
||||||
|
run: |
|
||||||
|
make integration-test
|
||||||
services:
|
services:
|
||||||
gitea:
|
gitea:
|
||||||
image: docker.gitea.com/gitea:1.25.5
|
image: docker.gitea.com/gitea:1.26.1
|
||||||
cmd:
|
cmd:
|
||||||
- bash
|
- bash
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ dist/
|
|||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
27
Makefile
27
Makefile
@@ -30,7 +30,10 @@ LDFLAGS := -X "code.gitea.io/tea/modules/version.Version=$(TEA_VERSION)" -X "cod
|
|||||||
# override to allow passing additional goflags via make CLI
|
# override to allow passing additional goflags via make CLI
|
||||||
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)'
|
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)'
|
||||||
|
|
||||||
PACKAGES ?= $(shell $(GO) list ./...)
|
PACKAGES ?= $(shell $(GO) list ./... | grep -v '^code.gitea.io/tea/tests')
|
||||||
|
UNIT_PACKAGES ?= $(PACKAGES)
|
||||||
|
INTEGRATION_PACKAGES ?= $(shell $(GO) list ./tests/... 2>/dev/null)
|
||||||
|
INTEGRATION_TEST_TAGS ?= testtools
|
||||||
SOURCES ?= $(shell find . -name "*.go" -type f)
|
SOURCES ?= $(shell find . -name "*.go" -type f)
|
||||||
|
|
||||||
# OS specific vars.
|
# OS specific vars.
|
||||||
@@ -64,11 +67,11 @@ vet:
|
|||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint:
|
lint:
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools
|
||||||
|
|
||||||
.PHONY: lint-fix
|
.PHONY: lint-fix
|
||||||
lint-fix:
|
lint-fix:
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools --fix
|
||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@@ -93,13 +96,24 @@ docs-check:
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi;
|
fi;
|
||||||
|
|
||||||
|
.PHONY: unit-test
|
||||||
|
unit-test:
|
||||||
|
$(GO) test $(UNIT_PACKAGES)
|
||||||
|
|
||||||
|
.PHONY: integration-test
|
||||||
|
integration-test:
|
||||||
|
@if [ -n "$(INTEGRATION_PACKAGES)" ]; then \
|
||||||
|
$(GO) test -tags='$(INTEGRATION_TEST_TAGS)' $(INTEGRATION_PACKAGES); \
|
||||||
|
else \
|
||||||
|
echo "No integration test packages found"; \
|
||||||
|
fi
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test: unit-test integration-test
|
||||||
$(GO) test -tags='sqlite sqlite_unlock_notify' $(PACKAGES)
|
|
||||||
|
|
||||||
.PHONY: unit-test-coverage
|
.PHONY: unit-test-coverage
|
||||||
unit-test-coverage:
|
unit-test-coverage:
|
||||||
$(GO) test -tags='sqlite sqlite_unlock_notify' -cover -coverprofile coverage.out $(PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
$(GO) test -cover -coverprofile coverage.out $(UNIT_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
tidy:
|
tidy:
|
||||||
@@ -122,4 +136,3 @@ $(EXECUTABLE): $(SOURCES)
|
|||||||
.PHONY: build-image
|
.PHONY: build-image
|
||||||
build-image:
|
build-image:
|
||||||
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .
|
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ var cmdAdminUsers = cli.Command{
|
|||||||
},
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
&users.CmdUserList,
|
&users.CmdUserList,
|
||||||
|
&users.CmdUserCreate,
|
||||||
|
&users.CmdUserEdit,
|
||||||
|
&users.CmdUserDelete,
|
||||||
},
|
},
|
||||||
Flags: users.CmdUserList.Flags,
|
Flags: users.CmdUserList.Flags,
|
||||||
}
|
}
|
||||||
|
|||||||
220
cmd/admin/users/create.go
Normal file
220
cmd/admin/users/create.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
"code.gitea.io/tea/modules/print"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdUserCreate represents a sub command of users to create a user
|
||||||
|
var CmdUserCreate = cli.Command{
|
||||||
|
Name: "create",
|
||||||
|
Aliases: []string{"add", "new"},
|
||||||
|
Usage: "Create a new user",
|
||||||
|
Description: "Create a new user account",
|
||||||
|
ArgsUsage: " ", // command does not accept arguments
|
||||||
|
Action: RunUserCreate,
|
||||||
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "username",
|
||||||
|
Aliases: []string{"u"},
|
||||||
|
Usage: "Username for the new user (required)",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "password",
|
||||||
|
Aliases: []string{"p"},
|
||||||
|
Usage: "Password for the new user (will prompt if not provided)",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "password-file",
|
||||||
|
Usage: "Read password from file",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "password-stdin",
|
||||||
|
Usage: "Read password from stdin",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "email",
|
||||||
|
Aliases: []string{"e"},
|
||||||
|
Usage: "Email address for the new user (required)",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "full-name",
|
||||||
|
Usage: "Full name for the new user",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "admin",
|
||||||
|
Usage: "Make the user an administrator",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "restricted",
|
||||||
|
Usage: "Make the user restricted",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "prohibit-login",
|
||||||
|
Usage: "Prohibit the user from logging in",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-must-change-password",
|
||||||
|
Usage: "Don't require the user to change password on first login (default: password change required)",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "visibility",
|
||||||
|
Usage: "Visibility of the user profile (public, limited, private)",
|
||||||
|
Value: "public",
|
||||||
|
},
|
||||||
|
}, flags.AllDefaultFlags...),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUserCreate creates a new user
|
||||||
|
func RunUserCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
username := ctx.String("username")
|
||||||
|
password := ctx.String("password")
|
||||||
|
email := ctx.String("email")
|
||||||
|
fullName := ctx.String("full-name")
|
||||||
|
isAdmin := ctx.Bool("admin")
|
||||||
|
restricted := ctx.Bool("restricted")
|
||||||
|
prohibitLogin := ctx.Bool("prohibit-login")
|
||||||
|
noMustChangePassword := ctx.Bool("no-must-change-password")
|
||||||
|
visibility := ctx.String("visibility")
|
||||||
|
|
||||||
|
// Get password from various sources in priority order
|
||||||
|
if password == "" {
|
||||||
|
if ctx.String("password-file") != "" {
|
||||||
|
// Read from file
|
||||||
|
content, err := os.ReadFile(ctx.String("password-file"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password file: %w", err)
|
||||||
|
}
|
||||||
|
password = strings.TrimSpace(string(content))
|
||||||
|
} else if ctx.Bool("password-stdin") {
|
||||||
|
// Read from stdin
|
||||||
|
content, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password from stdin: %w", err)
|
||||||
|
}
|
||||||
|
password = strings.TrimSpace(string(content))
|
||||||
|
} else {
|
||||||
|
// Interactive prompt (hidden input)
|
||||||
|
fmt.Printf("Enter password for '%s': ", username)
|
||||||
|
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println() // Add newline after hidden input
|
||||||
|
password = string(bytePassword)
|
||||||
|
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm password (only for interactive mode)
|
||||||
|
fmt.Printf("Confirm password for '%s': ", username)
|
||||||
|
bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password confirmation: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println() // Add newline after hidden input
|
||||||
|
passwordConfirm := string(bytePasswordConfirm)
|
||||||
|
|
||||||
|
if password != passwordConfirm {
|
||||||
|
return fmt.Errorf("passwords do not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if email == "" {
|
||||||
|
return fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ctx.Login.Client()
|
||||||
|
|
||||||
|
// Build create options
|
||||||
|
createOpts := gitea.CreateUserOption{
|
||||||
|
LoginName: username,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
Email: email,
|
||||||
|
FullName: fullName,
|
||||||
|
SendNotify: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set must change password flag (pointer to bool required)
|
||||||
|
// By default, require user to change password on first login
|
||||||
|
// Only set to false if --no-must-change-password flag is explicitly set
|
||||||
|
mustChangePassword := !noMustChangePassword
|
||||||
|
createOpts.MustChangePassword = &mustChangePassword
|
||||||
|
|
||||||
|
vis, err := parseUserVisibility(visibility)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
createOpts.Visibility = vis
|
||||||
|
|
||||||
|
// Create the user
|
||||||
|
user, _, err := client.AdminCreateUser(createOpts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin, Restricted, and ProhibitLogin cannot be set during user creation
|
||||||
|
// We need to update them via AdminEditUser after creation if any of these flags are set
|
||||||
|
if isAdmin || restricted || prohibitLogin {
|
||||||
|
editOpts := gitea.EditUserOption{
|
||||||
|
LoginName: username, // Required field
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAdmin {
|
||||||
|
editOpts.Admin = &isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if restricted {
|
||||||
|
editOpts.Restricted = &restricted
|
||||||
|
}
|
||||||
|
|
||||||
|
if prohibitLogin {
|
||||||
|
editOpts.ProhibitLogin = &prohibitLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user with admin/restricted/prohibit-login settings
|
||||||
|
_, err = client.AdminEditUser(username, editOpts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user created but failed to update admin/restricted/prohibit-login status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh user info to reflect the changes
|
||||||
|
user, _, err = client.GetUserInfo(username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print.UserDetails(user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
77
cmd/admin/users/delete.go
Normal file
77
cmd/admin/users/delete.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdUserDelete represents a sub command of users to delete a user
|
||||||
|
var CmdUserDelete = cli.Command{
|
||||||
|
Name: "delete",
|
||||||
|
Aliases: []string{"rm", "remove"},
|
||||||
|
Usage: "Delete a user",
|
||||||
|
Description: "Delete a user account",
|
||||||
|
ArgsUsage: "<username>",
|
||||||
|
Action: RunUserDelete,
|
||||||
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "confirm",
|
||||||
|
Aliases: []string{"y"},
|
||||||
|
Usage: "confirm deletion without prompting",
|
||||||
|
},
|
||||||
|
}, flags.AllDefaultFlags...),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUserDelete deletes a user
|
||||||
|
func RunUserDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Args().Len() == 0 {
|
||||||
|
return fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ctx.Login.Client()
|
||||||
|
username := ctx.Args().First()
|
||||||
|
|
||||||
|
// Get user details first to show what we're deleting
|
||||||
|
user, _, err := client.GetUserInfo(username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ctx.Bool("confirm") {
|
||||||
|
userInfo := fmt.Sprintf("%s (ID: %d)", user.UserName, user.ID)
|
||||||
|
if user.Email != "" {
|
||||||
|
userInfo += fmt.Sprintf(" - %s", user.Email)
|
||||||
|
}
|
||||||
|
if user.IsAdmin {
|
||||||
|
userInfo += " [admin]"
|
||||||
|
}
|
||||||
|
fmt.Printf("Are you sure you want to delete user %s? [y/N] ", userInfo)
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if !isConfirmationAccepted(response) {
|
||||||
|
fmt.Println("Deletion canceled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.AdminDeleteUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("User '%s' deleted successfully\n", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
374
cmd/admin/users/edit.go
Normal file
374
cmd/admin/users/edit.go
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
"code.gitea.io/tea/modules/print"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdUserEdit represents a sub command of users to edit a user
|
||||||
|
var CmdUserEdit = cli.Command{
|
||||||
|
Name: "edit",
|
||||||
|
Aliases: []string{"update", "e", "u"},
|
||||||
|
Usage: "Edit a user",
|
||||||
|
Description: "Edit user account properties",
|
||||||
|
ArgsUsage: "<username>",
|
||||||
|
Action: RunUserEdit,
|
||||||
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "password",
|
||||||
|
Usage: "New password (use empty value --password=\"\" to trigger interactive prompt)",
|
||||||
|
Value: "",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "password-file",
|
||||||
|
Usage: "Read password from file",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "password-stdin",
|
||||||
|
Usage: "Read password from stdin",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "email",
|
||||||
|
Aliases: []string{"e"},
|
||||||
|
Usage: "Email address",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "full-name",
|
||||||
|
Usage: "Full name",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "description",
|
||||||
|
Usage: "User description",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "website",
|
||||||
|
Usage: "Website URL",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "location",
|
||||||
|
Usage: "Location",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "admin",
|
||||||
|
Usage: "Make the user an administrator",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-admin",
|
||||||
|
Usage: "Remove administrator status",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "restricted",
|
||||||
|
Usage: "Make the user restricted",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-restricted",
|
||||||
|
Usage: "Remove restricted status",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "prohibit-login",
|
||||||
|
Usage: "Prohibit the user from logging in",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "allow-login",
|
||||||
|
Usage: "Allow the user to log in",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "active",
|
||||||
|
Usage: "Activate the user",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "inactive",
|
||||||
|
Usage: "Deactivate the user",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-must-change-password",
|
||||||
|
Usage: "Don't require the user to change password on next login (default: password change required)",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "visibility",
|
||||||
|
Usage: "Visibility of the user profile (public, limited, private)",
|
||||||
|
},
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "max-repo-creation",
|
||||||
|
Usage: "Maximum number of repositories the user can create (-1 for unlimited)",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "allow-git-hook",
|
||||||
|
Usage: "Allow the user to use git hooks",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-allow-git-hook",
|
||||||
|
Usage: "Disallow the user from using git hooks",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "allow-import-local",
|
||||||
|
Usage: "Allow the user to import local repositories",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-allow-import-local",
|
||||||
|
Usage: "Disallow the user from importing local repositories",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "allow-create-organization",
|
||||||
|
Usage: "Allow the user to create organizations",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-allow-create-organization",
|
||||||
|
Usage: "Disallow the user from creating organizations",
|
||||||
|
},
|
||||||
|
}, flags.AllDefaultFlags...),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUserEdit edits an existing user
|
||||||
|
func RunUserEdit(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Args().Len() == 0 {
|
||||||
|
return fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ctx.Login.Client()
|
||||||
|
username := ctx.Args().First()
|
||||||
|
|
||||||
|
// Verify the user exists before attempting an update.
|
||||||
|
_, _, err = client.GetUserInfo(username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edit options, starting with required LoginName
|
||||||
|
editOpts := gitea.EditUserOption{
|
||||||
|
LoginName: username,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update email if set
|
||||||
|
if ctx.IsSet("email") {
|
||||||
|
email := ctx.String("email")
|
||||||
|
editOpts.Email = &email
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update full name if set
|
||||||
|
if ctx.IsSet("full-name") {
|
||||||
|
fullName := ctx.String("full-name")
|
||||||
|
editOpts.FullName = &fullName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update description if set
|
||||||
|
if ctx.IsSet("description") {
|
||||||
|
description := ctx.String("description")
|
||||||
|
editOpts.Description = &description
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update website if set
|
||||||
|
if ctx.IsSet("website") {
|
||||||
|
website := ctx.String("website")
|
||||||
|
editOpts.Website = &website
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update location if set
|
||||||
|
if ctx.IsSet("location") {
|
||||||
|
location := ctx.String("location")
|
||||||
|
editOpts.Location = &location
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle admin status
|
||||||
|
if ctx.IsSet("admin") {
|
||||||
|
admin := ctx.Bool("admin")
|
||||||
|
editOpts.Admin = &admin
|
||||||
|
} else if ctx.IsSet("no-admin") {
|
||||||
|
admin := false
|
||||||
|
editOpts.Admin = &admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle restricted status
|
||||||
|
if ctx.IsSet("restricted") {
|
||||||
|
restricted := ctx.Bool("restricted")
|
||||||
|
editOpts.Restricted = &restricted
|
||||||
|
} else if ctx.IsSet("no-restricted") {
|
||||||
|
restricted := false
|
||||||
|
editOpts.Restricted = &restricted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle prohibit login status
|
||||||
|
if ctx.IsSet("prohibit-login") {
|
||||||
|
prohibitLogin := ctx.Bool("prohibit-login")
|
||||||
|
editOpts.ProhibitLogin = &prohibitLogin
|
||||||
|
} else if ctx.IsSet("allow-login") {
|
||||||
|
prohibitLogin := false
|
||||||
|
editOpts.ProhibitLogin = &prohibitLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle active status
|
||||||
|
if ctx.IsSet("active") {
|
||||||
|
active := ctx.Bool("active")
|
||||||
|
editOpts.Active = &active
|
||||||
|
} else if ctx.IsSet("inactive") {
|
||||||
|
active := false
|
||||||
|
editOpts.Active = &active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle must change password - will be set when password is changed unless flag is set
|
||||||
|
|
||||||
|
// Handle visibility
|
||||||
|
if ctx.IsSet("visibility") {
|
||||||
|
vis, err := parseUserVisibility(ctx.String("visibility"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
editOpts.Visibility = vis
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle max repo creation
|
||||||
|
if ctx.IsSet("max-repo-creation") {
|
||||||
|
maxRepoCreation := ctx.Int("max-repo-creation")
|
||||||
|
editOpts.MaxRepoCreation = &maxRepoCreation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle allow git hook
|
||||||
|
if ctx.IsSet("allow-git-hook") {
|
||||||
|
allowGitHook := ctx.Bool("allow-git-hook")
|
||||||
|
editOpts.AllowGitHook = &allowGitHook
|
||||||
|
} else if ctx.IsSet("no-allow-git-hook") {
|
||||||
|
allowGitHook := false
|
||||||
|
editOpts.AllowGitHook = &allowGitHook
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle allow import local
|
||||||
|
if ctx.IsSet("allow-import-local") {
|
||||||
|
allowImportLocal := ctx.Bool("allow-import-local")
|
||||||
|
editOpts.AllowImportLocal = &allowImportLocal
|
||||||
|
} else if ctx.IsSet("no-allow-import-local") {
|
||||||
|
allowImportLocal := false
|
||||||
|
editOpts.AllowImportLocal = &allowImportLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle allow create organization
|
||||||
|
if ctx.IsSet("allow-create-organization") {
|
||||||
|
allowCreateOrg := ctx.Bool("allow-create-organization")
|
||||||
|
editOpts.AllowCreateOrganization = &allowCreateOrg
|
||||||
|
} else if ctx.IsSet("no-allow-create-organization") {
|
||||||
|
allowCreateOrg := false
|
||||||
|
editOpts.AllowCreateOrganization = &allowCreateOrg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle password if any password flag is set or if password flag was provided (even without value)
|
||||||
|
shouldChangePassword := ctx.IsSet("password") || ctx.IsSet("password-file") || ctx.Bool("password-stdin")
|
||||||
|
if shouldChangePassword {
|
||||||
|
password := ctx.String("password")
|
||||||
|
|
||||||
|
// Get password from various sources in priority order
|
||||||
|
if password == "" {
|
||||||
|
if ctx.IsSet("password-file") && ctx.String("password-file") != "" {
|
||||||
|
// Read from file
|
||||||
|
content, err := os.ReadFile(ctx.String("password-file"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password file: %w", err)
|
||||||
|
}
|
||||||
|
password = strings.TrimSpace(string(content))
|
||||||
|
} else if ctx.Bool("password-stdin") {
|
||||||
|
// Read from stdin
|
||||||
|
content, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password from stdin: %w", err)
|
||||||
|
}
|
||||||
|
password = strings.TrimSpace(string(content))
|
||||||
|
} else {
|
||||||
|
// Interactive prompt (hidden input) - triggered when --password is used without value
|
||||||
|
fmt.Printf("Enter new password for '%s': ", username)
|
||||||
|
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println() // Add newline after hidden input
|
||||||
|
password = string(bytePassword)
|
||||||
|
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm password (only for interactive mode)
|
||||||
|
fmt.Printf("Confirm new password for '%s': ", username)
|
||||||
|
bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password confirmation: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println() // Add newline after hidden input
|
||||||
|
passwordConfirm := string(bytePasswordConfirm)
|
||||||
|
|
||||||
|
if password != passwordConfirm {
|
||||||
|
return fmt.Errorf("passwords do not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
editOpts.Password = password
|
||||||
|
|
||||||
|
// When password is changed, require user to change password on next login by default
|
||||||
|
// Only set to false if --no-must-change-password flag is explicitly set
|
||||||
|
if !ctx.IsSet("no-must-change-password") {
|
||||||
|
mustChangePassword := true
|
||||||
|
editOpts.MustChangePassword = &mustChangePassword
|
||||||
|
} else {
|
||||||
|
mustChangePassword := false
|
||||||
|
editOpts.MustChangePassword = &mustChangePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only proceed with update if at least one field is being modified
|
||||||
|
hasChanges := editOpts.Email != nil ||
|
||||||
|
editOpts.FullName != nil ||
|
||||||
|
editOpts.Description != nil ||
|
||||||
|
editOpts.Website != nil ||
|
||||||
|
editOpts.Location != nil ||
|
||||||
|
editOpts.Admin != nil ||
|
||||||
|
editOpts.Restricted != nil ||
|
||||||
|
editOpts.ProhibitLogin != nil ||
|
||||||
|
editOpts.Active != nil ||
|
||||||
|
editOpts.Visibility != nil ||
|
||||||
|
editOpts.MaxRepoCreation != nil ||
|
||||||
|
editOpts.AllowGitHook != nil ||
|
||||||
|
editOpts.AllowImportLocal != nil ||
|
||||||
|
editOpts.AllowCreateOrganization != nil ||
|
||||||
|
editOpts.Password != ""
|
||||||
|
|
||||||
|
if !hasChanges {
|
||||||
|
return fmt.Errorf("no changes specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user
|
||||||
|
_, err = client.AdminEditUser(username, editOpts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh user info to reflect the changes
|
||||||
|
updatedUser, _, err := client.GetUserInfo(username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
print.UserDetails(updatedUser)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
32
cmd/admin/users/shared.go
Normal file
32
cmd/admin/users/shared.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseUserVisibility(visibility string) (*gitea.VisibleType, error) {
|
||||||
|
switch visibility {
|
||||||
|
case "public":
|
||||||
|
vis := gitea.VisibleTypePublic
|
||||||
|
return &vis, nil
|
||||||
|
case "limited":
|
||||||
|
vis := gitea.VisibleTypeLimited
|
||||||
|
return &vis, nil
|
||||||
|
case "private":
|
||||||
|
vis := gitea.VisibleTypePrivate
|
||||||
|
return &vis, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid visibility: %s (must be public, limited, or private)", visibility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConfirmationAccepted(response string) bool {
|
||||||
|
trimmed := strings.TrimSpace(response)
|
||||||
|
return strings.EqualFold(trimmed, "y") || strings.EqualFold(trimmed, "yes")
|
||||||
|
}
|
||||||
@@ -61,12 +61,20 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, _, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
|
var existing []*gitea.Attachment
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
for page := 1; ; {
|
||||||
|
page_attachments, resp, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
existing = append(existing, page_attachments...)
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
|
}
|
||||||
|
|
||||||
for _, name := range ctx.Args().Slice()[1:] {
|
for _, name := range ctx.Args().Slice()[1:] {
|
||||||
var attachment *gitea.Attachment
|
var attachment *gitea.Attachment
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ func App() *cli.Command {
|
|||||||
&CmdNotifications,
|
&CmdNotifications,
|
||||||
&CmdRepoClone,
|
&CmdRepoClone,
|
||||||
|
|
||||||
|
&CmdSSHKeys,
|
||||||
|
|
||||||
&CmdAdmin,
|
&CmdAdmin,
|
||||||
|
|
||||||
&CmdApi,
|
&CmdApi,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package issues
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
stdctx "context"
|
stdctx "context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/tea/cmd/flags"
|
"code.gitea.io/tea/cmd/flags"
|
||||||
@@ -77,7 +78,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
|
|||||||
Type: kind,
|
Type: kind,
|
||||||
KeyWord: ctx.String("keyword"),
|
KeyWord: ctx.String("keyword"),
|
||||||
CreatedBy: ctx.String("author"),
|
CreatedBy: ctx.String("author"),
|
||||||
AssignedBy: ctx.String("assigned-to"),
|
AssignedBy: ctx.String("assignee"),
|
||||||
MentionedBy: ctx.String("mentions"),
|
MentionedBy: ctx.String("mentions"),
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
Milestones: milestones,
|
Milestones: milestones,
|
||||||
@@ -88,13 +89,15 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if ctx.IsSet("assignee") {
|
||||||
|
return errors.New("--assignee requires --repo (global issue search does not support assignee filter)")
|
||||||
|
}
|
||||||
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
|
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
|
||||||
ListOptions: flags.GetListOptions(cmd),
|
ListOptions: flags.GetListOptions(cmd),
|
||||||
State: state,
|
State: state,
|
||||||
Type: kind,
|
Type: kind,
|
||||||
KeyWord: ctx.String("keyword"),
|
KeyWord: ctx.String("keyword"),
|
||||||
CreatedBy: ctx.String("author"),
|
CreatedBy: ctx.String("author"),
|
||||||
AssignedBy: ctx.String("assigned-to"),
|
|
||||||
MentionedBy: ctx.String("mentions"),
|
MentionedBy: ctx.String("mentions"),
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
Milestones: milestones,
|
Milestones: milestones,
|
||||||
|
|||||||
@@ -32,9 +32,7 @@ func runLoginEdit(_ context.Context, _ *cli.Command) error {
|
|||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
if err := cmd.Run(); err != nil {
|
return cmd.Run()
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return open.Start(config.GetConfigPath())
|
return open.Start(config.GetConfigPath())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ var CmdGenerateManPage = cli.Command{
|
|||||||
Hidden: true,
|
Hidden: true,
|
||||||
Flags: DocRenderFlags,
|
Flags: DocRenderFlags,
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
return RenderDocs(cmd, cmd.Root(), docs.ToMan)
|
return RenderDocs(cmd, cmd.Root(), func(cmd *cli.Command) (string, error) {
|
||||||
|
return docs.ToManWithSection(cmd, 1)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
package organizations
|
package organizations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
stdctx "context"
|
stdctx "context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"code.gitea.io/tea/cmd/flags"
|
"code.gitea.io/tea/cmd/flags"
|
||||||
"code.gitea.io/tea/modules/context"
|
"code.gitea.io/tea/modules/context"
|
||||||
"code.gitea.io/tea/modules/print"
|
"code.gitea.io/tea/modules/print"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ var CmdOrganizationCreate = cli.Command{
|
|||||||
ArgsUsage: "<organization name>",
|
ArgsUsage: "<organization name>",
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "name",
|
Name: "full-name",
|
||||||
Aliases: []string{"n"},
|
Aliases: []string{"n"},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
@@ -76,7 +76,7 @@ func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error {
|
|||||||
|
|
||||||
org, _, err := ctx.Login.Client().CreateOrg(gitea.CreateOrgOption{
|
org, _, err := ctx.Login.Client().CreateOrg(gitea.CreateOrgOption{
|
||||||
Name: ctx.Args().First(),
|
Name: ctx.Args().First(),
|
||||||
// FullName: , // not really meaningful for orgs (not displayed in webui, use description instead?)
|
FullName: ctx.String("full-name"),
|
||||||
Description: ctx.String("description"),
|
Description: ctx.String("description"),
|
||||||
Website: ctx.String("website"),
|
Website: ctx.String("website"),
|
||||||
Location: ctx.String("location"),
|
Location: ctx.String("location"),
|
||||||
|
|||||||
14
cmd/pulls.go
14
cmd/pulls.go
@@ -77,6 +77,7 @@ var CmdPulls = cli.Command{
|
|||||||
&pulls.CmdPullsApprove,
|
&pulls.CmdPullsApprove,
|
||||||
&pulls.CmdPullsReject,
|
&pulls.CmdPullsReject,
|
||||||
&pulls.CmdPullsMerge,
|
&pulls.CmdPullsMerge,
|
||||||
|
&pulls.CmdPullsReply,
|
||||||
&pulls.CmdPullsReviewComments,
|
&pulls.CmdPullsReviewComments,
|
||||||
&pulls.CmdPullsResolve,
|
&pulls.CmdPullsResolve,
|
||||||
&pulls.CmdPullsUnresolve,
|
&pulls.CmdPullsUnresolve,
|
||||||
@@ -109,11 +110,20 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
reviews, _, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
|
var reviews []*gitea.PullReview
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
for page := 1; ; {
|
||||||
|
page_reviews, resp, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error while loading reviews: %v\n", err)
|
fmt.Printf("error while loading reviews: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
reviews = append(reviews, page_reviews...)
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.IsSet("output") {
|
if ctx.IsSet("output") {
|
||||||
|
|||||||
29
cmd/pulls/reply.go
Normal file
29
cmd/pulls/reply.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pulls
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdPullsReply replies to a review comment on a pull request.
|
||||||
|
var CmdPullsReply = cli.Command{
|
||||||
|
Name: "reply",
|
||||||
|
Usage: "Reply to a pull request review comment",
|
||||||
|
Description: "Reply to a pull request review comment",
|
||||||
|
ArgsUsage: "<pull index> <comment id> [<reply>]",
|
||||||
|
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runPullReviewReply(ctx)
|
||||||
|
},
|
||||||
|
Flags: flags.AllDefaultFlags,
|
||||||
|
}
|
||||||
70
cmd/pulls/reply_test.go
Normal file
70
cmd/pulls/reply_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pulls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/modules/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReply(t *testing.T) {
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{
|
||||||
|
Logins: []config.Login{{
|
||||||
|
Name: "testLogin",
|
||||||
|
URL: "https://gitea.example.com",
|
||||||
|
Token: "test-token",
|
||||||
|
User: "testUser",
|
||||||
|
Default: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{})
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
args: []string{},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "pull request index and comment ID are required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing comment id",
|
||||||
|
args: []string{"1"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "pull request index and comment ID are required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pull index and comment id",
|
||||||
|
args: []string{"1", "2"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "no reply content provided",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd := CmdPullsReply
|
||||||
|
args := append([]string{"reply"}, tt.args...)
|
||||||
|
args = append(args, "--login", "testLogin", "--repo", "user/repo")
|
||||||
|
err := cmd.Run(context.Background(), args)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tt.errContains != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ package pulls
|
|||||||
import (
|
import (
|
||||||
stdctx "context"
|
stdctx "context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"code.gitea.io/tea/cmd/flags"
|
"code.gitea.io/tea/cmd/flags"
|
||||||
"code.gitea.io/tea/modules/context"
|
"code.gitea.io/tea/modules/context"
|
||||||
"code.gitea.io/tea/modules/interact"
|
"code.gitea.io/tea/modules/interact"
|
||||||
|
"code.gitea.io/tea/modules/print"
|
||||||
"code.gitea.io/tea/modules/utils"
|
"code.gitea.io/tea/modules/utils"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
@@ -30,11 +32,18 @@ var CmdPullsReview = cli.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Args().Len() != 1 {
|
if !ctx.Args().Present() {
|
||||||
return fmt.Errorf("must specify a PR index")
|
return fmt.Errorf("must specify at least one PR index")
|
||||||
}
|
}
|
||||||
|
|
||||||
idx, err := utils.ArgToIndex(ctx.Args().First())
|
// This command is intentionally interactive. Fail early in CI / non-TTY
|
||||||
|
// contexts rather than hanging on prompts.
|
||||||
|
if os.Getenv("CI") != "" || !print.IsInteractive() || interact.IsStdinPiped() {
|
||||||
|
return fmt.Errorf("pull review requires an interactive terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, arg := range ctx.Args().Slice() {
|
||||||
|
idx, err := utils.ArgToIndex(arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -42,6 +51,8 @@ var CmdPullsReview = cli.Command{
|
|||||||
if err := interact.ReviewPull(ctx, idx); err != nil && !interact.IsQuitting(err) {
|
if err := interact.ReviewPull(ctx, idx); err != nil && !interact.IsQuitting(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Flags: flags.AllDefaultFlags,
|
Flags: flags.AllDefaultFlags,
|
||||||
|
|||||||
@@ -4,13 +4,20 @@
|
|||||||
package pulls
|
package pulls
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"code.gitea.io/tea/modules/config"
|
||||||
"code.gitea.io/tea/modules/context"
|
"code.gitea.io/tea/modules/context"
|
||||||
|
"code.gitea.io/tea/modules/interact"
|
||||||
"code.gitea.io/tea/modules/task"
|
"code.gitea.io/tea/modules/task"
|
||||||
|
"code.gitea.io/tea/modules/theme"
|
||||||
"code.gitea.io/tea/modules/utils"
|
"code.gitea.io/tea/modules/utils"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runPullReview handles the common logic for approving/rejecting pull requests
|
// runPullReview handles the common logic for approving/rejecting pull requests
|
||||||
@@ -58,3 +65,62 @@ func runResolveComment(ctx *context.TeaContext, action func(*context.TeaContext,
|
|||||||
|
|
||||||
return action(ctx, commentID)
|
return action(ctx, commentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runPullReviewReply handles replying to a specific review comment on a pull request.
|
||||||
|
func runPullReviewReply(ctx *context.TeaContext) error {
|
||||||
|
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Args().Len() < 2 {
|
||||||
|
return fmt.Errorf("pull request index and comment ID are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, err := utils.ArgToIndex(ctx.Args().First())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
commentID, err := utils.ArgToIndex(ctx.Args().Get(1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := getCommentBody(ctx, ctx.Args().Slice()[2:], "Reply(markdown):", "reply")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.ReplyToPullReviewComment(ctx, idx, commentID, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommentBody(ctx *context.TeaContext, extraArgs []string, promptTitle, noun string) (string, error) {
|
||||||
|
body := strings.Join(extraArgs, " ")
|
||||||
|
if interact.IsStdinPiped() {
|
||||||
|
bodyStdin, err := io.ReadAll(ctx.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(bodyStdin) != 0 {
|
||||||
|
body = strings.Join([]string{body, string(bodyStdin)}, "\n\n")
|
||||||
|
}
|
||||||
|
} else if len(body) == 0 {
|
||||||
|
if err := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewText().
|
||||||
|
Title(promptTitle).
|
||||||
|
ExternalEditor(config.GetPreferences().Editor).
|
||||||
|
EditorExtension("md").
|
||||||
|
Value(&body),
|
||||||
|
),
|
||||||
|
).WithTheme(theme.GetTheme()).Run(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(strings.TrimSpace(body)) == 0 {
|
||||||
|
return "", errors.New("no " + noun + " content provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|||||||
62
cmd/pulls/review_test.go
Normal file
62
cmd/pulls/review_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pulls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReview(t *testing.T) {
|
||||||
|
if os.Getenv("GITEA_TEA_TEST_URL") == "" {
|
||||||
|
t.Skip("GITEA_TEA_TEST_URL is not set, skipping test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
args: []string{},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "must specify at least one PR index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one argument",
|
||||||
|
args: []string{"1"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two arguments",
|
||||||
|
args: []string{"1", "2"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd := CmdPullsReview
|
||||||
|
args := append(tt.args, "--repo", "user/repo")
|
||||||
|
err := cmd.Run(context.Background(), args)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tt.errContains != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Don't assert no error, because we expect an error about the missing
|
||||||
|
// remote. Just assert that the error is not the one we're looking for.
|
||||||
|
if err != nil {
|
||||||
|
assert.NotContains(t, err.Error(), "must specify at least one PR index")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,14 @@ import (
|
|||||||
|
|
||||||
// GetReleaseByTag finds a release by its tag name.
|
// GetReleaseByTag finds a release by its tag name.
|
||||||
func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
|
func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
|
||||||
rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
|
for page := 1; ; {
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
rl, resp, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(rl) == 0 {
|
if page == 1 && len(rl) == 0 {
|
||||||
return nil, fmt.Errorf("repo does not have any release")
|
return nil, fmt.Errorf("repo does not have any release")
|
||||||
}
|
}
|
||||||
for _, r := range rl {
|
for _, r := range rl {
|
||||||
@@ -25,5 +26,10 @@ func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Rele
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("release tag does not exist")
|
return nil, fmt.Errorf("release tag does not exist")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import (
|
|||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CmdRepos represents to login a gitea server.
|
// CmdRepos represents the command to manage repositories.
|
||||||
var CmdRepos = cli.Command{
|
var CmdRepos = cli.Command{
|
||||||
Name: "repos",
|
Name: "repos",
|
||||||
Aliases: []string{"repo"},
|
Aliases: []string{"repo"},
|
||||||
Category: catEntities,
|
Category: catEntities,
|
||||||
Usage: "Show repository details",
|
Usage: "Manage repositories",
|
||||||
Description: "Show repository details",
|
Description: "Manage repositories",
|
||||||
ArgsUsage: "[<repo owner>/<repo name>]",
|
ArgsUsage: "[<repo owner>/<repo name>]",
|
||||||
Action: runRepos,
|
Action: runRepos,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
|
|||||||
32
cmd/sshkeys.go
Normal file
32
cmd/sshkeys.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/cmd/sshkeys"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdSSHKeys represents the ssh-keys command group
|
||||||
|
var CmdSSHKeys = cli.Command{
|
||||||
|
Name: "ssh-keys",
|
||||||
|
Aliases: []string{"ssh-key"},
|
||||||
|
Category: catSetup,
|
||||||
|
Usage: "Manage SSH public keys",
|
||||||
|
Description: "List, add, or delete SSH public keys on the current user's account",
|
||||||
|
ArgsUsage: " ",
|
||||||
|
Action: runSSHKeys,
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
&sshkeys.CmdSSHKeyList,
|
||||||
|
&sshkeys.CmdSSHKeyAdd,
|
||||||
|
&sshkeys.CmdSSHKeyDelete,
|
||||||
|
},
|
||||||
|
Flags: sshkeys.CmdSSHKeyList.Flags,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSSHKeys(ctx stdctx.Context, cmd *cli.Command) error {
|
||||||
|
return sshkeys.RunSSHKeyList(ctx, cmd)
|
||||||
|
}
|
||||||
74
cmd/sshkeys/add.go
Normal file
74
cmd/sshkeys/add.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sshkeys
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdSSHKeyAdd represents a sub command of ssh-keys to add an SSH public key
|
||||||
|
var CmdSSHKeyAdd = cli.Command{
|
||||||
|
Name: "add",
|
||||||
|
Usage: "Add an SSH public key",
|
||||||
|
Description: "Add an SSH public key to the current user's profile",
|
||||||
|
ArgsUsage: "<key-file>",
|
||||||
|
Action: RunSSHKeyAdd,
|
||||||
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "title",
|
||||||
|
Aliases: []string{"t"},
|
||||||
|
Usage: "Title for the key (defaults to the filename without extension)",
|
||||||
|
},
|
||||||
|
}, flags.LoginOutputFlags...),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSSHKeyAdd reads a public key file and registers it with the Gitea instance
|
||||||
|
func RunSSHKeyAdd(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Args().Len() < 1 {
|
||||||
|
return fmt.Errorf("key file path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyFile := ctx.Args().First()
|
||||||
|
keyBytes, err := os.ReadFile(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not read key file '%s': %w", keyFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyContent := strings.TrimSpace(string(keyBytes))
|
||||||
|
if keyContent == "" {
|
||||||
|
return fmt.Errorf("key file '%s' is empty", keyFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := ctx.String("title")
|
||||||
|
if title == "" {
|
||||||
|
base := filepath.Base(keyFile)
|
||||||
|
title = strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
key, _, err := ctx.Login.Client().CreatePublicKey(gitea.CreateKeyOption{
|
||||||
|
Title: title,
|
||||||
|
Key: keyContent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Key '%s' (id: %d) added successfully.\n", key.Title, key.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
33
cmd/sshkeys/add_test.go
Normal file
33
cmd/sshkeys/add_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sshkeys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeyTitleFromFilename(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"id_ed25519.pub", "id_ed25519"},
|
||||||
|
{"id_rsa.pub", "id_rsa"},
|
||||||
|
{"/home/user/.ssh/id_ed25519.pub", "id_ed25519"},
|
||||||
|
{"mykey", "mykey"}, // no extension
|
||||||
|
{"my.key.pub", "my.key"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
base := filepath.Base(tc.input)
|
||||||
|
title := strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
assert.Equal(t, tc.expected, title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
76
cmd/sshkeys/delete.go
Normal file
76
cmd/sshkeys/delete.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sshkeys
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdSSHKeyDelete represents a sub command of ssh-keys to delete an SSH key by ID
|
||||||
|
var CmdSSHKeyDelete = cli.Command{
|
||||||
|
Name: "delete",
|
||||||
|
Aliases: []string{"rm"},
|
||||||
|
Usage: "Delete an SSH key",
|
||||||
|
Description: "Delete an SSH key from the current user's profile by its numeric ID",
|
||||||
|
ArgsUsage: "<key-id>",
|
||||||
|
Action: RunSSHKeyDelete,
|
||||||
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "confirm",
|
||||||
|
Aliases: []string{"y"},
|
||||||
|
Usage: "Confirm deletion (required)",
|
||||||
|
},
|
||||||
|
}, flags.LoginOutputFlags...),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSSHKeyDelete removes an SSH key by its numeric ID
|
||||||
|
func RunSSHKeyDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Args().Len() < 1 {
|
||||||
|
return fmt.Errorf("key ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyID, err := strconv.ParseInt(ctx.Args().First(), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid key ID '%s': must be a number", ctx.Args().First())
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ctx.Login.Client()
|
||||||
|
|
||||||
|
key, resp, err := client.GetPublicKey(keyID)
|
||||||
|
if err != nil {
|
||||||
|
if resp != nil && resp.StatusCode == 404 {
|
||||||
|
return fmt.Errorf("SSH key with ID %d not found", keyID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ctx.Bool("confirm") {
|
||||||
|
fmt.Printf("Are you sure you want to delete SSH key '%s' (id: %d)? [y/N] ", key.Title, keyID)
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if response != "y" && response != "Y" && response != "yes" {
|
||||||
|
fmt.Println("Deletion canceled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = client.DeletePublicKey(keyID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("SSH key '%s' deleted successfully\n", key.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
47
cmd/sshkeys/list.go
Normal file
47
cmd/sshkeys/list.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sshkeys
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"code.gitea.io/tea/cmd/flags"
|
||||||
|
"code.gitea.io/tea/modules/context"
|
||||||
|
"code.gitea.io/tea/modules/print"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdSSHKeyList represents a sub command of ssh-keys to list the current user's SSH keys
|
||||||
|
var CmdSSHKeyList = cli.Command{
|
||||||
|
Name: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
|
Usage: "List SSH keys",
|
||||||
|
Description: "List the SSH keys registered for the current user",
|
||||||
|
ArgsUsage: " ", // command does not accept arguments
|
||||||
|
Action: RunSSHKeyList,
|
||||||
|
Flags: append([]cli.Flag{
|
||||||
|
&flags.PaginationPageFlag,
|
||||||
|
&flags.PaginationLimitFlag,
|
||||||
|
}, flags.LoginOutputFlags...),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSSHKeyList lists SSH keys for the current user
|
||||||
|
func RunSSHKeyList(_ stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client := ctx.Login.Client()
|
||||||
|
|
||||||
|
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
|
||||||
|
ListOptions: flags.GetListOptions(cmd),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return print.SSHKeysList(keys, ctx.Output)
|
||||||
|
}
|
||||||
@@ -89,14 +89,6 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error {
|
|||||||
config["secret"] = secret
|
config["secret"] = secret
|
||||||
}
|
}
|
||||||
|
|
||||||
if branchFilter != "" {
|
|
||||||
config["branch_filter"] = branchFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
if authHeader != "" {
|
|
||||||
config["authorization_header"] = authHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
var hook *gitea.Hook
|
var hook *gitea.Hook
|
||||||
if c.IsGlobal {
|
if c.IsGlobal {
|
||||||
return fmt.Errorf("global webhooks not yet supported in this version")
|
return fmt.Errorf("global webhooks not yet supported in this version")
|
||||||
@@ -106,6 +98,8 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error {
|
|||||||
Config: config,
|
Config: config,
|
||||||
Events: events,
|
Events: events,
|
||||||
Active: active,
|
Active: active,
|
||||||
|
BranchFilter: branchFilter,
|
||||||
|
AuthorizationHeader: authHeader,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
hook, _, err = client.CreateRepoHook(c.Owner, c.Repo, gitea.CreateHookOption{
|
hook, _, err = client.CreateRepoHook(c.Owner, c.Repo, gitea.CreateHookOption{
|
||||||
@@ -113,6 +107,8 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error {
|
|||||||
Config: config,
|
Config: config,
|
||||||
Events: events,
|
Events: events,
|
||||||
Active: active,
|
Active: active,
|
||||||
|
BranchFilter: branchFilter,
|
||||||
|
AuthorizationHeader: authHeader,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ func TestWebhookConfigConstruction(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
url string
|
url string
|
||||||
secret string
|
secret string
|
||||||
branchFilter string
|
|
||||||
authHeader string
|
|
||||||
expectedKeys []string
|
expectedKeys []string
|
||||||
expectedValues map[string]string
|
expectedValues map[string]string
|
||||||
}{
|
}{
|
||||||
@@ -106,44 +104,16 @@ func TestWebhookConfigConstruction(t *testing.T) {
|
|||||||
"secret": "my-secret",
|
"secret": "my-secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Config with branch filter",
|
|
||||||
url: "https://example.com/webhook",
|
|
||||||
branchFilter: "main,develop",
|
|
||||||
expectedKeys: []string{"url", "http_method", "content_type", "branch_filter"},
|
|
||||||
expectedValues: map[string]string{
|
|
||||||
"url": "https://example.com/webhook",
|
|
||||||
"http_method": "post",
|
|
||||||
"content_type": "json",
|
|
||||||
"branch_filter": "main,develop",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Config with auth header",
|
|
||||||
url: "https://example.com/webhook",
|
|
||||||
authHeader: "Bearer token123",
|
|
||||||
expectedKeys: []string{"url", "http_method", "content_type", "authorization_header"},
|
|
||||||
expectedValues: map[string]string{
|
|
||||||
"url": "https://example.com/webhook",
|
|
||||||
"http_method": "post",
|
|
||||||
"content_type": "json",
|
|
||||||
"authorization_header": "Bearer token123",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Complete config",
|
name: "Complete config",
|
||||||
url: "https://example.com/webhook",
|
url: "https://example.com/webhook",
|
||||||
secret: "secret123",
|
secret: "secret123",
|
||||||
branchFilter: "main",
|
expectedKeys: []string{"url", "http_method", "content_type", "secret"},
|
||||||
authHeader: "X-Token: abc",
|
|
||||||
expectedKeys: []string{"url", "http_method", "content_type", "secret", "branch_filter", "authorization_header"},
|
|
||||||
expectedValues: map[string]string{
|
expectedValues: map[string]string{
|
||||||
"url": "https://example.com/webhook",
|
"url": "https://example.com/webhook",
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
"secret": "secret123",
|
"secret": "secret123",
|
||||||
"branch_filter": "main",
|
|
||||||
"authorization_header": "X-Token: abc",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -159,12 +129,6 @@ func TestWebhookConfigConstruction(t *testing.T) {
|
|||||||
if tt.secret != "" {
|
if tt.secret != "" {
|
||||||
config["secret"] = tt.secret
|
config["secret"] = tt.secret
|
||||||
}
|
}
|
||||||
if tt.branchFilter != "" {
|
|
||||||
config["branch_filter"] = tt.branchFilter
|
|
||||||
}
|
|
||||||
if tt.authHeader != "" {
|
|
||||||
config["authorization_header"] = tt.authHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all expected keys exist
|
// Check all expected keys exist
|
||||||
for _, key := range tt.expectedKeys {
|
for _, key := range tt.expectedKeys {
|
||||||
@@ -189,6 +153,8 @@ func TestWebhookCreateOptions(t *testing.T) {
|
|||||||
events []string
|
events []string
|
||||||
active bool
|
active bool
|
||||||
config map[string]string
|
config map[string]string
|
||||||
|
branchFilter string
|
||||||
|
authHeader string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Gitea webhook",
|
name: "Gitea webhook",
|
||||||
@@ -200,6 +166,8 @@ func TestWebhookCreateOptions(t *testing.T) {
|
|||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
},
|
},
|
||||||
|
branchFilter: "main",
|
||||||
|
authHeader: "X-Token: abc",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Slack webhook",
|
name: "Slack webhook",
|
||||||
@@ -232,12 +200,16 @@ func TestWebhookCreateOptions(t *testing.T) {
|
|||||||
Config: tt.config,
|
Config: tt.config,
|
||||||
Events: tt.events,
|
Events: tt.events,
|
||||||
Active: tt.active,
|
Active: tt.active,
|
||||||
|
BranchFilter: tt.branchFilter,
|
||||||
|
AuthorizationHeader: tt.authHeader,
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, gitea.HookType(tt.webhookType), option.Type)
|
assert.Equal(t, gitea.HookType(tt.webhookType), option.Type)
|
||||||
assert.Equal(t, tt.events, option.Events)
|
assert.Equal(t, tt.events, option.Events)
|
||||||
assert.Equal(t, tt.active, option.Active)
|
assert.Equal(t, tt.active, option.Active)
|
||||||
assert.Equal(t, tt.config, option.Config)
|
assert.Equal(t, tt.config, option.Config)
|
||||||
|
assert.Equal(t, tt.branchFilter, option.BranchFilter)
|
||||||
|
assert.Equal(t, tt.authHeader, option.AuthorizationHeader)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,11 +97,14 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error {
|
|||||||
if cmd.IsSet("secret") {
|
if cmd.IsSet("secret") {
|
||||||
config["secret"] = cmd.String("secret")
|
config["secret"] = cmd.String("secret")
|
||||||
}
|
}
|
||||||
|
branchFilter := hook.BranchFilter
|
||||||
if cmd.IsSet("branch-filter") {
|
if cmd.IsSet("branch-filter") {
|
||||||
config["branch_filter"] = cmd.String("branch-filter")
|
branchFilter = cmd.String("branch-filter")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authHeader := hook.AuthorizationHeader
|
||||||
if cmd.IsSet("authorization-header") {
|
if cmd.IsSet("authorization-header") {
|
||||||
config["authorization_header"] = cmd.String("authorization-header")
|
authHeader = cmd.String("authorization-header")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update events if specified
|
// Update events if specified
|
||||||
@@ -129,12 +132,16 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error {
|
|||||||
Config: config,
|
Config: config,
|
||||||
Events: events,
|
Events: events,
|
||||||
Active: &active,
|
Active: &active,
|
||||||
|
BranchFilter: branchFilter,
|
||||||
|
AuthorizationHeader: authHeader,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
_, err = client.EditRepoHook(c.Owner, c.Repo, int64(webhookID), gitea.EditHookOption{
|
_, err = client.EditRepoHook(c.Owner, c.Repo, int64(webhookID), gitea.EditHookOption{
|
||||||
Config: config,
|
Config: config,
|
||||||
Events: events,
|
Events: events,
|
||||||
Active: &active,
|
Active: &active,
|
||||||
|
BranchFilter: branchFilter,
|
||||||
|
AuthorizationHeader: authHeader,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -130,8 +130,6 @@ func TestUpdateConfigPreservation(t *testing.T) {
|
|||||||
originalConfig := map[string]string{
|
originalConfig := map[string]string{
|
||||||
"url": "https://old.example.com/webhook",
|
"url": "https://old.example.com/webhook",
|
||||||
"secret": "old-secret",
|
"secret": "old-secret",
|
||||||
"branch_filter": "main",
|
|
||||||
"authorization_header": "Bearer old-token",
|
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
}
|
}
|
||||||
@@ -149,37 +147,18 @@ func TestUpdateConfigPreservation(t *testing.T) {
|
|||||||
expectedConfig: map[string]string{
|
expectedConfig: map[string]string{
|
||||||
"url": "https://new.example.com/webhook",
|
"url": "https://new.example.com/webhook",
|
||||||
"secret": "old-secret",
|
"secret": "old-secret",
|
||||||
"branch_filter": "main",
|
|
||||||
"authorization_header": "Bearer old-token",
|
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Update secret and auth header",
|
name: "Update secret",
|
||||||
updates: map[string]string{
|
updates: map[string]string{
|
||||||
"secret": "new-secret",
|
"secret": "new-secret",
|
||||||
"authorization_header": "X-Token: new-token",
|
|
||||||
},
|
},
|
||||||
expectedConfig: map[string]string{
|
expectedConfig: map[string]string{
|
||||||
"url": "https://old.example.com/webhook",
|
"url": "https://old.example.com/webhook",
|
||||||
"secret": "new-secret",
|
"secret": "new-secret",
|
||||||
"branch_filter": "main",
|
|
||||||
"authorization_header": "X-Token: new-token",
|
|
||||||
"http_method": "post",
|
|
||||||
"content_type": "json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Clear branch filter",
|
|
||||||
updates: map[string]string{
|
|
||||||
"branch_filter": "",
|
|
||||||
},
|
|
||||||
expectedConfig: map[string]string{
|
|
||||||
"url": "https://old.example.com/webhook",
|
|
||||||
"secret": "old-secret",
|
|
||||||
"branch_filter": "",
|
|
||||||
"authorization_header": "Bearer old-token",
|
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
},
|
},
|
||||||
@@ -190,8 +169,6 @@ func TestUpdateConfigPreservation(t *testing.T) {
|
|||||||
expectedConfig: map[string]string{
|
expectedConfig: map[string]string{
|
||||||
"url": "https://old.example.com/webhook",
|
"url": "https://old.example.com/webhook",
|
||||||
"secret": "old-secret",
|
"secret": "old-secret",
|
||||||
"branch_filter": "main",
|
|
||||||
"authorization_header": "Bearer old-token",
|
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
},
|
},
|
||||||
@@ -217,6 +194,61 @@ func TestUpdateConfigPreservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateBranchFilterAndAuthHeaderHandling(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
originalBranchFilter string
|
||||||
|
originalAuthHeader string
|
||||||
|
setBranchFilter bool
|
||||||
|
newBranchFilter string
|
||||||
|
setAuthorizationHeader bool
|
||||||
|
newAuthHeader string
|
||||||
|
expectedBranchFilter string
|
||||||
|
expectedAuthHeader string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Preserve values",
|
||||||
|
originalBranchFilter: "main",
|
||||||
|
originalAuthHeader: "Bearer old-token",
|
||||||
|
expectedBranchFilter: "main",
|
||||||
|
expectedAuthHeader: "Bearer old-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update branch filter",
|
||||||
|
originalBranchFilter: "main",
|
||||||
|
setBranchFilter: true,
|
||||||
|
newBranchFilter: "develop",
|
||||||
|
expectedBranchFilter: "develop",
|
||||||
|
expectedAuthHeader: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update authorization header",
|
||||||
|
originalAuthHeader: "Bearer old-token",
|
||||||
|
setAuthorizationHeader: true,
|
||||||
|
newAuthHeader: "X-Token: new-token",
|
||||||
|
expectedBranchFilter: "",
|
||||||
|
expectedAuthHeader: "X-Token: new-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
branchFilter := tt.originalBranchFilter
|
||||||
|
if tt.setBranchFilter {
|
||||||
|
branchFilter = tt.newBranchFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := tt.originalAuthHeader
|
||||||
|
if tt.setAuthorizationHeader {
|
||||||
|
authHeader = tt.newAuthHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedBranchFilter, branchFilter)
|
||||||
|
assert.Equal(t, tt.expectedAuthHeader, authHeader)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateEventsHandling(t *testing.T) {
|
func TestUpdateEventsHandling(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
172
docs/CLI.md
172
docs/CLI.md
@@ -483,6 +483,18 @@ Merge a pull request
|
|||||||
|
|
||||||
**--title, -t**="": Merge commit title
|
**--title, -t**="": Merge commit title
|
||||||
|
|
||||||
|
### reply
|
||||||
|
|
||||||
|
Reply to a pull request review comment
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
### review-comments, rc
|
### review-comments, rc
|
||||||
|
|
||||||
List review comments on a pull request
|
List review comments on a pull request
|
||||||
@@ -1043,12 +1055,12 @@ Create an organization
|
|||||||
|
|
||||||
**--description, -d**="":
|
**--description, -d**="":
|
||||||
|
|
||||||
|
**--full-name, -n**="":
|
||||||
|
|
||||||
**--location, -L**="":
|
**--location, -L**="":
|
||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
**--name, -n**="":
|
|
||||||
|
|
||||||
**--repo-admins-can-change-team-access**:
|
**--repo-admins-can-change-team-access**:
|
||||||
|
|
||||||
**--visibility, -v**="":
|
**--visibility, -v**="":
|
||||||
@@ -1065,7 +1077,7 @@ Delete users Organizations
|
|||||||
|
|
||||||
## repos, repo
|
## repos, repo
|
||||||
|
|
||||||
Show repository details
|
Manage repositories
|
||||||
|
|
||||||
**--fields, -f**="": Comma-separated list of fields to print. Available values:
|
**--fields, -f**="": Comma-separated list of fields to print. Available values:
|
||||||
description,forks,id,name,owner,stars,ssh,updated,url,permission,type
|
description,forks,id,name,owner,stars,ssh,updated,url,permission,type
|
||||||
@@ -1941,6 +1953,50 @@ Clone a repository locally
|
|||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
## ssh-keys, ssh-key
|
||||||
|
|
||||||
|
Manage SSH public keys
|
||||||
|
|
||||||
|
**--limit, --lm**="": specify limit of items per page (default: 30)
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--page, -p**="": specify page (default: 1)
|
||||||
|
|
||||||
|
### list, ls
|
||||||
|
|
||||||
|
List SSH keys
|
||||||
|
|
||||||
|
**--limit, --lm**="": specify limit of items per page (default: 30)
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--page, -p**="": specify page (default: 1)
|
||||||
|
|
||||||
|
### add
|
||||||
|
|
||||||
|
Add an SSH public key
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--title, -t**="": Title for the key (defaults to the filename without extension)
|
||||||
|
|
||||||
|
### delete, rm
|
||||||
|
|
||||||
|
Delete an SSH key
|
||||||
|
|
||||||
|
**--confirm, -y**: Confirm deletion (required)
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
## admin, a
|
## admin, a
|
||||||
|
|
||||||
Operations requiring admin access on the Gitea instance
|
Operations requiring admin access on the Gitea instance
|
||||||
@@ -1985,6 +2041,116 @@ List Users
|
|||||||
|
|
||||||
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
|
#### create, add, new
|
||||||
|
|
||||||
|
Create a new user
|
||||||
|
|
||||||
|
**--admin**: Make the user an administrator
|
||||||
|
|
||||||
|
**--email, -e**="": Email address for the new user (required)
|
||||||
|
|
||||||
|
**--full-name**="": Full name for the new user
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--no-must-change-password**: Don't require the user to change password on first login (default: password change required)
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--password, -p**="": Password for the new user (will prompt if not provided)
|
||||||
|
|
||||||
|
**--password-file**="": Read password from file
|
||||||
|
|
||||||
|
**--password-stdin**: Read password from stdin
|
||||||
|
|
||||||
|
**--prohibit-login**: Prohibit the user from logging in
|
||||||
|
|
||||||
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
|
**--restricted**: Make the user restricted
|
||||||
|
|
||||||
|
**--username, -u**="": Username for the new user (required)
|
||||||
|
|
||||||
|
**--visibility**="": Visibility of the user profile (public, limited, private) (default: "public")
|
||||||
|
|
||||||
|
#### edit, update, e, u
|
||||||
|
|
||||||
|
Edit a user
|
||||||
|
|
||||||
|
**--active**: Activate the user
|
||||||
|
|
||||||
|
**--admin**: Make the user an administrator
|
||||||
|
|
||||||
|
**--allow-create-organization**: Allow the user to create organizations
|
||||||
|
|
||||||
|
**--allow-git-hook**: Allow the user to use git hooks
|
||||||
|
|
||||||
|
**--allow-import-local**: Allow the user to import local repositories
|
||||||
|
|
||||||
|
**--allow-login**: Allow the user to log in
|
||||||
|
|
||||||
|
**--description**="": User description
|
||||||
|
|
||||||
|
**--email, -e**="": Email address
|
||||||
|
|
||||||
|
**--full-name**="": Full name
|
||||||
|
|
||||||
|
**--inactive**: Deactivate the user
|
||||||
|
|
||||||
|
**--location**="": Location
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--max-repo-creation**="": Maximum number of repositories the user can create (-1 for unlimited) (default: 0)
|
||||||
|
|
||||||
|
**--no-admin**: Remove administrator status
|
||||||
|
|
||||||
|
**--no-allow-create-organization**: Disallow the user from creating organizations
|
||||||
|
|
||||||
|
**--no-allow-git-hook**: Disallow the user from using git hooks
|
||||||
|
|
||||||
|
**--no-allow-import-local**: Disallow the user from importing local repositories
|
||||||
|
|
||||||
|
**--no-must-change-password**: Don't require the user to change password on next login (default: password change required)
|
||||||
|
|
||||||
|
**--no-restricted**: Remove restricted status
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--password**="": New password (use empty value --password="" to trigger interactive prompt)
|
||||||
|
|
||||||
|
**--password-file**="": Read password from file
|
||||||
|
|
||||||
|
**--password-stdin**: Read password from stdin
|
||||||
|
|
||||||
|
**--prohibit-login**: Prohibit the user from logging in
|
||||||
|
|
||||||
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
|
**--restricted**: Make the user restricted
|
||||||
|
|
||||||
|
**--visibility**="": Visibility of the user profile (public, limited, private)
|
||||||
|
|
||||||
|
**--website**="": Website URL
|
||||||
|
|
||||||
|
#### delete, rm, remove
|
||||||
|
|
||||||
|
Delete a user
|
||||||
|
|
||||||
|
**--confirm, -y**: confirm deletion without prompting
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
## api
|
## api
|
||||||
|
|
||||||
Make an authenticated API request
|
Make an authenticated API request
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -5,15 +5,15 @@ go 1.26
|
|||||||
require (
|
require (
|
||||||
charm.land/glamour/v2 v2.0.0
|
charm.land/glamour/v2 v2.0.0
|
||||||
charm.land/huh/v2 v2.0.3
|
charm.land/huh/v2 v2.0.3
|
||||||
charm.land/lipgloss/v2 v2.0.2
|
charm.land/lipgloss/v2 v2.0.3
|
||||||
code.gitea.io/gitea-vet v0.2.3
|
code.gitea.io/gitea-vet v0.2.3
|
||||||
code.gitea.io/sdk/gitea v0.24.1
|
code.gitea.io/sdk/gitea v0.25.0
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
|
||||||
github.com/adrg/xdg v0.5.3
|
github.com/adrg/xdg v0.5.3
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||||
github.com/enescakir/emoji v1.0.0
|
github.com/enescakir/emoji v1.0.0
|
||||||
github.com/go-authgate/sdk-go v0.6.1
|
github.com/go-authgate/sdk-go v0.10.0
|
||||||
github.com/go-git/go-git/v5 v5.17.2
|
github.com/go-git/go-git/v5 v5.18.0
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/olekukonko/tablewriter v1.1.4
|
github.com/olekukonko/tablewriter v1.1.4
|
||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||||
@@ -42,7 +42,7 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143 // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||||
|
|||||||
20
go.sum
20
go.sum
@@ -6,12 +6,12 @@ charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
|
|||||||
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
|
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
|
||||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||||
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||||
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
|
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
|
||||||
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
|
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
|
||||||
code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8=
|
code.gitea.io/sdk/gitea v0.25.0 h1:wSJlL0Qv+ODY2OdF0L7fwt86wgf1C/0g3xIXZ6eC5zI=
|
||||||
code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA=
|
code.gitea.io/sdk/gitea v0.25.0/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
|
||||||
@@ -55,8 +55,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
|
|||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
@@ -110,8 +110,8 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
|||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-authgate/sdk-go v0.6.1 h1:oQREINU63YckTRdJ+0VBmN6ewFSMXa0D862w8624/jw=
|
github.com/go-authgate/sdk-go v0.10.0 h1:MNcfV6XSPs63SWPDdLqoJ9CFiKlXIue1RmiAbTXDAEI=
|
||||||
github.com/go-authgate/sdk-go v0.6.1/go.mod h1:55PLAPuu8GDK0omOwG6lx4c+9/T6dJwZd8kecUueLEk=
|
github.com/go-authgate/sdk-go v0.10.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU=
|
||||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
@@ -120,8 +120,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz
|
|||||||
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
|
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
|
github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM=
|
||||||
github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
|
github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
|
||||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
|||||||
13
modules/config/testtools.go
Normal file
13
modules/config/testtools.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build testtools
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AcquireConfigLockForTesting exposes the internal lock helper to integration tests.
|
||||||
|
func AcquireConfigLockForTesting(lockPath string, timeout time.Duration) (func() error, error) {
|
||||||
|
return acquireConfigLock(lockPath, timeout)
|
||||||
|
}
|
||||||
@@ -83,6 +83,8 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
}
|
}
|
||||||
if repoFlagPathExists {
|
if repoFlagPathExists {
|
||||||
repoPath = repoFlag
|
repoPath = repoFlag
|
||||||
|
} else {
|
||||||
|
c.RepoSlug = repoFlag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +92,6 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
remoteFlag = config.GetPreferences().FlagDefaults.Remote
|
remoteFlag = config.GetPreferences().FlagDefaults.Remote
|
||||||
}
|
}
|
||||||
|
|
||||||
if repoPath == "" {
|
|
||||||
if repoPath, err = os.Getwd(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create env login before repo context detection so it participates in remote URL matching
|
// Create env login before repo context detection so it participates in remote URL matching
|
||||||
var extraLogins []config.Login
|
var extraLogins []config.Login
|
||||||
envLogin := GetLoginByEnvVar()
|
envLogin := GetLoginByEnvVar()
|
||||||
@@ -108,6 +104,13 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
|
|
||||||
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
|
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
|
||||||
// otherwise attempt PWD. if no repo is found, continue with default login
|
// otherwise attempt PWD. if no repo is found, continue with default login
|
||||||
|
if c.RepoSlug == "" {
|
||||||
|
if repoPath == "" {
|
||||||
|
if repoPath, err = os.Getwd(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
|
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
|
||||||
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
|
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
|
||||||
// we can deal with that, commands needing the optional values use ctx.Ensure()
|
// we can deal with that, commands needing the optional values use ctx.Ensure()
|
||||||
@@ -115,10 +118,6 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(repoFlag) != 0 && !repoFlagPathExists {
|
|
||||||
// if repoFlag is not a valid path, use it to override repoSlug
|
|
||||||
c.RepoSlug = repoFlag
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If env vars are set, always use the env login (but repo slug was already
|
// If env vars are set, always use the env login (but repo slug was already
|
||||||
|
|||||||
@@ -180,19 +180,25 @@ func fetchIssueSelectables(login *config.Login, owner, repo string, done chan is
|
|||||||
r.MilestoneList[i] = m.Title
|
r.MilestoneList[i] = m.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
labels, _, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
|
r.LabelMap = make(map[string]int64)
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
r.LabelList = make([]string, 0)
|
||||||
|
for page := 1; ; {
|
||||||
|
labels, resp, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = err
|
r.Err = err
|
||||||
done <- r
|
done <- r
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.LabelMap = make(map[string]int64)
|
for _, l := range labels {
|
||||||
r.LabelList = make([]string, len(labels))
|
|
||||||
for i, l := range labels {
|
|
||||||
r.LabelMap[l.Name] = l.ID
|
r.LabelMap[l.Name] = l.ID
|
||||||
r.LabelList[i] = l.Name
|
r.LabelList = append(r.LabelList, l.Name)
|
||||||
|
}
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
}
|
}
|
||||||
|
|
||||||
done <- r
|
done <- r
|
||||||
|
|||||||
44
modules/print/sshkey.go
Normal file
44
modules/print/sshkey.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package print
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHKeysList prints a table of SSH public keys
|
||||||
|
func SSHKeysList(keys []*gitea.PublicKey, output string) error {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
fmt.Printf("No SSH keys found\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t := tableWithHeader(
|
||||||
|
"ID",
|
||||||
|
"Title",
|
||||||
|
"Fingerprint",
|
||||||
|
"KeyType",
|
||||||
|
"ReadOnly",
|
||||||
|
"Created",
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
readOnly := "false"
|
||||||
|
if k.ReadOnly {
|
||||||
|
readOnly = "true"
|
||||||
|
}
|
||||||
|
t.addRow(
|
||||||
|
fmt.Sprintf("%d", k.ID),
|
||||||
|
k.Title,
|
||||||
|
k.Fingerprint,
|
||||||
|
k.KeyType,
|
||||||
|
readOnly,
|
||||||
|
FormatTime(k.Created, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.print(output)
|
||||||
|
}
|
||||||
@@ -67,13 +67,21 @@ func WebhookDetails(hook *gitea.Hook) {
|
|||||||
if method, ok := hook.Config["http_method"]; ok {
|
if method, ok := hook.Config["http_method"]; ok {
|
||||||
fmt.Printf("- **HTTP Method**: %s\n", method)
|
fmt.Printf("- **HTTP Method**: %s\n", method)
|
||||||
}
|
}
|
||||||
if branchFilter, ok := hook.Config["branch_filter"]; ok && branchFilter != "" {
|
branchFilter := hook.BranchFilter
|
||||||
|
if branchFilter == "" {
|
||||||
|
branchFilter = hook.Config["branch_filter"]
|
||||||
|
}
|
||||||
|
if branchFilter != "" {
|
||||||
fmt.Printf("- **Branch Filter**: %s\n", branchFilter)
|
fmt.Printf("- **Branch Filter**: %s\n", branchFilter)
|
||||||
}
|
}
|
||||||
if _, hasSecret := hook.Config["secret"]; hasSecret {
|
if _, hasSecret := hook.Config["secret"]; hasSecret {
|
||||||
fmt.Printf("- **Secret**: (configured)\n")
|
fmt.Printf("- **Secret**: (configured)\n")
|
||||||
}
|
}
|
||||||
if _, hasAuth := hook.Config["authorization_header"]; hasAuth {
|
hasAuth := hook.AuthorizationHeader != ""
|
||||||
|
if !hasAuth {
|
||||||
|
_, hasAuth = hook.Config["authorization_header"]
|
||||||
|
}
|
||||||
|
if hasAuth {
|
||||||
fmt.Printf("- **Authorization Header**: (configured)\n")
|
fmt.Printf("- **Authorization Header**: (configured)\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ func TestWebhookDetails(t *testing.T) {
|
|||||||
"url": "https://example.com/webhook",
|
"url": "https://example.com/webhook",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"branch_filter": "main,develop",
|
|
||||||
"secret": "secret-value",
|
"secret": "secret-value",
|
||||||
"authorization_header": "Bearer token123",
|
|
||||||
},
|
},
|
||||||
|
BranchFilter: "main,develop",
|
||||||
|
AuthorizationHeader: "Bearer token123",
|
||||||
Events: []string{"push", "pull_request", "issues"},
|
Events: []string{"push", "pull_request", "issues"},
|
||||||
Active: true,
|
Active: true,
|
||||||
Created: now.Add(-24 * time.Hour),
|
Created: now.Add(-24 * time.Hour),
|
||||||
@@ -240,14 +240,12 @@ func TestWebhookConfigHandling(t *testing.T) {
|
|||||||
config: map[string]string{
|
config: map[string]string{
|
||||||
"url": "https://example.com/webhook",
|
"url": "https://example.com/webhook",
|
||||||
"secret": "my-secret",
|
"secret": "my-secret",
|
||||||
"authorization_header": "Bearer token",
|
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"branch_filter": "main",
|
|
||||||
},
|
},
|
||||||
expectedURL: "https://example.com/webhook",
|
expectedURL: "https://example.com/webhook",
|
||||||
hasSecret: true,
|
hasSecret: true,
|
||||||
hasAuthHeader: true,
|
hasAuthHeader: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Config with minimal fields",
|
name: "Config with minimal fields",
|
||||||
@@ -344,10 +342,10 @@ func TestWebhookDetailsFormatting(t *testing.T) {
|
|||||||
"url": "https://example.com/webhook",
|
"url": "https://example.com/webhook",
|
||||||
"content_type": "json",
|
"content_type": "json",
|
||||||
"http_method": "post",
|
"http_method": "post",
|
||||||
"branch_filter": "main,develop",
|
|
||||||
"secret": "secret-value",
|
"secret": "secret-value",
|
||||||
"authorization_header": "Bearer token123",
|
|
||||||
},
|
},
|
||||||
|
BranchFilter: "main,develop",
|
||||||
|
AuthorizationHeader: "Bearer token123",
|
||||||
Events: []string{"push", "pull_request", "issues"},
|
Events: []string{"push", "pull_request", "issues"},
|
||||||
Active: true,
|
Active: true,
|
||||||
Created: now.Add(-24 * time.Hour),
|
Created: now.Add(-24 * time.Hour),
|
||||||
@@ -379,8 +377,8 @@ func TestWebhookDetailsFormatting(t *testing.T) {
|
|||||||
assert.Equal(t, "https://example.com/webhook", hook.Config["url"])
|
assert.Equal(t, "https://example.com/webhook", hook.Config["url"])
|
||||||
assert.Equal(t, "json", hook.Config["content_type"])
|
assert.Equal(t, "json", hook.Config["content_type"])
|
||||||
assert.Equal(t, "post", hook.Config["http_method"])
|
assert.Equal(t, "post", hook.Config["http_method"])
|
||||||
assert.Equal(t, "main,develop", hook.Config["branch_filter"])
|
assert.Equal(t, "main,develop", hook.BranchFilter)
|
||||||
assert.Contains(t, hook.Config, "secret")
|
assert.Contains(t, hook.Config, "secret")
|
||||||
assert.Contains(t, hook.Config, "authorization_header")
|
assert.Equal(t, "Bearer token123", hook.AuthorizationHeader)
|
||||||
assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events)
|
assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
// ResolveLabelNames returns a list of label IDs for a given list of label names
|
// ResolveLabelNames returns a list of label IDs for a given list of label names
|
||||||
func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
|
func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
|
||||||
labelIDs := make([]int64, 0, len(labelNames))
|
labelIDs := make([]int64, 0, len(labelNames))
|
||||||
labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
|
page := 1
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
for {
|
||||||
|
labels, resp, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -24,6 +26,11 @@ func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []st
|
|||||||
labelIDs = append(labelIDs, l.ID)
|
labelIDs = append(labelIDs, l.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
|
}
|
||||||
return labelIDs, nil
|
return labelIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,12 +166,20 @@ func generateToken(login config.Login, user, pass, otp, scopes string) (string,
|
|||||||
}
|
}
|
||||||
client := login.Client(opts...)
|
client := login.Client(opts...)
|
||||||
|
|
||||||
tl, _, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{
|
var tl []*gitea.AccessToken
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
for page := 1; ; {
|
||||||
|
page_tokens, resp, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
tl = append(tl, page_tokens...)
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
|
}
|
||||||
host, _ := os.Hostname()
|
host, _ := os.Hostname()
|
||||||
tokenName := host + "-tea"
|
tokenName := host + "-tea"
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,23 @@ import (
|
|||||||
// a matching private key in ~/.ssh/. If no match is found, path is empty.
|
// a matching private key in ~/.ssh/. If no match is found, path is empty.
|
||||||
func findSSHKey(client *gitea.Client) (string, error) {
|
func findSSHKey(client *gitea.Client) (string, error) {
|
||||||
// get keys registered on gitea instance
|
// get keys registered on gitea instance
|
||||||
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
|
var keys []*gitea.PublicKey
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
for page := 1; ; {
|
||||||
|
page_keys, resp, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil || len(keys) == 0 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
keys = append(keys, page_keys...)
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// enumerate ~/.ssh/*.pub files
|
// enumerate ~/.ssh/*.pub files
|
||||||
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
|
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ import (
|
|||||||
func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) {
|
func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) {
|
||||||
c := ctx.Login.Client()
|
c := ctx.Login.Client()
|
||||||
|
|
||||||
reviews, _, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
|
var reviews []*gitea.PullReview
|
||||||
ListOptions: gitea.ListOptions{Page: -1},
|
for page := 1; ; {
|
||||||
|
page_reviews, resp, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
reviews = append(reviews, page_reviews...)
|
||||||
|
if resp == nil || resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.NextPage
|
||||||
|
}
|
||||||
|
|
||||||
var allComments []*gitea.PullReviewComment
|
var allComments []*gitea.PullReviewComment
|
||||||
for _, review := range reviews {
|
for _, review := range reviews {
|
||||||
@@ -46,6 +54,21 @@ func ResolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplyToPullReviewComment replies to a review comment on a pull request.
|
||||||
|
func ReplyToPullReviewComment(ctx *context.TeaContext, idx, commentID int64, body string) error {
|
||||||
|
c := ctx.Login.Client()
|
||||||
|
|
||||||
|
comment, _, err := c.CreatePullReviewCommentReply(ctx.Owner, ctx.Repo, idx, commentID, gitea.CreatePullReviewCommentReplyOptions{
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(comment.HTMLURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnresolvePullReviewComment unresolves a review comment
|
// UnresolvePullReviewComment unresolves a review comment
|
||||||
func UnresolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
|
func UnresolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
|
||||||
c := ctx.Login.Client()
|
c := ctx.Login.Client()
|
||||||
|
|||||||
10
tests/README.md
Normal file
10
tests/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
This directory contains integration tests that exercise tea against external services or external executables.
|
||||||
|
|
||||||
|
- Unit tests stay next to the packages they cover.
|
||||||
|
- Integration tests live under `tests/` so they can be run separately.
|
||||||
|
|
||||||
|
Common targets:
|
||||||
|
|
||||||
|
- `make unit-test`
|
||||||
|
- `make integration-test`
|
||||||
|
- `make test`
|
||||||
139
tests/integration/admin_users_test.go
Normal file
139
tests/integration/admin_users_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
teacmd "code.gitea.io/tea/cmd"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runAdminCommand(t *testing.T, args []string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
adminCmd := teacmd.CmdAdmin
|
||||||
|
return adminCmd.Run(context.Background(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAdminTestUser(t *testing.T, client *gitea.Client, username, password string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mustChangePassword := false
|
||||||
|
user, _, err := client.AdminCreateUser(gitea.CreateUserOption{
|
||||||
|
LoginName: username,
|
||||||
|
Username: username,
|
||||||
|
Email: username + "@example.com",
|
||||||
|
Password: password,
|
||||||
|
MustChangePassword: &mustChangePassword,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, username, user.UserName)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, err := client.AdminDeleteUser(username); err != nil {
|
||||||
|
t.Logf("failed to delete integration test user %q: %v", username, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUsersCreateRequiresEmail(t *testing.T) {
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
|
||||||
|
err := runAdminCommand(t, []string{
|
||||||
|
"admin", "users", "create",
|
||||||
|
"--username", fmt.Sprintf("create-no-email-%d", time.Now().UnixNano()),
|
||||||
|
"--password", "secret123",
|
||||||
|
"--login", login.Name,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "email")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUsersCreateAndDelete(t *testing.T) {
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
client := login.Client()
|
||||||
|
username := fmt.Sprintf("tea-admin-create-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
err := runAdminCommand(t, []string{
|
||||||
|
"admin", "users", "create",
|
||||||
|
"--username", username,
|
||||||
|
"--email", username + "@example.com",
|
||||||
|
"--password", "secret123",
|
||||||
|
"--admin",
|
||||||
|
"--prohibit-login",
|
||||||
|
"--visibility", "limited",
|
||||||
|
"--login", login.Name,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
createdUser, _, err := client.GetUserInfo(username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, username, createdUser.UserName)
|
||||||
|
assert.Equal(t, username+"@example.com", createdUser.Email)
|
||||||
|
assert.True(t, createdUser.IsAdmin)
|
||||||
|
assert.True(t, createdUser.ProhibitLogin)
|
||||||
|
assert.Equal(t, gitea.VisibleTypeLimited, createdUser.Visibility)
|
||||||
|
|
||||||
|
err = runAdminCommand(t, []string{
|
||||||
|
"admin", "users", "delete", username,
|
||||||
|
"--confirm",
|
||||||
|
"--login", login.Name,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, _, err = client.GetUserInfo(username)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUsersEdit(t *testing.T) {
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
client := login.Client()
|
||||||
|
username := fmt.Sprintf("tea-admin-edit-%d", time.Now().UnixNano())
|
||||||
|
oldPassword := "old-secret"
|
||||||
|
newPassword := "new-secret"
|
||||||
|
createAdminTestUser(t, client, username, oldPassword)
|
||||||
|
|
||||||
|
passwordFile := filepath.Join(t.TempDir(), "password.txt")
|
||||||
|
require.NoError(t, os.WriteFile(passwordFile, []byte(newPassword+"\n"), 0o600))
|
||||||
|
|
||||||
|
err := runAdminCommand(t, []string{
|
||||||
|
"admin", "users", "edit", username,
|
||||||
|
"--email", username + "+new@example.com",
|
||||||
|
"--full-name", "Tea Integration",
|
||||||
|
"--restricted",
|
||||||
|
"--password-file", passwordFile,
|
||||||
|
"--no-must-change-password",
|
||||||
|
"--visibility", "private",
|
||||||
|
"--login", login.Name,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedUser, _, err := client.GetUserInfo(username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, username+"+new@example.com", updatedUser.Email)
|
||||||
|
assert.Equal(t, "Tea Integration", updatedUser.FullName)
|
||||||
|
assert.True(t, updatedUser.IsActive)
|
||||||
|
assert.True(t, updatedUser.Restricted)
|
||||||
|
assert.False(t, updatedUser.ProhibitLogin)
|
||||||
|
assert.Equal(t, gitea.VisibleTypePrivate, updatedUser.Visibility)
|
||||||
|
|
||||||
|
passwordClient, err := gitea.NewClient(
|
||||||
|
integrationGiteaURL,
|
||||||
|
gitea.SetBasicAuth(username, newPassword),
|
||||||
|
gitea.SetGiteaVersion(""),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
me, _, err := passwordClient.GetMyUserInfo()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, username, me.UserName)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
//go:build unix
|
//go:build unix
|
||||||
|
|
||||||
package config
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,10 +12,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/modules/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigLock_CrossProcess(t *testing.T) {
|
func TestConfigLock_CrossProcess(t *testing.T) {
|
||||||
// Create a temp directory for test
|
|
||||||
tmpDir, err := os.MkdirTemp("", "tea-lock-test")
|
tmpDir, err := os.MkdirTemp("", "tea-lock-test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create temp dir: %v", err)
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
@@ -24,15 +25,16 @@ func TestConfigLock_CrossProcess(t *testing.T) {
|
|||||||
|
|
||||||
lockPath := filepath.Join(tmpDir, "config.yml.lock")
|
lockPath := filepath.Join(tmpDir, "config.yml.lock")
|
||||||
|
|
||||||
// Acquire lock in main process
|
unlock, err := config.AcquireConfigLockForTesting(lockPath, 5*time.Second)
|
||||||
unlock, err := acquireConfigLock(lockPath, 5*time.Second)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to acquire lock: %v", err)
|
t.Fatalf("failed to acquire lock: %v", err)
|
||||||
}
|
}
|
||||||
defer unlock()
|
defer func() {
|
||||||
|
if err := unlock(); err != nil {
|
||||||
|
t.Fatalf("failed to release lock: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Spawn a subprocess that tries to acquire the same lock
|
|
||||||
// The subprocess should fail to acquire within timeout
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -48,19 +50,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Try non-blocking lock
|
|
||||||
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Lock is held - expected behavior
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
// Lock was acquired - unexpected
|
|
||||||
syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
`, lockPath)
|
`, lockPath)
|
||||||
|
|
||||||
// Write and run the test script
|
|
||||||
scriptPath := filepath.Join(tmpDir, "locktest.go")
|
scriptPath := filepath.Join(tmpDir, "locktest.go")
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
||||||
t.Fatalf("failed to write test script: %v", err)
|
t.Fatalf("failed to write test script: %v", err)
|
||||||
@@ -78,5 +77,4 @@ func main() {
|
|||||||
t.Errorf("subprocess execution failed: %v", err)
|
t.Errorf("subprocess execution failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Exit code 0 means lock was properly held - success
|
|
||||||
}
|
}
|
||||||
59
tests/integration/context_init_test.go
Normal file
59
tests/integration/context_init_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/tea/modules/config"
|
||||||
|
teacontext "code.gitea.io/tea/modules/context"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitCommand_WithRepoSlugSkipsLocalRepoDetection(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{
|
||||||
|
Logins: []config.Login{{
|
||||||
|
Name: "test-login",
|
||||||
|
URL: "https://gitea.example.com",
|
||||||
|
Token: "token",
|
||||||
|
User: "login-user",
|
||||||
|
Default: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "init", "--object-format=sha256", tmpDir)
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
require.NoError(t, cmd.Run())
|
||||||
|
|
||||||
|
oldWd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(tmpDir))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, os.Chdir(oldWd))
|
||||||
|
})
|
||||||
|
|
||||||
|
cliCmd := cli.Command{
|
||||||
|
Name: "branches",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{Name: "login"},
|
||||||
|
&cli.StringFlag{Name: "repo"},
|
||||||
|
&cli.StringFlag{Name: "remote"},
|
||||||
|
&cli.StringFlag{Name: "output"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, cliCmd.Set("repo", "owner/repo"))
|
||||||
|
|
||||||
|
ctx, err := teacontext.InitCommand(&cliCmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "owner", ctx.Owner)
|
||||||
|
require.Equal(t, "repo", ctx.Repo)
|
||||||
|
require.Equal(t, "owner/repo", ctx.RepoSlug)
|
||||||
|
require.Nil(t, ctx.LocalRepo)
|
||||||
|
require.NotNil(t, ctx.Login)
|
||||||
|
require.Equal(t, "test-login", ctx.Login.Name)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package git
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
teagit "code.gitea.io/tea/modules/git"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRepoFromPath_Worktree(t *testing.T) {
|
func TestRepoFromPath_Worktree(t *testing.T) {
|
||||||
// Create a temporary directory for test
|
|
||||||
tmpDir, err := os.MkdirTemp("", "tea-worktree-test-*")
|
tmpDir, err := os.MkdirTemp("", "tea-worktree-test-*")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
@@ -21,21 +21,17 @@ func TestRepoFromPath_Worktree(t *testing.T) {
|
|||||||
mainRepoPath := filepath.Join(tmpDir, "main-repo")
|
mainRepoPath := filepath.Join(tmpDir, "main-repo")
|
||||||
worktreePath := filepath.Join(tmpDir, "worktree")
|
worktreePath := filepath.Join(tmpDir, "worktree")
|
||||||
|
|
||||||
// Initialize main repository
|
|
||||||
cmd := exec.Command("git", "init", mainRepoPath)
|
cmd := exec.Command("git", "init", mainRepoPath)
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
|
|
||||||
// Configure git for the test
|
|
||||||
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.email", "test@example.com")
|
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.email", "test@example.com")
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
|
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
|
|
||||||
// Add a remote to the main repository
|
|
||||||
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
|
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
|
|
||||||
// Create an initial commit (required for worktree)
|
|
||||||
readmePath := filepath.Join(mainRepoPath, "README.md")
|
readmePath := filepath.Join(mainRepoPath, "README.md")
|
||||||
err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644)
|
err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -44,19 +40,14 @@ func TestRepoFromPath_Worktree(t *testing.T) {
|
|||||||
cmd = exec.Command("git", "-C", mainRepoPath, "commit", "-m", "Initial commit")
|
cmd = exec.Command("git", "-C", mainRepoPath, "commit", "-m", "Initial commit")
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
|
|
||||||
// Create a worktree
|
|
||||||
cmd = exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
|
cmd = exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
|
|
||||||
// Test: Open repository from worktree path
|
repo, err := teagit.RepoFromPath(worktreePath)
|
||||||
repo, err := RepoFromPath(worktreePath)
|
|
||||||
assert.NoError(t, err, "Should be able to open worktree")
|
assert.NoError(t, err, "Should be able to open worktree")
|
||||||
|
|
||||||
// Test: Read config from worktree (should read from main repo's config)
|
|
||||||
config, err := repo.Config()
|
config, err := repo.Config()
|
||||||
assert.NoError(t, err, "Should be able to read config")
|
assert.NoError(t, err, "Should be able to read config")
|
||||||
|
|
||||||
// Verify that remotes are accessible from worktree
|
|
||||||
assert.NotEmpty(t, config.Remotes, "Should be able to read remotes from worktree")
|
assert.NotEmpty(t, config.Remotes, "Should be able to read remotes from worktree")
|
||||||
assert.Contains(t, config.Remotes, "origin", "Should have origin remote")
|
assert.Contains(t, config.Remotes, "origin", "Should have origin remote")
|
||||||
assert.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0], "Should have correct remote URL")
|
assert.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0], "Should have correct remote URL")
|
||||||
104
tests/integration/helpers_test.go
Normal file
104
tests/integration/helpers_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"code.gitea.io/tea/modules/config"
|
||||||
|
"code.gitea.io/tea/modules/task"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
integrationGiteaURL string
|
||||||
|
integrationUsername string
|
||||||
|
integrationPassword string
|
||||||
|
integrationToken string
|
||||||
|
integrationTokenID int64
|
||||||
|
integrationSetupErr error
|
||||||
|
integrationClient *gitea.Client
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
integrationGiteaURL = os.Getenv("GITEA_TEA_TEST_URL")
|
||||||
|
integrationUsername = os.Getenv("GITEA_TEA_TEST_USERNAME")
|
||||||
|
integrationPassword = os.Getenv("GITEA_TEA_TEST_PASSWORD")
|
||||||
|
|
||||||
|
if integrationGiteaURL != "" {
|
||||||
|
if integrationUsername == "" || integrationPassword == "" {
|
||||||
|
integrationSetupErr = fmt.Errorf("GITEA_TEA_TEST_USERNAME and GITEA_TEA_TEST_PASSWORD are required for integration tests")
|
||||||
|
} else {
|
||||||
|
integrationClient, integrationSetupErr = gitea.NewClient(
|
||||||
|
integrationGiteaURL,
|
||||||
|
gitea.SetBasicAuth(integrationUsername, integrationPassword),
|
||||||
|
gitea.SetGiteaVersion(""),
|
||||||
|
)
|
||||||
|
if integrationSetupErr == nil {
|
||||||
|
tokenName := fmt.Sprintf("tea-integration-%d", time.Now().UnixNano())
|
||||||
|
var token *gitea.AccessToken
|
||||||
|
token, _, integrationSetupErr = integrationClient.CreateAccessToken(gitea.CreateAccessTokenOption{
|
||||||
|
Name: tokenName,
|
||||||
|
Scopes: []gitea.AccessTokenScope{gitea.AccessTokenScopeAll},
|
||||||
|
})
|
||||||
|
if integrationSetupErr == nil {
|
||||||
|
integrationToken = token.Token
|
||||||
|
integrationTokenID = token.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := m.Run()
|
||||||
|
|
||||||
|
if integrationClient != nil && integrationTokenID != 0 {
|
||||||
|
if _, err := integrationClient.DeleteAccessToken(integrationTokenID); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "failed to delete integration token %d: %v\n", integrationTokenID, err)
|
||||||
|
if exitCode == 0 {
|
||||||
|
exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func useTempConfigPath(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
config.SetConfigPathForTesting(configPath)
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{})
|
||||||
|
config.SetConfigPathForTesting("")
|
||||||
|
})
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func createIntegrationLogin(t *testing.T) *config.Login {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_ = useTempConfigPath(t)
|
||||||
|
if integrationGiteaURL == "" {
|
||||||
|
t.Skip("GITEA_TEA_TEST_URL is not set, skipping integration test")
|
||||||
|
}
|
||||||
|
require.NoError(t, integrationSetupErr)
|
||||||
|
|
||||||
|
require.NotEmpty(t, integrationToken, "integration token setup failed")
|
||||||
|
|
||||||
|
require.NoError(t, task.CreateLogin("integration", integrationToken, "", "", "", "", "", integrationGiteaURL, "", "", true, false, false, false))
|
||||||
|
|
||||||
|
login, err := config.GetLoginByName("integration")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, login)
|
||||||
|
|
||||||
|
return login
|
||||||
|
}
|
||||||
109
tests/integration/pulls_reply_test.go
Normal file
109
tests/integration/pulls_reply_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"code.gitea.io/tea/cmd/pulls"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPullsReply(t *testing.T) {
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
client := login.Client()
|
||||||
|
timestamp := time.Now().UnixNano()
|
||||||
|
repoName := fmt.Sprintf("tea-pr-reply-%d", timestamp)
|
||||||
|
featureBranch := fmt.Sprintf("reply-test-%d", timestamp)
|
||||||
|
replyBody := fmt.Sprintf("Thanks for the review %d", timestamp)
|
||||||
|
|
||||||
|
repo, _, err := client.CreateRepo(gitea.CreateRepoOption{
|
||||||
|
Name: repoName,
|
||||||
|
AutoInit: true,
|
||||||
|
DefaultBranch: "main",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, delErr := client.DeleteRepo(login.User, repoName); delErr != nil {
|
||||||
|
t.Logf("failed to delete integration test repo %q: %v", repoName, delErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
baseBranch := repo.DefaultBranch
|
||||||
|
if baseBranch == "" {
|
||||||
|
baseBranch = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = client.CreateFile(login.User, repoName, "review.txt", gitea.CreateFileOptions{
|
||||||
|
FileOptions: gitea.FileOptions{
|
||||||
|
Message: "add review target",
|
||||||
|
BranchName: baseBranch,
|
||||||
|
NewBranchName: featureBranch,
|
||||||
|
},
|
||||||
|
Content: base64.StdEncoding.EncodeToString([]byte("line for review\n")),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pr, _, err := client.CreatePullRequest(login.User, repoName, gitea.CreatePullRequestOption{
|
||||||
|
Base: baseBranch,
|
||||||
|
Head: featureBranch,
|
||||||
|
Title: "Integration test for pr reply",
|
||||||
|
Body: "Adds a file so we can reply to a review comment.",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
review, _, err := client.CreatePullReview(login.User, repoName, pr.Index, gitea.CreatePullReviewOptions{
|
||||||
|
State: gitea.ReviewStateComment,
|
||||||
|
Body: "Please take another look.",
|
||||||
|
Comments: []gitea.CreatePullReviewComment{{
|
||||||
|
Path: "review.txt",
|
||||||
|
Body: "Could you clarify this line?",
|
||||||
|
NewLineNum: 1,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
comments, _, err := client.ListPullReviewComments(login.User, repoName, pr.Index, review.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, comments, 1)
|
||||||
|
|
||||||
|
pullsCmd := &cli.Command{
|
||||||
|
Name: "pulls",
|
||||||
|
Commands: []*cli.Command{&pulls.CmdPullsReply},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pullsCmd.Run(context.Background(), []string{
|
||||||
|
"pulls",
|
||||||
|
"reply",
|
||||||
|
strconv.FormatInt(pr.Index, 10),
|
||||||
|
strconv.FormatInt(comments[0].ID, 10),
|
||||||
|
replyBody,
|
||||||
|
"--login",
|
||||||
|
login.Name,
|
||||||
|
"--repo",
|
||||||
|
repo.FullName,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
reviewComments, _, listErr := client.ListPullReviewComments(login.User, repoName, pr.Index, review.ID)
|
||||||
|
if listErr != nil {
|
||||||
|
t.Logf("failed to list review comments: %v", listErr)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, reviewComment := range reviewComments {
|
||||||
|
if reviewComment.Body == replyBody && reviewComment.ReviewID == review.ID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, 10*time.Second, 500*time.Millisecond)
|
||||||
|
}
|
||||||
@@ -1,28 +1,26 @@
|
|||||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package repos
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"code.gitea.io/tea/modules/task"
|
"code.gitea.io/tea/cmd/repos"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateRepoObjectFormat(t *testing.T) {
|
func TestCreateRepoObjectFormat(t *testing.T) {
|
||||||
giteaURL := os.Getenv("GITEA_TEA_TEST_URL")
|
login := createIntegrationLogin(t)
|
||||||
if giteaURL == "" {
|
client := login.Client()
|
||||||
t.Skip("GITEA_TEA_TEST_URL is not set, skipping test")
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.Now().Unix()
|
timestamp := time.Now().Unix()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
@@ -56,22 +54,15 @@ func TestCreateRepoObjectFormat(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaUserName := os.Getenv("GITEA_TEA_TEST_USERNAME")
|
|
||||||
giteaUserPasword := os.Getenv("GITEA_TEA_TEST_PASSWORD")
|
|
||||||
|
|
||||||
err := task.CreateLogin("test", "", giteaUserName, giteaUserPasword, "", "", "", giteaURL, "", "", true, false, false, false)
|
|
||||||
if err != nil && err.Error() != "login name 'test' has already been used" {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
reposCmd := &cli.Command{
|
reposCmd := &cli.Command{
|
||||||
Name: "repos",
|
Name: "repos",
|
||||||
Commands: []*cli.Command{&CmdRepoCreate},
|
Commands: []*cli.Command{&repos.CmdRepoCreate},
|
||||||
}
|
}
|
||||||
tt.args = append(tt.args, "--login", "test")
|
|
||||||
args := append([]string{"repos", "create"}, tt.args...)
|
args := append([]string{"repos", "create"}, tt.args...)
|
||||||
|
args = append(args, "--login", login.Name)
|
||||||
|
|
||||||
err := reposCmd.Run(context.Background(), args)
|
err := reposCmd.Run(context.Background(), args)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
@@ -82,7 +73,12 @@ func TestCreateRepoObjectFormat(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, delErr := client.DeleteRepo(login.User, tt.wantOpts.Name); delErr != nil {
|
||||||
|
t.Logf("failed to delete integration test repo %q: %v", tt.wantOpts.Name, delErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
115
tests/integration/sshkeys_test.go
Normal file
115
tests/integration/sshkeys_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
sshkeyscmd "code.gitea.io/tea/cmd/sshkeys"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateTestPublicKey creates a fresh ed25519 keypair and returns a temp
|
||||||
|
// file path containing the public key in authorized_keys format.
|
||||||
|
func generateTestPublicKey(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sshPub, err := ssh.NewPublicKey(priv.Public())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pubKeyStr := fmt.Sprintf("ssh-ed25519 %s tea-test-key", base64.StdEncoding.EncodeToString(sshPub.Marshal()))
|
||||||
|
|
||||||
|
f, err := os.CreateTemp(t.TempDir(), "test-*.pub")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = f.WriteString(pubKeyStr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
return f.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshKeysCmd() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "ssh-keys",
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
&sshkeyscmd.CmdSSHKeyList,
|
||||||
|
&sshkeyscmd.CmdSSHKeyAdd,
|
||||||
|
&sshkeyscmd.CmdSSHKeyDelete,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHKeyAddAndDelete(t *testing.T) {
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
pubKeyFile := generateTestPublicKey(t)
|
||||||
|
keyTitle := fmt.Sprintf("tea-test-%d", time.Now().Unix())
|
||||||
|
|
||||||
|
cmd := sshKeysCmd()
|
||||||
|
client := login.Client()
|
||||||
|
|
||||||
|
err := cmd.Run(context.Background(), []string{
|
||||||
|
"ssh-keys", "add", pubKeyFile,
|
||||||
|
"--title", keyTitle,
|
||||||
|
"--login", login.Name,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: -1},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var addedKey *gitea.PublicKey
|
||||||
|
for _, key := range keys {
|
||||||
|
if key.Title == keyTitle {
|
||||||
|
addedKey = key
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, addedKey, "added key not found in key list")
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
client.DeletePublicKey(addedKey.ID) //nolint:errcheck
|
||||||
|
})
|
||||||
|
|
||||||
|
err = cmd.Run(context.Background(), []string{
|
||||||
|
"ssh-keys", "delete", strconv.FormatInt(addedKey.ID, 10),
|
||||||
|
"--confirm",
|
||||||
|
"--login", login.Name,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, resp, err := client.GetPublicKey(addedKey.ID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
if assert.NotNil(t, resp) {
|
||||||
|
assert.Equal(t, 404, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHKeyList(t *testing.T) {
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
|
||||||
|
cmd := sshKeysCmd()
|
||||||
|
err := cmd.Run(context.Background(), []string{
|
||||||
|
"ssh-keys", "list",
|
||||||
|
"--login", login.Name,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user