feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)

- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated.
- **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes.

## Breaking changes

This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement.

## Issues

fixes #1118
fixes #1116
fixes #1115
fixes #1094
fixes #1091
fixes #1073
fixes #1052
fixes #1032
fixes #1023
fixes #966
fixes #908
fixes #833
fixes #831
fixes #817
fixes #804
fixes #779
fixes #770
fixes #737
fixes #697
fixes #726
fixes #677
fixes #672
fixes #633
fixes #632
fixes #560
fixes #556
fixes #523
fixes #516
fixes #510
fixes #504
fixes #500
fixes #482
fixes #406
fixes #403
fixes #373
fixes #351
fixes #344
fixes #326
fixes #325
fixes #323
fixes #246
fixes #240
fixes #232
fixes #197
fixes #172
fixes #141
fixes #69
fixes #1110
fixes #569
fixes #480
fixes #666
fixes #670
fixes #457
fixes #643
fixes #498
fixes #490
fixes #1069
fixes #834
fixes #602
fixes #328
fixes #417
fixes #571
fixes #1029
fixes #489
fixes #1050
fixes #162
fixes #430
fixes #1087
fixes #683

Reviewed-on: https://gitea.com/gitea/tea/pulls/1126
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-11 17:23:06 +00:00
1 parent 12e415a7a7
commit f02245449b
456 files changed
+18892 -34015

No files matched your search

-76
View File
@@ -1,76 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// ActionSecretsList prints a list of action secrets
func ActionSecretsList(secrets []*gitea.Secret, output string) error {
t := table{
headers: []string{
"Name",
"Created",
},
}
for _, secret := range secrets {
t.addRow(
secret.Name,
FormatTime(secret.Created, output != ""),
)
}
if len(secrets) == 0 {
fmt.Printf("No secrets found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
// ActionVariableDetails prints details of a specific action variable
func ActionVariableDetails(variable *gitea.RepoActionVariable) {
fmt.Printf("Name: %s\n", variable.Name)
fmt.Printf("Value: %s\n", variable.Value)
fmt.Printf("Repository ID: %d\n", variable.RepoID)
fmt.Printf("Owner ID: %d\n", variable.OwnerID)
}
// ActionVariablesList prints a list of action variables
func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) error {
t := table{
headers: []string{
"Name",
"Value",
"Repository ID",
},
}
for _, variable := range variables {
// Truncate long values for table display
value := variable.Value
if len(value) > 50 {
value = value[:47] + "..."
}
t.addRow(
variable.Name,
value,
fmt.Sprintf("%d", variable.RepoID),
)
}
if len(variables) == 0 {
fmt.Printf("No variables found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
-215
View File
@@ -1,215 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"time"
"gitea.dev/sdk"
)
// formatDurationMinutes formats duration in a human-readable way
func formatDurationMinutes(started, completed time.Time) string {
if started.IsZero() {
return ""
}
end := completed
if end.IsZero() {
end = time.Now()
}
duration := end.Sub(started)
if duration < time.Minute {
return fmt.Sprintf("%ds", int(duration.Seconds()))
}
if duration < time.Hour {
return fmt.Sprintf("%dm", int(duration.Minutes()))
}
hours := int(duration.Hours())
minutes := int(duration.Minutes()) % 60
return fmt.Sprintf("%dh%dm", hours, minutes)
}
// getWorkflowDisplayName returns the display title or falls back to path
func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string {
if run.DisplayTitle != "" {
return run.DisplayTitle
}
return run.Path
}
// ActionRunsList prints a list of workflow runs
func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) error {
t := table{
headers: []string{
"ID",
"Status",
"Workflow",
"Branch",
"Event",
"Started",
"Duration",
},
}
machineReadable := isMachineReadable(output)
for _, run := range runs {
workflowName := getWorkflowDisplayName(run)
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
t.addRow(
fmt.Sprintf("%d", run.ID),
run.Status,
workflowName,
run.HeadBranch,
run.Event,
FormatTime(run.StartedAt, machineReadable),
duration,
)
}
if len(runs) == 0 {
fmt.Printf("No workflow runs found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
// ActionRunDetails prints detailed information about a workflow run
func ActionRunDetails(run *gitea.ActionWorkflowRun) {
workflowName := getWorkflowDisplayName(run)
fmt.Printf("Run ID: %d\n", run.ID)
fmt.Printf("Run Number: %d\n", run.RunNumber)
fmt.Printf("Status: %s\n", run.Status)
if run.Conclusion != "" {
fmt.Printf("Conclusion: %s\n", run.Conclusion)
}
fmt.Printf("Workflow: %s\n", workflowName)
fmt.Printf("Path: %s\n", run.Path)
fmt.Printf("Branch: %s\n", run.HeadBranch)
fmt.Printf("Event: %s\n", run.Event)
fmt.Printf("Head SHA: %s\n", run.HeadSha)
fmt.Printf("Started: %s\n", FormatTime(run.StartedAt, false))
if !run.CompletedAt.IsZero() {
fmt.Printf("Completed: %s\n", FormatTime(run.CompletedAt, false))
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
fmt.Printf("Duration: %s\n", duration)
}
if run.RunAttempt > 1 {
fmt.Printf("Attempt: %d\n", run.RunAttempt)
}
if run.Actor != nil {
fmt.Printf("Triggered by: %s\n", run.Actor.UserName)
}
if run.HTMLURL != "" {
fmt.Printf("URL: %s\n", run.HTMLURL)
}
}
// ActionWorkflowJobsList prints a list of workflow jobs
func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) error {
t := table{
headers: []string{
"ID",
"Name",
"Status",
"Runner",
"Started",
"Duration",
},
}
machineReadable := isMachineReadable(output)
for _, job := range jobs {
duration := formatDurationMinutes(job.StartedAt, job.CompletedAt)
runner := job.RunnerName
if runner == "" {
runner = "-"
}
t.addRow(
fmt.Sprintf("%d", job.ID),
job.Name,
job.Status,
runner,
FormatTime(job.StartedAt, machineReadable),
duration,
)
}
if len(jobs) == 0 {
fmt.Printf("No jobs found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
// ActionWorkflowsList prints a list of workflows from the workflow API
func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error {
t := table{
headers: []string{
"ID",
"Name",
"Path",
"State",
},
}
for _, wf := range workflows {
t.addRow(
wf.ID,
wf.Name,
wf.Path,
wf.State,
)
}
if len(workflows) == 0 {
fmt.Printf("No workflows found\n")
return nil
}
t.sort(1, true) // Sort by name column
return t.print(output)
}
// ActionWorkflowDetails prints detailed information about a workflow
func ActionWorkflowDetails(wf *gitea.ActionWorkflow) {
fmt.Printf("ID: %s\n", wf.ID)
fmt.Printf("Name: %s\n", wf.Name)
fmt.Printf("Path: %s\n", wf.Path)
fmt.Printf("State: %s\n", wf.State)
if wf.HTMLURL != "" {
fmt.Printf("URL: %s\n", wf.HTMLURL)
}
if wf.BadgeURL != "" {
fmt.Printf("Badge: %s\n", wf.BadgeURL)
}
if !wf.CreatedAt.IsZero() {
fmt.Printf("Created: %s\n", FormatTime(wf.CreatedAt, false))
}
if !wf.UpdatedAt.IsZero() {
fmt.Printf("Updated: %s\n", FormatTime(wf.UpdatedAt, false))
}
}
// ActionWorkflowDispatchResult prints the result of a workflow dispatch
func ActionWorkflowDispatchResult(details *gitea.RunDetails) {
fmt.Printf("Workflow dispatched successfully\n")
if details != nil {
fmt.Printf("Run ID: %d\n", details.WorkflowRunID)
if details.HTMLURL != "" {
fmt.Printf("URL: %s\n", details.HTMLURL)
}
}
}
-256
View File
@@ -1,256 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/require"
)
func TestActionRunsListEmpty(t *testing.T) {
// Test with empty runs - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionRunsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{}, ""))
}
func TestActionRunsListWithData(t *testing.T) {
runs := []*gitea.ActionWorkflowRun{
{
ID: 1,
Status: "success",
DisplayTitle: "Test Workflow",
HeadBranch: "main",
Event: "push",
StartedAt: time.Now().Add(-1 * time.Hour),
CompletedAt: time.Now().Add(-30 * time.Minute),
},
{
ID: 2,
Status: "in_progress",
Path: ".gitea/workflows/test.yml",
HeadBranch: "feature",
Event: "pull_request",
StartedAt: time.Now().Add(-10 * time.Minute),
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionRunsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionRunsList(runs, ""))
}
func TestActionRunDetails(t *testing.T) {
run := &gitea.ActionWorkflowRun{
ID: 123,
RunNumber: 42,
Status: "success",
Conclusion: "success",
DisplayTitle: "Build and Test",
Path: ".gitea/workflows/ci.yml",
HeadBranch: "main",
Event: "push",
HeadSha: "abc123def456",
StartedAt: time.Now().Add(-2 * time.Hour),
CompletedAt: time.Now().Add(-1 * time.Hour),
RunAttempt: 1,
Actor: &gitea.User{
UserName: "testuser",
},
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/123",
}
// Test that it doesn't panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionRunDetails panicked: %v", r)
}
}()
ActionRunDetails(run)
}
func TestActionWorkflowJobsListEmpty(t *testing.T) {
// Test with empty jobs - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowJobsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, ""))
}
func TestActionWorkflowJobsListWithData(t *testing.T) {
jobs := []*gitea.ActionWorkflowJob{
{
ID: 1,
Name: "build",
Status: "success",
RunnerName: "runner-1",
StartedAt: time.Now().Add(-30 * time.Minute),
CompletedAt: time.Now().Add(-20 * time.Minute),
},
{
ID: 2,
Name: "test",
Status: "in_progress",
RunnerName: "runner-2",
StartedAt: time.Now().Add(-5 * time.Minute),
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowJobsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionWorkflowJobsList(jobs, ""))
}
func TestActionWorkflowsListEmpty(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList([]*gitea.ActionWorkflow{}, ""))
}
func TestActionWorkflowsListWithData(t *testing.T) {
workflows := []*gitea.ActionWorkflow{
{
ID: "1",
Name: "CI",
Path: ".gitea/workflows/ci.yml",
State: "active",
},
{
ID: "2",
Name: "Deploy",
Path: ".gitea/workflows/deploy.yml",
State: "disabled_manually",
},
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList(workflows, ""))
}
func TestActionWorkflowDetails(t *testing.T) {
wf := &gitea.ActionWorkflow{
ID: "1",
Name: "CI Pipeline",
Path: ".gitea/workflows/ci.yml",
State: "active",
HTMLURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml",
BadgeURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml/badge.svg",
CreatedAt: time.Now().Add(-24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * time.Hour),
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDetails panicked: %v", r)
}
}()
ActionWorkflowDetails(wf)
}
func TestActionWorkflowDispatchResult(t *testing.T) {
details := &gitea.RunDetails{
WorkflowRunID: 42,
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/42",
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked: %v", r)
}
}()
ActionWorkflowDispatchResult(details)
}
func TestActionWorkflowDispatchResultNil(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked with nil: %v", r)
}
}()
ActionWorkflowDispatchResult(nil)
}
func TestFormatDurationMinutes(t *testing.T) {
now := time.Now()
tests := []struct {
name string
started time.Time
completed time.Time
expected string
}{
{
name: "zero started",
started: time.Time{},
completed: now,
expected: "",
},
{
name: "30 seconds",
started: now.Add(-30 * time.Second),
completed: now,
expected: "30s",
},
{
name: "5 minutes",
started: now.Add(-5 * time.Minute),
completed: now,
expected: "5m",
},
{
name: "in progress (no completed)",
started: now.Add(-1 * time.Hour),
completed: time.Time{},
expected: "1h0m",
},
{
name: "2 hours 30 minutes",
started: now.Add(-150 * time.Minute),
completed: now,
expected: "2h30m",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := formatDurationMinutes(test.started, test.completed)
if result != test.expected {
t.Errorf("formatDurationMinutes() = %q, want %q", result, test.expected)
}
})
}
}
-215
View File
@@ -1,215 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"strconv"
"strings"
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/require"
)
func TestActionSecretsListEmpty(t *testing.T) {
// Test with empty secrets - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionSecretsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionSecretsList([]*gitea.Secret{}, ""))
}
func TestActionSecretsListWithData(t *testing.T) {
secrets := []*gitea.Secret{
{
Name: "TEST_SECRET_1",
Created: time.Now().Add(-24 * time.Hour),
},
{
Name: "TEST_SECRET_2",
Created: time.Now().Add(-48 * time.Hour),
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionSecretsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionSecretsList(secrets, ""))
// Test JSON output format to verify structure
var buf bytes.Buffer
testTable := table{
headers: []string{"Name", "Created"},
}
for _, secret := range secrets {
testTable.addRow(secret.Name, FormatTime(secret.Created, true))
}
require.NoError(t, testTable.fprint(&buf, "json"))
output := buf.String()
if !strings.Contains(output, "TEST_SECRET_1") {
t.Error("Expected TEST_SECRET_1 in JSON output")
}
if !strings.Contains(output, "TEST_SECRET_2") {
t.Error("Expected TEST_SECRET_2 in JSON output")
}
}
func TestActionVariableDetails(t *testing.T) {
variable := &gitea.RepoActionVariable{
Name: "TEST_VARIABLE",
Value: "test_value",
RepoID: 123,
OwnerID: 456,
}
// Test that it doesn't panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariableDetails panicked: %v", r)
}
}()
ActionVariableDetails(variable)
}
func TestActionVariablesListEmpty(t *testing.T) {
// Test with empty variables - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariablesList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{}, ""))
}
func TestActionVariablesListWithData(t *testing.T) {
variables := []*gitea.RepoActionVariable{
{
Name: "TEST_VARIABLE_1",
Value: "short_value",
RepoID: 123,
OwnerID: 456,
},
{
Name: "TEST_VARIABLE_2",
Value: strings.Repeat("a", 60), // Long value to test truncation
RepoID: 124,
OwnerID: 457,
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariablesList panicked with data: %v", r)
}
}()
require.NoError(t, ActionVariablesList(variables, ""))
// Test JSON output format to verify structure and truncation
var buf bytes.Buffer
testTable := table{
headers: []string{"Name", "Value", "Repository ID"},
}
for _, variable := range variables {
value := variable.Value
if len(value) > 50 {
value = value[:47] + "..."
}
testTable.addRow(variable.Name, value, strconv.Itoa(int(variable.RepoID)))
}
require.NoError(t, testTable.fprint(&buf, "json"))
output := buf.String()
if !strings.Contains(output, "TEST_VARIABLE_1") {
t.Error("Expected TEST_VARIABLE_1 in JSON output")
}
if !strings.Contains(output, "TEST_VARIABLE_2") {
t.Error("Expected TEST_VARIABLE_2 in JSON output")
}
// Check that long value is truncated in our test table
if strings.Contains(output, strings.Repeat("a", 60)) {
t.Error("Long value should be truncated in table output")
}
}
func TestActionVariablesListValueTruncation(t *testing.T) {
variable := &gitea.RepoActionVariable{
Name: "LONG_VALUE_VARIABLE",
Value: strings.Repeat("abcdefghij", 10), // 100 characters
RepoID: 123,
OwnerID: 456,
}
// Test that it doesn't panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariablesList panicked with long value: %v", r)
}
}()
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{variable}, ""))
// Test the truncation logic directly
value := variable.Value
if len(value) > 50 {
value = value[:47] + "..."
}
if len(value) != 50 { // 47 chars + "..." = 50
t.Errorf("Truncated value should be 50 characters, got %d", len(value))
}
if !strings.HasSuffix(value, "...") {
t.Error("Truncated value should end with '...'")
}
}
func TestTableSorting(t *testing.T) {
// Test that the table sorting works correctly
secrets := []*gitea.Secret{
{Name: "Z_SECRET", Created: time.Now()},
{Name: "A_SECRET", Created: time.Now()},
{Name: "M_SECRET", Created: time.Now()},
}
// Test the table sorting logic
table := table{
headers: []string{"Name", "Created"},
}
for _, secret := range secrets {
table.addRow(secret.Name, FormatTime(secret.Created, true))
}
// Sort by first column (Name) in ascending order (false = ascending)
table.sort(0, false)
// Check that the first row is A_SECRET after ascending sorting
if table.values[0][0] != "A_SECRET" {
t.Errorf("Expected first sorted value to be 'A_SECRET', got '%s'", table.values[0][0])
}
// Check that the last row is Z_SECRET after ascending sorting
if table.values[2][0] != "Z_SECRET" {
t.Errorf("Expected last sorted value to be 'Z_SECRET', got '%s'", table.values[2][0])
}
}
-34
View File
@@ -1,34 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
func formatByteSize(size int64) string {
if size < 1024 {
return fmt.Sprintf("%d B", size)
}
return formatSize(size / 1024)
}
// ReleaseAttachmentsList prints a listing of release attachments
func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) error {
t := tableWithHeader(
"Name",
"Size",
)
for _, attachment := range attachments {
t.addRow(
attachment.Name,
formatByteSize(attachment.Size),
)
}
return t.print(output)
}
-85
View File
@@ -1,85 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// BranchesList prints a listing of the branches
func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) error {
printables := make([]printable, len(branches))
for i, branch := range branches {
var protection *gitea.BranchProtection
for _, p := range protections {
if p.BranchName == branch.Name {
protection = p
}
}
printables[i] = &printableBranch{branch, protection}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
type printableBranch struct {
branch *gitea.Branch
protection *gitea.BranchProtection
}
func (x printableBranch) FormatField(field string, machineReadable bool) string {
switch field {
case "name":
return x.branch.Name
case "protected":
return fmt.Sprintf("%t", x.branch.Protected)
case "user-can-merge":
return fmt.Sprintf("%t", x.branch.UserCanMerge)
case "user-can-push":
return fmt.Sprintf("%t", x.branch.UserCanPush)
case "protection":
if x.protection != nil {
approving := ""
for _, entry := range x.protection.ApprovalsWhitelistTeams {
approving += entry + "/"
}
for _, entry := range x.protection.ApprovalsWhitelistUsernames {
approving += entry + "/"
}
merging := ""
for _, entry := range x.protection.MergeWhitelistTeams {
merging += entry + "/"
}
for _, entry := range x.protection.MergeWhitelistUsernames {
merging += entry + "/"
}
pushing := ""
for _, entry := range x.protection.PushWhitelistTeams {
pushing += entry + "/"
}
for _, entry := range x.protection.PushWhitelistUsernames {
pushing += entry + "/"
}
return fmt.Sprintf(
"- enable-push: %t\n- approving: %s\n- merging: %s\n- pushing: %s\n",
x.protection.EnablePush, approving, merging, pushing,
)
}
return "<None>"
}
return ""
}
// BranchFields are all available fields to print with BranchesList()
var BranchFields = []string{
"name",
"protected",
"user-can-merge",
"user-can-push",
"protection",
}
-33
View File
@@ -1,33 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
)
func TestPrintableBranchProtectionUsesSeparateWhitelists(t *testing.T) {
protection := &gitea.BranchProtection{
EnablePush: true,
ApprovalsWhitelistTeams: []string{"approve-team"},
ApprovalsWhitelistUsernames: []string{"approve-user"},
MergeWhitelistTeams: []string{"merge-team"},
MergeWhitelistUsernames: []string{"merge-user"},
PushWhitelistTeams: []string{"push-team"},
PushWhitelistUsernames: []string{"push-user"},
}
result := printableBranch{
branch: &gitea.Branch{Name: "main"},
protection: protection,
}.FormatField("protection", false)
assert.Contains(t, result, "- approving: approve-team/approve-user/")
assert.Contains(t, result, "- merging: merge-team/merge-user/")
assert.Contains(t, result, "- pushing: push-team/push-user/")
assert.NotContains(t, result, "- approving: approve-team/approve-user/merge-team/")
}
-99
View File
@@ -1,99 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
)
// Comments renders a list of comments to stdout
func Comments(comments []*gitea.Comment) {
var baseURL string
if len(comments) != 0 {
baseURL = getRepoURL(comments[0].HTMLURL)
}
out := make([]string, len(comments))
for i, c := range comments {
out[i] = formatComment(c)
}
_ = outputMarkdown(fmt.Sprintf(
// this will become a heading by means of the first --- from a comment
"Comments\n%s",
strings.Join(out, "\n"),
), baseURL)
}
// CommentsList prints comments in tabular form, including IDs so they can be
// passed to 'tea comments edit' / 'tea comments delete'.
func CommentsList(comments []*gitea.Comment, output string) error {
if len(comments) == 0 {
fmt.Println("No comments found")
return nil
}
t := tableWithHeader(
"ID",
"Author",
"Created",
"Updated",
"Body",
)
for _, c := range comments {
updated := ""
if c.Updated.After(c.Created) {
updated = FormatTime(c.Updated, false)
}
t.addRow(
fmt.Sprintf("%d", c.ID),
"@"+c.Poster.UserName,
FormatTime(c.Created, false),
updated,
summarizeBody(c.Body),
)
}
return t.print(output)
}
func summarizeBody(body string) string {
const max = 80
var b strings.Builder
for _, r := range body {
if r == '\n' || r == '\r' {
b.WriteByte(' ')
} else {
b.WriteRune(r)
}
}
s := b.String()
if len(s) > max {
return s[:max-1] + "…"
}
return s
}
// Comment renders a comment to stdout
func Comment(c *gitea.Comment) {
_ = outputMarkdown(formatComment(c), getRepoURL(c.HTMLURL))
}
func formatComment(c *gitea.Comment) string {
edited := ""
if c.Updated.After(c.Created) {
edited = fmt.Sprintf(" *(edited on %s)*", FormatTime(c.Updated, false))
}
return fmt.Sprintf(
"---\n\n**@%s** wrote on %s%s:\n\n%s\n",
c.Poster.UserName,
FormatTime(c.Created, false),
edited,
c.Body,
)
}
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"os"
"regexp"
"time"
"gitea.dev/sdk"
"github.com/muesli/termenv"
"golang.org/x/term"
)
// IsInteractive checks if the output is piped, but NOT if the session is run interactively..
func IsInteractive() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
// captures the repo URL part <host>/<owner>/<repo> of an url
var repoURLRegex = regexp.MustCompile("^([[:alnum:]]+://[^/]+(?:/[[:alnum:]]+){2})/.*")
func getRepoURL(resourceURL string) string {
return repoURLRegex.ReplaceAllString(resourceURL, "$1/")
}
// formatSize get kb in int and return string
func formatSize(kb int64) string {
if kb < 1024 {
return fmt.Sprintf("%d KB", kb)
}
mb := kb / 1024
if mb < 1024 {
return fmt.Sprintf("%d MB", mb)
}
gb := mb / 1024
if gb < 1024 {
return fmt.Sprintf("%d GB", gb)
}
return fmt.Sprintf("%d TB", gb/1024)
}
// FormatTime provides a string for the given time value.
// If machineReadable is set, a UTC RFC3339 string is returned,
// otherwise a simplified string in local time is used.
func FormatTime(t time.Time, machineReadable bool) string {
if t.IsZero() {
return ""
}
if machineReadable {
return t.UTC().Format(time.RFC3339)
}
location, err := time.LoadLocation("Local")
if err != nil {
return t.Format("2006-01-02 15:04 UTC")
}
return t.In(location).Format("2006-01-02 15:04")
}
func formatDuration(seconds int64, outputType string) string {
if isMachineReadable(outputType) {
return fmt.Sprint(seconds)
}
return time.Duration(1e9 * seconds).String()
}
func formatLabel(label *gitea.Label, allowColor bool, text string) string {
colorProfile := termenv.Ascii
if allowColor {
colorProfile = termenv.EnvColorProfile()
}
if len(text) == 0 {
text = label.Name
}
styled := termenv.String(text)
styled = styled.Foreground(colorProfile.Color("#" + label.Color))
return fmt.Sprint(styled)
}
func formatPermission(p *gitea.Permission) string {
if p.Admin {
return "admin"
} else if p.Push {
return "write"
}
return "read"
}
func formatUserName(u *gitea.User) string {
if len(u.FullName) == 0 {
return u.UserName
}
return u.FullName
}
func formatBoolean(b bool, allowIcons bool) string {
if !allowIcons {
return fmt.Sprintf("%v", b)
}
styled := "✔"
if !b {
styled = "✖"
}
return styled
}
-156
View File
@@ -1,156 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
"github.com/enescakir/emoji"
)
// IssueDetails print an issue rendered to stdout
func IssueDetails(issue *gitea.Issue, reactions []*gitea.Reaction) {
out := fmt.Sprintf(
"# #%d %s (%s)\n@%s created %s\n\n%s\n",
issue.Index,
issue.Title,
issue.State,
issue.Poster.UserName,
FormatTime(issue.Created, false),
issue.Body,
)
if len(reactions) > 0 {
out += fmt.Sprintf("\n---\n\n%s\n", formatReactions(reactions))
}
_ = outputMarkdown(out, getRepoURL(issue.HTMLURL))
}
func formatReactions(reactions []*gitea.Reaction) string {
reactionCounts := make(map[string]uint16)
for _, r := range reactions {
reactionCounts[r.Reaction]++
}
reactionStrings := make([]string, 0, len(reactionCounts))
for reaction, count := range reactionCounts {
reactionStrings = append(reactionStrings, fmt.Sprintf("%dx :%s:", count, reaction))
}
return emoji.Parse(strings.Join(reactionStrings, " | "))
}
// IssuesPullsList prints a listing of issues & pulls
func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) error {
return printIssues(issues, output, fields)
}
// IssueFields are all available fields to print with IssuesList()
var IssueFields = []string{
"index",
"state",
"kind",
"author",
"author-id",
"url",
"title",
"body",
"created",
"updated",
"deadline",
"assignees",
"milestone",
"labels",
"comments",
"owner",
"repo",
}
func printIssues(issues []*gitea.Issue, output string, fields []string) error {
labelMap := map[int64]string{}
printables := make([]printable, len(issues))
machineReadable := isMachineReadable(output)
for i, x := range issues {
// pre-serialize labels for performance
for _, label := range x.Labels {
if _, ok := labelMap[label.ID]; !ok {
labelMap[label.ID] = formatLabel(label, !machineReadable, "")
}
}
// store items with printable interface
printables[i] = &printableIssue{x, &labelMap}
}
t := tableFromItems(fields, printables, machineReadable)
return t.print(output)
}
type printableIssue struct {
*gitea.Issue
formattedLabels *map[int64]string
}
func (x printableIssue) FormatField(field string, machineReadable bool) string {
switch field {
case "index":
return fmt.Sprintf("%d", x.Index)
case "state":
return string(x.State)
case "kind":
if x.PullRequest != nil {
return "Pull"
}
return "Issue"
case "author":
return formatUserName(x.Poster)
case "author-id":
return x.Poster.UserName
case "url":
return x.HTMLURL
case "title":
return x.Title
case "body":
return x.Body
case "created":
return FormatTime(x.Created, machineReadable)
case "updated":
return FormatTime(x.Updated, machineReadable)
case "deadline":
if x.Deadline == nil {
return ""
}
return FormatTime(*x.Deadline, machineReadable)
case "milestone":
if x.Milestone != nil {
return x.Milestone.Title
}
return ""
case "labels":
labels := make([]string, len(x.Labels))
for i, l := range x.Labels {
labels[i] = (*x.formattedLabels)[l.ID]
}
return strings.Join(labels, " ")
case "assignees":
assignees := make([]string, len(x.Assignees))
for i, a := range x.Assignees {
assignees[i] = formatUserName(a)
}
return strings.Join(assignees, " ")
case "comments":
return fmt.Sprintf("%d", x.Comments)
case "owner":
return x.Repository.Owner
case "repo":
return x.Repository.Name
}
return ""
}
-37
View File
@@ -1,37 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"strconv"
"strings"
gitea "gitea.dev/sdk"
)
// LabelsList prints a listing of labels
func LabelsList(labels []*gitea.Label, output string) error {
t := tableWithHeader(
"Index",
"Color",
"Name",
"Description",
"Level",
)
for _, label := range labels {
level := "Repository"
if strings.Contains(label.URL, "/orgs/") {
level = "Organization"
}
t.addRow(
strconv.FormatInt(label.ID, 10),
formatLabel(label, !isMachineReadable(output), label.Color),
label.Name,
label.Description,
level,
)
}
return t.print(output)
}
-54
View File
@@ -1,54 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
"gitea.dev/tea/modules/config"
)
// LoginDetails print login entry to stdout
func LoginDetails(login *config.Login) {
in := fmt.Sprintf("# %s\n\n[@%s](%s/%s)\n",
login.Name,
login.User,
strings.TrimSuffix(login.URL, "/"),
login.User,
)
if len(login.SSHKey) != 0 {
in += fmt.Sprintf("\nSSH Key: '%s' via %s\n",
login.SSHKey,
login.SSHHost,
)
}
in += fmt.Sprintf("\nCreated: %s", time.Unix(login.Created, 0).Format(time.RFC822))
_ = outputMarkdown(in, "")
}
// LoginsList prints a listing of logins
func LoginsList(logins []config.Login, output string) error {
t := tableWithHeader(
"Name",
"URL",
"SSHHost",
"User",
"Default",
)
for _, l := range logins {
t.addRow(
l.Name,
l.URL,
l.GetSSHHost(),
l.User,
fmt.Sprint(l.Default),
)
}
return t.print(output)
}
-145
View File
@@ -1,145 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
)
// LoginStatus contains the authentication status of a single configured login.
type LoginStatus struct {
Name string
URL string
User string
Valid bool
AuthMethod string
TokenExpiry time.Time
Helper bool
Default bool
Error string
}
// LoginStatusFields are the available fields to print with LoginStatuses.
var LoginStatusFields = []string{
"name",
"url",
"user",
"valid",
"auth_method",
"token_expiry",
"helper",
"default",
}
// LoginStatuses prints authentication status for one or more logins.
func LoginStatuses(statuses []LoginStatus, output string) error {
if output != "" {
printables := make([]printable, len(statuses))
for i := range statuses {
printables[i] = statuses[i]
}
t := tableFromItems(LoginStatusFields, printables, isMachineReadable(output))
return t.print(output)
}
if len(statuses) == 0 {
fmt.Println("No logins configured.")
return nil
}
for i, status := range statuses {
if i > 0 {
fmt.Println()
}
printLoginStatusReport(status)
}
return nil
}
func printLoginStatusReport(status LoginStatus) {
name := status.Name
if status.Default {
name += " (default)"
}
fmt.Println(name)
if status.Valid {
line := " ✔ Logged in to " + status.URL
if status.User != "" {
line += " as " + status.User
}
fmt.Println(line)
tokenLine := " ✔ Token is valid"
if status.AuthMethod != "" {
tokenLine += " (" + status.AuthMethod
if !status.TokenExpiry.IsZero() {
tokenLine += ", " + formatTokenExpiry(status.TokenExpiry)
}
tokenLine += ")"
}
fmt.Println(tokenLine)
} else {
message := status.Error
if message == "" {
message = "Login failed"
}
fmt.Println(" ✗ " + message)
}
if status.Helper {
fmt.Println(" ✔ Git credential helper configured")
} else {
fmt.Println(" ✗ Git credential helper not configured")
}
}
func formatExpiryDuration(t time.Time) string {
d := time.Until(t)
if d < 0 {
return "expired"
}
if d < time.Minute {
return "in less than a minute"
}
return "in " + strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
}
func formatTokenExpiry(t time.Time) string {
if t.Before(time.Now()) {
return "expired"
}
return "expires " + formatExpiryDuration(t)
}
// FormatField implements the printable interface for LoginStatus.
func (s LoginStatus) FormatField(field string, machineReadable bool) string {
switch field {
case "name":
return s.Name
case "url":
return s.URL
case "user":
return s.User
case "valid":
return formatBoolean(s.Valid, !machineReadable)
case "auth_method":
return s.AuthMethod
case "token_expiry":
if s.TokenExpiry.IsZero() {
return ""
}
if machineReadable {
return FormatTime(s.TokenExpiry, true)
}
return formatExpiryDuration(s.TokenExpiry)
case "helper":
return formatBoolean(s.Helper, !machineReadable)
case "default":
return formatBoolean(s.Default, !machineReadable)
}
return ""
}
-38
View File
@@ -1,38 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestLoginStatusFormatField(t *testing.T) {
status := LoginStatus{
Name: "gitea",
URL: "https://gitea.com",
User: "alice",
Valid: true,
AuthMethod: "oauth",
TokenExpiry: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC),
Helper: true,
Default: true,
}
assert.Equal(t, "gitea", status.FormatField("name", false))
assert.Equal(t, "https://gitea.com", status.FormatField("url", false))
assert.Equal(t, "alice", status.FormatField("user", false))
assert.Equal(t, "true", status.FormatField("valid", true))
assert.Equal(t, "✔", status.FormatField("valid", false))
assert.Equal(t, "oauth", status.FormatField("auth_method", true))
assert.Equal(t, "2026-08-27T12:00:00Z", status.FormatField("token_expiry", true))
assert.Equal(t, "true", status.FormatField("helper", true))
assert.Equal(t, "✔", status.FormatField("default", false))
}
func TestFormatExpiryDurationExpired(t *testing.T) {
assert.Equal(t, "expired", formatExpiryDuration(time.Now().Add(-time.Hour)))
}
-62
View File
@@ -1,62 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"os"
"charm.land/glamour/v2"
"golang.org/x/term"
)
// outputMarkdown prints markdown to stdout, formatted for terminals.
// If the input could not be parsed, it is printed unformatted, the error
// is returned anyway.
func outputMarkdown(markdown string, baseURL string) error {
var renderer *glamour.TermRenderer
var err error
if IsInteractive() {
renderer, err = glamour.NewTermRenderer(
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
glamour.WithWordWrap(getWordWrap()),
)
} else {
renderer, err = glamour.NewTermRenderer(
glamour.WithStandardStyle("notty"),
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
glamour.WithWordWrap(getWordWrap()),
)
}
if err != nil {
fmt.Print(markdown)
return err
}
out, err := renderer.Render(markdown)
if err != nil {
fmt.Print(markdown)
return err
}
fmt.Print(out)
return nil
}
// stolen from https://github.com/charmbracelet/glow/blob/e9d728c/main.go#L152-L165
func getWordWrap() int {
fd := int(os.Stdout.Fd())
width := 80
if term.IsTerminal(fd) {
if w, _, err := term.GetSize(fd); err == nil {
width = w
}
}
if width > 120 {
width = 120
}
return width
}
-87
View File
@@ -1,87 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// MilestoneDetails print an milestone formatted to stdout
func MilestoneDetails(milestone *gitea.Milestone) {
fmt.Printf("%s\n",
milestone.Title,
)
if len(milestone.Description) != 0 {
outputMarkdown(milestone.Description, "")
}
if milestone.Deadline != nil && !milestone.Deadline.IsZero() {
fmt.Printf("\nDeadline: %s\n", FormatTime(*milestone.Deadline, false))
}
}
// MilestonesList prints a listing of milestones
func MilestonesList(news []*gitea.Milestone, output string, fields []string) error {
printables := make([]printable, len(news))
for i, x := range news {
printables[i] = &printableMilestone{x}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
t.sort(0, true)
return t.print(output)
}
// MilestoneFields are all available fields to print with MilestonesList
var MilestoneFields = []string{
"title",
"state",
"items_open",
"items_closed",
"items",
"duedate",
"description",
"created",
"updated",
"closed",
"id",
}
type printableMilestone struct {
*gitea.Milestone
}
func (m printableMilestone) FormatField(field string, machineReadable bool) string {
switch field {
case "title":
return m.Title
case "state":
return string(m.State)
case "items_open":
return fmt.Sprintf("%d", m.OpenIssues)
case "items_closed":
return fmt.Sprintf("%d", m.ClosedIssues)
case "items":
return fmt.Sprintf("%d/%d", m.OpenIssues, m.ClosedIssues)
case "duedate":
if m.Deadline != nil && !m.Deadline.IsZero() {
return FormatTime(*m.Deadline, machineReadable)
}
case "id":
return fmt.Sprintf("%d", m.ID)
case "description":
return m.Description
case "created":
return FormatTime(m.Created, machineReadable)
case "updated":
if m.Updated != nil {
return FormatTime(*m.Updated, machineReadable)
}
case "closed":
if m.Closed != nil {
return FormatTime(*m.Closed, machineReadable)
}
}
return ""
}
-82
View File
@@ -1,82 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
)
// NotificationsList prints a listing of notification threads
func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) error {
printables := make([]printable, len(news))
for i, x := range news {
printables[i] = &printableNotification{x}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
// NotificationFields are all available fields to print with NotificationsList
var NotificationFields = []string{
"id",
"status",
"updated",
// these are about the notification subject
"index",
"type",
"state",
"title",
"repository",
}
type printableNotification struct {
*gitea.NotificationThread
}
func (n printableNotification) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", n.ID)
case "status":
status := "read"
if n.Pinned {
status = "pinned"
} else if n.Unread {
status = "unread"
}
return status
case "updated":
return FormatTime(n.UpdatedAt, machineReadable)
case "index":
var index string
if n.Subject.Type == "Issue" || n.Subject.Type == "Pull" {
index = n.Subject.URL
urlParts := strings.Split(n.Subject.URL, "/")
if len(urlParts) != 0 {
index = urlParts[len(urlParts)-1]
}
}
return index
case "type":
return string(n.Subject.Type)
case "state":
return string(n.Subject.State)
case "title":
return n.Subject.Title
case "repo", "repository":
return n.Repository.FullName
}
return ""
}
-50
View File
@@ -1,50 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// OrganizationDetails prints details of an org with formatting
func OrganizationDetails(org *gitea.Organization) {
_ = outputMarkdown(fmt.Sprintf(
"# %s\n%s\n\n- Visibility: %s\n- Location: %s\n- Website: %s\n",
org.UserName,
org.Description,
org.Visibility,
org.Location,
org.Website,
), "")
}
// OrganizationsList prints a listing of the organizations
func OrganizationsList(organizations []*gitea.Organization, output string) error {
if len(organizations) == 0 {
fmt.Println("No organizations found")
return nil
}
t := tableWithHeader(
"Name",
"FullName",
"Website",
"Location",
"Description",
)
for _, org := range organizations {
t.addRow(
org.UserName,
org.FullName,
org.Website,
org.Location,
org.Description,
)
}
return t.print(output)
}
-290
View File
@@ -1,290 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
)
var ciStatusSymbols = map[gitea.StatusState]string{
gitea.StatusSuccess: "✓ ",
gitea.StatusPending: "⏳ ",
gitea.StatusWarning: "⚠ ",
gitea.StatusError: "✘ ",
gitea.StatusFailure: "❌ ",
}
// PullDetails print an pull rendered to stdout
func PullDetails(pr *gitea.PullRequest, reviews []*gitea.PullReview, ciStatus *gitea.CombinedStatus) {
base := pr.Base.Name
head := formatPRHead(pr)
state := formatPRState(pr)
out := fmt.Sprintf(
"# #%d %s (%s)\n@%s created %s\t**%s** <- **%s**\n\n%s\n\n",
pr.Index,
pr.Title,
state,
pr.Poster.UserName,
FormatTime(*pr.Created, false),
base,
head,
pr.Body,
)
if ciStatus != nil || len(reviews) != 0 || pr.State == gitea.StateOpen {
out += "---\n"
}
out += formatReviews(pr, reviews)
if ciStatus != nil && len(ciStatus.Statuses) != 0 {
out += "- CI:\n"
for _, s := range ciStatus.Statuses {
symbol := ciStatusSymbols[s.State]
if s.TargetURL != "" {
out += fmt.Sprintf(" - %s[**%s**](%s)", symbol, s.Context, s.TargetURL)
} else {
out += fmt.Sprintf(" - %s**%s**", symbol, s.Context)
}
if s.Description != "" {
out += fmt.Sprintf(": %s", s.Description)
}
out += "\n"
}
}
if pr.State == gitea.StateOpen {
switch {
case pr.Mergeable:
out += "- No Conflicts\n"
case pr.Draft:
out += "- Draft (not mergeable until marked ready)\n"
default:
out += "- **Conflicting files**\n"
}
}
if pr.AllowMaintainerEdit {
out += "- Maintainers are allowed to edit\n"
}
if pr.HTMLURL != "" {
out = strings.TrimRight(out, "\n") + "\n\n" + pr.HTMLURL + "\n"
}
outputMarkdown(out, getRepoURL(pr.HTMLURL))
}
func formatPRHead(pr *gitea.PullRequest) string {
head := pr.Head.Name
if pr.Head.RepoID != pr.Base.RepoID {
if pr.Head.Repository != nil {
head = pr.Head.Repository.Owner.UserName + ":" + head
} else {
head = "delete:" + head
}
}
return head
}
func formatPRState(pr *gitea.PullRequest) string {
if pr.Merged != nil {
return "merged"
}
return string(pr.State)
}
func formatCIStatus(ci *gitea.CombinedStatus, machineReadable bool) string {
if ci == nil || len(ci.Statuses) == 0 {
return ""
}
if machineReadable {
return string(ci.State)
}
items := make([]string, 0, len(ci.Statuses))
for _, s := range ci.Statuses {
items = append(items, fmt.Sprintf("%s%s", ciStatusSymbols[s.State], s.Context))
}
return strings.Join(items, ", ")
}
func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
result := ""
if len(reviews) == 0 {
return result
}
// deduplicate reviews by user (via review time & userID),
reviewByUserOrTeam := make(map[string]*gitea.PullReview)
for _, review := range reviews {
switch review.State {
case gitea.ReviewStateApproved,
gitea.ReviewStateRequestChanges,
gitea.ReviewStateRequestReview:
if review.Reviewer != nil {
if r, ok := reviewByUserOrTeam[fmt.Sprintf("user_%d", review.Reviewer.ID)]; !ok || review.Submitted.After(r.Submitted) {
reviewByUserOrTeam[fmt.Sprintf("user_%d", review.Reviewer.ID)] = review
}
} else if review.ReviewerTeam != nil {
if r, ok := reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)]; !ok || review.Submitted.After(r.Submitted) {
reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)] = review
}
}
}
}
// group reviews by type
reviewByState := make(map[gitea.ReviewStateType][]string)
for _, r := range reviewByUserOrTeam {
if r.Reviewer != nil {
reviewByState[r.State] = append(reviewByState[r.State],
r.Reviewer.UserName,
)
} else if r.ReviewerTeam != nil {
// only pulls to orgs can have team reviews
org := pr.Base.Repository.Owner
reviewByState[r.State] = append(reviewByState[r.State],
fmt.Sprintf("%s/%s", org.UserName, r.ReviewerTeam.Name),
)
}
}
// stringify
for state, user := range reviewByState {
result += fmt.Sprintf("- %s by @%s\n", state, strings.Join(user, ", @"))
}
return result
}
// PullsList prints a listing of pulls
func PullsList(prs []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
return printPulls(prs, output, fields, ciStatuses)
}
// PullFields are all available fields to print with PullsList()
var PullFields = []string{
"index",
"state",
"author",
"author-id",
"url",
"title",
"body",
"mergeable",
"base",
"base-commit",
"head",
"diff",
"patch",
"created",
"updated",
"deadline",
"assignees",
"milestone",
"labels",
"comments",
"ci",
}
func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
labelMap := map[int64]string{}
printables := make([]printable, len(pulls))
machineReadable := isMachineReadable(output)
for i, x := range pulls {
// pre-serialize labels for performance
for _, label := range x.Labels {
if _, ok := labelMap[label.ID]; !ok {
labelMap[label.ID] = formatLabel(label, !machineReadable, "")
}
}
// store items with printable interface
printables[i] = &printablePull{x, &labelMap, &ciStatuses}
}
t := tableFromItems(fields, printables, machineReadable)
return t.print(output)
}
type printablePull struct {
*gitea.PullRequest
formattedLabels *map[int64]string
ciStatuses *map[int64]*gitea.CombinedStatus
}
func (x printablePull) FormatField(field string, machineReadable bool) string {
switch field {
case "index":
return fmt.Sprintf("%d", x.Index)
case "state":
return formatPRState(x.PullRequest)
case "author":
return formatUserName(x.Poster)
case "author-id":
return x.Poster.UserName
case "url":
return x.HTMLURL
case "title":
return x.Title
case "body":
return x.Body
case "created":
return FormatTime(*x.Created, machineReadable)
case "updated":
return FormatTime(*x.Updated, machineReadable)
case "deadline":
if x.Deadline == nil {
return ""
}
return FormatTime(*x.Deadline, machineReadable)
case "milestone":
if x.Milestone != nil {
return x.Milestone.Title
}
return ""
case "labels":
labels := make([]string, len(x.Labels))
for i, l := range x.Labels {
labels[i] = (*x.formattedLabels)[l.ID]
}
return strings.Join(labels, " ")
case "assignees":
assignees := make([]string, len(x.Assignees))
for i, a := range x.Assignees {
assignees[i] = formatUserName(a)
}
return strings.Join(assignees, " ")
case "comments":
return fmt.Sprintf("%d", x.Comments)
case "mergeable":
isMergeable := x.Mergeable && x.State == gitea.StateOpen
return formatBoolean(isMergeable, !machineReadable)
case "base":
return x.Base.Ref
case "base-commit":
return x.MergeBase
case "head":
return formatPRHead(x.PullRequest)
case "diff":
return x.DiffURL
case "patch":
return x.PatchURL
case "ci":
if x.ciStatuses != nil {
if ci, ok := (*x.ciStatuses)[x.Index]; ok {
return formatCIStatus(ci, machineReadable)
}
}
return ""
}
return ""
}
-73
View File
@@ -1,73 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// PullReviewCommentFields are all available fields to print with PullReviewCommentsList()
var PullReviewCommentFields = []string{
"id",
"body",
"reviewer",
"path",
"line",
"resolver",
"created",
"updated",
"url",
}
// PullReviewCommentsList prints a listing of pull review comments
func PullReviewCommentsList(comments []*gitea.PullReviewComment, output string, fields []string) error {
printables := make([]printable, len(comments))
for i, c := range comments {
printables[i] = &printablePullReviewComment{c}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
type printablePullReviewComment struct {
*gitea.PullReviewComment
}
func (x printablePullReviewComment) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", x.ID)
case "body":
return x.Body
case "reviewer":
if x.Reviewer != nil {
return formatUserName(x.Reviewer)
}
return ""
case "path":
return x.Path
case "line":
if x.LineNum != 0 {
return fmt.Sprintf("%d", x.LineNum)
}
if x.OldLineNum != 0 {
return fmt.Sprintf("%d", x.OldLineNum)
}
return ""
case "resolver":
if x.Resolver != nil {
return formatUserName(x.Resolver)
}
return ""
case "created":
return FormatTime(x.Created, machineReadable)
case "updated":
return FormatTime(x.Updated, machineReadable)
case "url":
return x.HTMLURL
}
return ""
}
-200
View File
@@ -1,200 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"slices"
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestPR(index int64, title string) *gitea.PullRequest {
now := time.Now()
return &gitea.PullRequest{
Index: index,
Title: title,
State: gitea.StateOpen,
Poster: &gitea.User{UserName: "testuser"},
Head: &gitea.PRBranchInfo{Ref: "branch", Name: "branch"},
Base: &gitea.PRBranchInfo{Ref: "main", Name: "main"},
Created: &now,
Updated: &now,
}
}
func TestFormatCIStatusNil(t *testing.T) {
assert.Equal(t, "", formatCIStatus(nil, false))
assert.Equal(t, "", formatCIStatus(nil, true))
}
func TestFormatCIStatusEmpty(t *testing.T) {
ci := &gitea.CombinedStatus{Statuses: []*gitea.Status{}}
assert.Equal(t, "", formatCIStatus(ci, false))
assert.Equal(t, "", formatCIStatus(ci, true))
}
func TestFormatCIStatusMachineReadable(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
},
}
assert.Equal(t, "success", formatCIStatus(ci, true))
ci.State = gitea.StatusPending
ci.Statuses = []*gitea.Status{
{State: gitea.StatusPending, Context: "build"},
}
assert.Equal(t, "pending", formatCIStatus(ci, true))
}
func TestFormatCIStatusSingle(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
},
}
assert.Equal(t, "✓ lint", formatCIStatus(ci, false))
}
func TestFormatCIStatusMultiple(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusFailure,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
{State: gitea.StatusPending, Context: "build"},
{State: gitea.StatusFailure, Context: "test"},
},
}
assert.Equal(t, "✓ lint, ⏳ build, ❌ test", formatCIStatus(ci, false))
}
func TestFormatCIStatusAllStates(t *testing.T) {
tests := []struct {
state gitea.StatusState
context string
expected string
}{
{gitea.StatusSuccess, "s", "✓ s"},
{gitea.StatusPending, "p", "⏳ p"},
{gitea.StatusWarning, "w", "⚠ w"},
{gitea.StatusError, "e", "✘ e"},
{gitea.StatusFailure, "f", "❌ f"},
}
for _, tt := range tests {
ci := &gitea.CombinedStatus{
State: tt.state,
Statuses: []*gitea.Status{{State: tt.state, Context: tt.context}},
}
assert.Equal(t, tt.expected, formatCIStatus(ci, false), "state: %s", tt.state)
}
}
func TestPullDetailsIncludesHTMLURL(t *testing.T) {
pr := newTestPR(8, "Crm wip")
pr.HTMLURL = "https://gitea.example.com/owner/repo/pulls/8"
out := captureStdout(t, func() {
PullDetails(pr, nil, nil)
})
assert.Contains(t, out, pr.HTMLURL)
}
func TestPullsListWithCIField(t *testing.T) {
prs := []*gitea.PullRequest{
newTestPR(1, "feat: add feature"),
newTestPR(2, "fix: bug fix"),
}
ciStatuses := map[int64]*gitea.CombinedStatus{
1: {
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "ci/build"},
},
},
2: {
State: gitea.StatusFailure,
Statuses: []*gitea.Status{
{State: gitea.StatusFailure, Context: "ci/test"},
},
},
}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
&printablePull{prs[1], &map[int64]string{}, &ciStatuses},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 2)
assert.Equal(t, "1", result[0]["index"])
assert.Equal(t, "success", result[0]["ci"])
assert.Equal(t, "2", result[1]["index"])
assert.Equal(t, "failure", result[1]["ci"])
}
func TestPullsListCIFieldEmpty(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "no ci")}
ciStatuses := map[int64]*gitea.CombinedStatus{}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
assert.Equal(t, "", result[0]["ci"])
}
func TestPullsListNilCIStatusesWithCIField(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "nil ci")}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, nil},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
assert.Equal(t, "", result[0]["ci"])
}
func TestPullsListNoCIFieldNoPanic(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "test")}
require.NoError(t, PullsList(prs, "", []string{"index", "title"}, nil))
}
func TestPullFieldsContainsCI(t *testing.T) {
assert.True(t, slices.Contains(PullFields, "ci"), "PullFields should contain 'ci'")
}
-37
View File
@@ -1,37 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"gitea.dev/sdk"
)
// ReleasesList prints a listing of releases
func ReleasesList(releases []*gitea.Release, output string) error {
t := tableWithHeader(
"Tag-Name",
"Title",
"Published At",
"Status",
"Tar/Zip URL",
)
for _, release := range releases {
status := "released"
if release.IsDraft {
status = "draft"
} else if release.IsPrerelease {
status = "prerelease"
}
t.addRow(
release.TagName,
release.Title,
FormatTime(release.PublishedAt, isMachineReadable(output)),
status,
release.TarURL+"\n"+release.ZipURL,
)
}
return t.print(output)
}
-141
View File
@@ -1,141 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
"gitea.dev/sdk"
)
// ReposList prints a listing of the repos
func ReposList(repos []*gitea.Repository, output string, fields []string) error {
printables := make([]printable, len(repos))
for i, r := range repos {
printables[i] = &printableRepo{r}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
// RepoDetails print an repo formatted to stdout
func RepoDetails(repo *gitea.Repository, topics []string) {
title := "# " + repo.FullName
if repo.Mirror {
title += " (mirror)"
}
if repo.Fork {
title += " (fork)"
}
if repo.Archived {
title += " (archived)"
}
if repo.Empty {
title += " (empty)"
}
title += "\n"
var desc string
if len(repo.Description) != 0 {
desc = fmt.Sprintf("*%s*\n\n", repo.Description)
}
stats := fmt.Sprintf(
"Issues: %d, Stars: %d, Forks: %d, Size: %s\n",
repo.OpenIssues,
repo.Stars,
repo.Forks,
formatSize(int64(repo.Size)),
)
// NOTE: for mirrors, this is the time the mirror was last fetched..
updated := fmt.Sprintf(
"Updated: %s (%s ago)\n",
repo.Updated.Format("2006-01-02 15:04"),
time.Since(repo.Updated).Truncate(time.Minute),
)
urls := fmt.Sprintf(
"- Browse:\t%s\n- Clone:\t%s\n",
repo.HTMLURL,
repo.SSHURL,
)
if len(repo.Website) != 0 {
urls += fmt.Sprintf("- Web:\t%s\n", repo.Website)
}
perm := fmt.Sprintf(
"- Permission:\t%s\n",
formatPermission(repo.Permissions),
)
var tops string
if len(topics) != 0 {
tops = fmt.Sprintf("- Topics:\t%s\n", strings.Join(topics, ", "))
}
outputMarkdown(fmt.Sprintf(
"%s%s\n%s\n%s%s%s%s",
title,
desc,
stats,
updated,
urls,
perm,
tops,
), repo.HTMLURL)
}
// RepoFields are the available fields to print with ReposList()
var RepoFields = []string{
"description",
"forks",
"id",
"name",
"owner",
"stars",
"ssh",
"updated",
"url",
"permission",
"type",
}
type printableRepo struct{ *gitea.Repository }
func (x printableRepo) FormatField(field string, machineReadable bool) string {
switch field {
case "description":
return x.Description
case "forks":
return fmt.Sprintf("%d", x.Forks)
case "id":
return fmt.Sprintf("%d", x.ID)
case "name":
return x.Name
case "owner":
return x.Owner.UserName
case "stars":
return fmt.Sprintf("%d", x.Stars)
case "ssh":
return x.SSHURL
case "updated":
return FormatTime(x.Updated, machineReadable)
case "url":
return x.HTMLURL
case "permission":
return formatPermission(x.Permissions)
case "type":
if x.Fork {
return "fork"
}
if x.Mirror {
return "mirror"
}
return "source"
}
return ""
}
-33
View File
@@ -1,33 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/require"
)
func TestReposListUsesNumericIDField(t *testing.T) {
repos := []*gitea.Repository{{
ID: 123,
Name: "tea",
Owner: &gitea.User{
UserName: "gitea",
},
}}
buf := &bytes.Buffer{}
tbl := tableFromItems([]string{"id", "name"}, []printable{&printableRepo{repos[0]}}, true)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
require.Equal(t, "123", result[0]["id"])
require.Equal(t, "tea", result[0]["name"])
}
-44
View File
@@ -1,44 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// 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)
}
-235
View File
@@ -1,235 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"sort"
"strconv"
"strings"
"github.com/olekukonko/tablewriter"
"gopkg.in/yaml.v3"
)
// table provides infrastructure to easily print (sorted) lists in different formats
type table struct {
headers []string
values [][]string
sortDesc bool // used internally by sortable interface
sortColumn uint // ↑
}
// printable can be implemented for structs to put fields dynamically into a table
type printable interface {
FormatField(field string, machineReadable bool) string
}
// high level api to print a table of items with dynamic fields
func tableFromItems(fields []string, values []printable, machineReadable bool) table {
t := table{headers: fields}
for _, v := range values {
row := make([]string, len(fields))
for i, f := range fields {
row[i] = v.FormatField(f, machineReadable)
}
t.addRowSlice(row)
}
return t
}
func tableWithHeader(header ...string) table {
return table{headers: header}
}
// it's the callers responsibility to ensure row length is equal to header length!
func (t *table) addRow(row ...string) {
t.addRowSlice(row)
}
// it's the callers responsibility to ensure row length is equal to header length!
func (t *table) addRowSlice(row []string) {
t.values = append(t.values, row)
}
func (t *table) sort(column uint, desc bool) {
t.sortColumn = column
t.sortDesc = desc
sort.Stable(t) // stable to allow multiple calls to sort
}
// sortable interface
func (t table) Len() int { return len(t.values) }
func (t table) Swap(i, j int) { t.values[i], t.values[j] = t.values[j], t.values[i] }
func (t table) Less(i, j int) bool {
if t.sortDesc {
i, j = j, i
}
return t.values[i][t.sortColumn] < t.values[j][t.sortColumn]
}
func (t *table) print(output string) error {
return t.fprint(os.Stdout, output)
}
func (t *table) fprint(f io.Writer, output string) error {
switch output {
case "", "table":
return outputTable(f, t.headers, t.values)
case "csv":
return outputDsv(f, t.headers, t.values, ',')
case "simple":
return outputSimple(f, t.headers, t.values)
case "tsv":
return outputDsv(f, t.headers, t.values, '\t')
case "yml", "yaml":
return outputYaml(f, t.headers, t.values)
case "json":
return outputJSON(f, t.headers, t.values)
default:
return fmt.Errorf("unknown output type %q, available types are: csv, simple, table, tsv, yaml, json", output)
}
}
// outputTable prints structured data as table
func outputTable(f io.Writer, headers []string, values [][]string) error {
table := tablewriter.NewWriter(f)
if len(headers) > 0 {
table.Header(headers)
}
for _, value := range values {
if err := table.Append(value); err != nil {
return err
}
}
return table.Render()
}
// outputSimple prints structured data as space delimited value
func outputSimple(f io.Writer, headers []string, values [][]string) error {
for _, value := range values {
if _, err := fmt.Fprintln(f, strings.Join(value, " ")); err != nil {
return err
}
}
return nil
}
// outputDsv prints structured data as delimiter separated value format.
func outputDsv(f io.Writer, headers []string, values [][]string, delimiter rune) error {
writer := csv.NewWriter(f)
writer.Comma = delimiter
if err := writer.Write(headers); err != nil {
return err
}
for _, value := range values {
if err := writer.Write(value); err != nil {
return err
}
}
writer.Flush()
return writer.Error()
}
// outputYaml prints structured data as yaml
func outputYaml(f io.Writer, headers []string, values [][]string) error {
root := &yaml.Node{Kind: yaml.SequenceNode}
for _, value := range values {
row := &yaml.Node{Kind: yaml.MappingNode}
for j, val := range value {
row.Content = append(row.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: headers[j],
})
valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val}
if _, err := strconv.ParseInt(val, 10, 64); err == nil {
valueNode.Tag = "!!int"
} else {
valueNode.Tag = "!!str"
}
row.Content = append(row.Content, valueNode)
}
root.Content = append(root.Content, row)
}
encoder := yaml.NewEncoder(f)
if err := encoder.Encode(root); err != nil {
_ = encoder.Close()
return err
}
return encoder.Close()
}
var (
matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
)
func toSnakeCase(str string) string {
snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
return strings.ToLower(snake)
}
// orderedRow preserves header insertion order when marshaled to JSON.
type orderedRow struct {
keys []string
values map[string]string
}
func (o orderedRow) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range o.keys {
if i > 0 {
buf.WriteByte(',')
}
key, err := json.Marshal(k)
if err != nil {
return nil, err
}
val, err := json.Marshal(o.values[k])
if err != nil {
return nil, err
}
buf.Write(key)
buf.WriteByte(':')
buf.Write(val)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
// outputJSON prints structured data as json, preserving header field order.
func outputJSON(f io.Writer, headers []string, values [][]string) error {
snakeHeaders := make([]string, len(headers))
for i, h := range headers {
snakeHeaders[i] = toSnakeCase(h)
}
rows := make([]orderedRow, 0, len(values))
for _, value := range values {
row := orderedRow{keys: snakeHeaders, values: make(map[string]string, len(headers))}
for j, val := range value {
row.values[snakeHeaders[j]] = val
}
rows = append(rows, row)
}
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
return encoder.Encode(rows)
}
func isMachineReadable(outputFormat string) bool {
switch outputFormat {
case "yml", "yaml", "csv", "tsv", "json":
return true
}
return false
}
-116
View File
@@ -1,116 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/csv"
"encoding/json"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestToSnakeCase(t *testing.T) {
assert.EqualValues(t, "some_test_var_at2d", toSnakeCase("SomeTestVarAt2d"))
}
func TestPrint(t *testing.T) {
tData := &table{
headers: []string{"A", "B"},
values: [][]string{
{"new a", "some bbbb"},
{"AAAAA", "b2"},
{"\"abc", "\"def"},
{"'abc", "de'f"},
{"\\abc", "'def\\"},
},
}
buf := &bytes.Buffer{}
require.NoError(t, tData.fprint(buf, "json"))
result := []struct {
A string
B string
}{}
assert.NoError(t, json.NewDecoder(buf).Decode(&result))
if assert.Len(t, result, 5) {
assert.EqualValues(t, "new a", result[0].A)
assert.EqualValues(t, "some bbbb", result[0].B)
assert.EqualValues(t, "AAAAA", result[1].A)
assert.EqualValues(t, "b2", result[1].B)
assert.EqualValues(t, "\"abc", result[2].A)
assert.EqualValues(t, "\"def", result[2].B)
assert.EqualValues(t, "'abc", result[3].A)
assert.EqualValues(t, "de'f", result[3].B)
assert.EqualValues(t, "\\abc", result[4].A)
assert.EqualValues(t, "'def\\", result[4].B)
}
buf.Reset()
require.NoError(t, tData.fprint(buf, "yaml"))
var yamlResult []map[string]string
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &yamlResult))
assert.Equal(t, []map[string]string{
{"A": "new a", "B": "some bbbb"},
{"A": "AAAAA", "B": "b2"},
{"A": "\"abc", "B": "\"def"},
{"A": "'abc", "B": "de'f"},
{"A": "\\abc", "B": "'def\\"},
}, yamlResult)
}
func TestPrintCSVUsesEscaping(t *testing.T) {
tData := &table{
headers: []string{"A", "B"},
values: [][]string{
{"hello,world", `quote "here"`},
{"multi\nline", "plain"},
},
}
buf := &bytes.Buffer{}
require.NoError(t, tData.fprint(buf, "csv"))
reader := csv.NewReader(bytes.NewReader(buf.Bytes()))
records, err := reader.ReadAll()
require.NoError(t, err)
assert.Equal(t, [][]string{
{"A", "B"},
{"hello,world", `quote "here"`},
{"multi\nline", "plain"},
}, records)
}
func TestPrintJSONPreservesFieldOrder(t *testing.T) {
tData := &table{
headers: []string{"Zebra", "Apple", "Mango"},
values: [][]string{{"z", "a", "m"}},
}
buf := &bytes.Buffer{}
require.NoError(t, tData.fprint(buf, "json"))
// Keys must appear in header order (Zebra, Apple, Mango), not sorted alphabetically
raw := buf.String()
zebraIdx := bytes.Index([]byte(raw), []byte(`"zebra"`))
appleIdx := bytes.Index([]byte(raw), []byte(`"apple"`))
mangoIdx := bytes.Index([]byte(raw), []byte(`"mango"`))
assert.Greater(t, appleIdx, zebraIdx, "apple should appear after zebra")
assert.Greater(t, mangoIdx, appleIdx, "mango should appear after apple")
}
func TestPrintUnknownOutputReturnsError(t *testing.T) {
tData := &table{headers: []string{"A"}, values: [][]string{{"value"}}}
err := tData.fprint(io.Discard, "unknown")
require.ErrorContains(t, err, `unknown output type "unknown"`)
}
-63
View File
@@ -1,63 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// TrackedTimesList print list of tracked times to stdout
func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) error {
printables := make([]printable, len(times))
var totalDuration int64
for i, t := range times {
totalDuration += t.Time
printables[i] = &printableTrackedTime{t, outputType}
}
t := tableFromItems(fields, printables, isMachineReadable(outputType))
if printTotal {
total := make([]string, len(fields))
total[0] = "TOTAL"
total[len(fields)-1] = formatDuration(totalDuration, outputType)
t.addRowSlice(total)
}
return t.print(outputType)
}
// TrackedTimeFields contains all available fields for printing of tracked times.
var TrackedTimeFields = []string{
"id",
"created",
"repo",
"issue",
"user",
"duration",
}
type printableTrackedTime struct {
*gitea.TrackedTime
outputFormat string
}
func (t printableTrackedTime) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", t.ID)
case "created":
return FormatTime(t.Created, machineReadable)
case "repo":
return t.Issue.Repository.FullName
case "issue":
return fmt.Sprintf("#%d", t.Issue.Index)
case "user":
return t.UserName
case "duration":
return formatDuration(t.Time, t.outputFormat)
}
return ""
}
-134
View File
@@ -1,134 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// UserDetails print a formatted user to stdout
func UserDetails(user *gitea.User) {
title := "# " + user.UserName
if user.IsAdmin {
title += " (admin)"
}
if !user.IsActive {
title += " (disabled)"
}
if user.Restricted {
title += " (restricted)"
}
if user.ProhibitLogin {
title += " (login prohibited)"
}
title += "\n"
var desc string
if len(user.Description) != 0 {
desc = fmt.Sprintf("*%s*\n\n", user.Description)
}
var website string
if len(user.Website) != 0 {
website = fmt.Sprintf("%s\n\n", user.Website)
}
stats := fmt.Sprintf(
"Follower Count: %d, Following Count: %d, Starred Repos: %d\n",
user.FollowerCount,
user.FollowingCount,
user.StarredRepoCount,
)
outputMarkdown(fmt.Sprintf(
"%s%s\n%s\n%s",
title,
desc,
website,
stats,
), "")
}
// UserList prints a listing of the users
func UserList(user []*gitea.User, output string, fields []string) error {
printables := make([]printable, len(user))
for i, u := range user {
printables[i] = &printableUser{u}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
// UserFields are the available fields to print with UserList()
var UserFields = []string{
"id",
"login",
"full_name",
"email",
"avatar_url",
"language",
"is_admin",
"restricted",
"prohibit_login",
"location",
"website",
"description",
"visibility",
"activated",
"lastlogin_at",
"created_at",
}
type printableUser struct{ *gitea.User }
func (x printableUser) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", x.ID)
case "login":
if x.IsAdmin {
return fmt.Sprintf("%s (admin)", x.UserName)
}
if !x.IsActive {
return fmt.Sprintf("%s (disabled)", x.UserName)
}
if x.Restricted {
return fmt.Sprintf("%s (restricted)", x.UserName)
}
if x.ProhibitLogin {
return fmt.Sprintf("%s (login prohibited)", x.UserName)
}
return x.UserName
case "full_name":
return x.FullName
case "email":
return x.Email
case "avatar_url":
return x.AvatarURL
case "language":
return x.Language
case "is_admin":
return formatBoolean(x.IsAdmin, !machineReadable)
case "restricted":
return formatBoolean(x.Restricted, !machineReadable)
case "prohibit_login":
return formatBoolean(x.ProhibitLogin, !machineReadable)
case "activated":
return formatBoolean(x.IsActive, !machineReadable)
case "location":
return x.Location
case "website":
return x.Website
case "description":
return x.Description
case "visibility":
return string(x.Visibility)
case "created_at":
return FormatTime(x.Created, machineReadable)
case "lastlogin_at":
return FormatTime(x.LastLogin, machineReadable)
}
return ""
}
-90
View File
@@ -1,90 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strconv"
"strings"
"gitea.dev/sdk"
)
// WebhooksList prints a listing of webhooks
func WebhooksList(hooks []*gitea.Hook, output string) error {
t := tableWithHeader(
"ID",
"Type",
"URL",
"Events",
"Active",
"Updated",
)
for _, hook := range hooks {
var url string
if hook.Config != nil {
url = hook.Config["url"]
}
events := strings.Join(hook.Events, ",")
if len(events) > 40 {
events = events[:37] + "..."
}
active := "✓"
if !hook.Active {
active = "✗"
}
t.addRow(
strconv.FormatInt(hook.ID, 10),
string(hook.Type),
url,
events,
active,
FormatTime(hook.Updated, false),
)
}
return t.print(output)
}
// WebhookDetails prints detailed information about a webhook
func WebhookDetails(hook *gitea.Hook) {
fmt.Printf("# Webhook %d\n\n", hook.ID)
fmt.Printf("- **Type**: %s\n", hook.Type)
fmt.Printf("- **Active**: %t\n", hook.Active)
fmt.Printf("- **Created**: %s\n", FormatTime(hook.Created, false))
fmt.Printf("- **Updated**: %s\n", FormatTime(hook.Updated, false))
if hook.Config != nil {
fmt.Printf("- **URL**: %s\n", hook.Config["url"])
if contentType, ok := hook.Config["content_type"]; ok {
fmt.Printf("- **Content Type**: %s\n", contentType)
}
if method, ok := hook.Config["http_method"]; ok {
fmt.Printf("- **HTTP Method**: %s\n", method)
}
branchFilter := hook.BranchFilter
if branchFilter == "" {
branchFilter = hook.Config["branch_filter"]
}
if branchFilter != "" {
fmt.Printf("- **Branch Filter**: %s\n", branchFilter)
}
if _, hasSecret := hook.Config["secret"]; hasSecret {
fmt.Printf("- **Secret**: (configured)\n")
}
hasAuth := hook.AuthorizationHeader != ""
if !hasAuth {
_, hasAuth = hook.Config["authorization_header"]
}
if hasAuth {
fmt.Printf("- **Authorization Header**: (configured)\n")
}
}
fmt.Printf("- **Events**: %s\n", strings.Join(hook.Events, ", "))
}
-384
View File
@@ -1,384 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"strings"
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
)
func TestWebhooksList(t *testing.T) {
now := time.Now()
hooks := []*gitea.Hook{
{
ID: 1,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
},
Events: []string{"push", "pull_request"},
Active: true,
Updated: now,
},
{
ID: 2,
Type: "slack",
Config: map[string]string{
"url": "https://hooks.slack.com/services/xxx",
},
Events: []string{"push"},
Active: false,
Updated: now,
},
{
ID: 3,
Type: "discord",
Config: nil,
Events: []string{"pull_request", "pull_request_review_approved"},
Active: true,
Updated: now,
},
}
// Test that function doesn't panic with various output formats
outputFormats := []string{"table", "csv", "json", "yaml", "simple", "tsv"}
for _, format := range outputFormats {
t.Run("Format_"+format, func(t *testing.T) {
assert.NoError(t, WebhooksList(hooks, format))
})
}
}
func TestWebhooksListEmpty(t *testing.T) {
// Test with empty hook list
hooks := []*gitea.Hook{}
assert.NoError(t, WebhooksList(hooks, "table"))
}
func TestWebhooksListNil(t *testing.T) {
// Test with nil hook list
assert.NoError(t, WebhooksList(nil, "table"))
}
func TestWebhookDetails(t *testing.T) {
now := time.Now()
tests := []struct {
name string
hook *gitea.Hook
}{
{
name: "Complete webhook",
hook: &gitea.Hook{
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
},
},
{
name: "Minimal webhook",
hook: &gitea.Hook{
ID: 456,
Type: "slack",
Config: map[string]string{"url": "https://hooks.slack.com/xxx"},
Events: []string{"push"},
Active: false,
Created: now,
Updated: now,
},
},
{
name: "Webhook with nil config",
hook: &gitea.Hook{
ID: 789,
Type: "discord",
Config: nil,
Events: []string{"pull_request"},
Active: true,
Created: now,
Updated: now,
},
},
{
name: "Webhook with empty config",
hook: &gitea.Hook{
ID: 999,
Type: "gitea",
Config: map[string]string{},
Events: []string{},
Active: false,
Created: now,
Updated: now,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Should not panic
assert.NotPanics(t, func() {
WebhookDetails(tt.hook)
})
})
}
}
func TestWebhookEventsTruncation(t *testing.T) {
tests := []struct {
name string
events []string
maxLength int
shouldTruncate bool
}{
{
name: "Short events list",
events: []string{"push"},
maxLength: 40,
shouldTruncate: false,
},
{
name: "Medium events list",
events: []string{"push", "pull_request"},
maxLength: 40,
shouldTruncate: false,
},
{
name: "Long events list",
events: []string{"push", "pull_request", "pull_request_review_approved", "pull_request_sync", "issues"},
maxLength: 40,
shouldTruncate: true,
},
{
name: "Very long events list",
events: []string{"push", "pull_request", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_comment", "pull_request_assigned", "pull_request_label"},
maxLength: 40,
shouldTruncate: true,
},
{
name: "Empty events",
events: []string{},
maxLength: 40,
shouldTruncate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eventsStr := strings.Join(tt.events, ",")
if len(eventsStr) > tt.maxLength {
assert.True(t, tt.shouldTruncate, "Events string should be truncated")
truncated := eventsStr[:tt.maxLength-3] + "..."
assert.Contains(t, truncated, "...")
assert.LessOrEqual(t, len(truncated), tt.maxLength)
} else {
assert.False(t, tt.shouldTruncate, "Events string should not be truncated")
}
})
}
}
func TestWebhookActiveStatus(t *testing.T) {
tests := []struct {
name string
active bool
expectedSymbol string
}{
{
name: "Active webhook",
active: true,
expectedSymbol: "✓",
},
{
name: "Inactive webhook",
active: false,
expectedSymbol: "✗",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
symbol := "✓"
if !tt.active {
symbol = "✗"
}
assert.Equal(t, tt.expectedSymbol, symbol)
})
}
}
func TestWebhookConfigHandling(t *testing.T) {
tests := []struct {
name string
config map[string]string
expectedURL string
hasSecret bool
hasAuthHeader bool
}{
{
name: "Config with all fields",
config: map[string]string{
"url": "https://example.com/webhook",
"secret": "my-secret",
"content_type": "json",
"http_method": "post",
},
expectedURL: "https://example.com/webhook",
hasSecret: true,
hasAuthHeader: false,
},
{
name: "Config with minimal fields",
config: map[string]string{
"url": "https://hooks.slack.com/xxx",
},
expectedURL: "https://hooks.slack.com/xxx",
hasSecret: false,
hasAuthHeader: false,
},
{
name: "Nil config",
config: nil,
expectedURL: "",
hasSecret: false,
hasAuthHeader: false,
},
{
name: "Empty config",
config: map[string]string{},
expectedURL: "",
hasSecret: false,
hasAuthHeader: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var url string
if tt.config != nil {
url = tt.config["url"]
}
assert.Equal(t, tt.expectedURL, url)
var hasSecret, hasAuthHeader bool
if tt.config != nil {
_, hasSecret = tt.config["secret"]
_, hasAuthHeader = tt.config["authorization_header"]
}
assert.Equal(t, tt.hasSecret, hasSecret)
assert.Equal(t, tt.hasAuthHeader, hasAuthHeader)
})
}
}
func TestWebhookTableHeaders(t *testing.T) {
expectedHeaders := []string{
"ID",
"Type",
"URL",
"Events",
"Active",
"Updated",
}
// Verify all headers are non-empty and unique
headerSet := make(map[string]bool)
for _, header := range expectedHeaders {
assert.NotEmpty(t, header, "Header should not be empty")
assert.False(t, headerSet[header], "Header %s should be unique", header)
headerSet[header] = true
}
assert.Len(t, expectedHeaders, 6, "Should have exactly 6 headers")
}
func TestWebhookTypeValues(t *testing.T) {
validTypes := []string{
"gitea",
"gogs",
"slack",
"discord",
"dingtalk",
"telegram",
"msteams",
"feishu",
"wechatwork",
"packagist",
}
for _, hookType := range validTypes {
t.Run("Type_"+hookType, func(t *testing.T) {
assert.NotEmpty(t, hookType, "Hook type should not be empty")
})
}
}
func TestWebhookDetailsFormatting(t *testing.T) {
now := time.Now()
hook := &gitea.Hook{
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
}
// Test that all expected fields are included in details
expectedElements := []string{
"123", // webhook ID
"gitea", // webhook type
"true", // active status
"https://example.com/webhook", // URL
"json", // content type
"post", // HTTP method
"main,develop", // branch filter
"(configured)", // secret indicator
"(configured)", // auth header indicator
"push, pull_request, issues", // events list
}
// Verify elements exist (placeholder test)
assert.Greater(t, len(expectedElements), 0, "Should have expected elements")
// This is a functional test - in practice, we'd capture output
// For now, we verify the webhook structure contains expected data
assert.Equal(t, int64(123), hook.ID)
assert.Equal(t, "gitea", hook.Type)
assert.True(t, hook.Active)
assert.Equal(t, "https://example.com/webhook", hook.Config["url"])
assert.Equal(t, "json", hook.Config["content_type"])
assert.Equal(t, "post", hook.Config["http_method"])
assert.Equal(t, "main,develop", hook.BranchFilter)
assert.Contains(t, hook.Config, "secret")
assert.Equal(t, "Bearer token123", hook.AuthorizationHeader)
assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events)
}
-209
View File
@@ -1,209 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"encoding/base64"
"fmt"
"strings"
"time"
"gitea.dev/sdk"
)
// WikiPageFields are all available fields to print with WikiPagesList().
var WikiPageFields = []string{
"title",
"path",
"url",
"sha",
"author",
"updated",
"message",
}
// WikiRevisionFields are all available fields to print with WikiRevisionsList().
var WikiRevisionFields = []string{
"sha",
"message",
"author",
"date",
}
// WikiPagesList prints a listing of wiki pages.
func WikiPagesList(pages []*gitea.WikiPageMetaData, output string, fields []string) error {
if len(pages) == 0 && !isMachineReadable(output) {
fmt.Println("No wiki pages found")
return nil
}
t := wikiPagesTable(pages, fields, isMachineReadable(output))
return t.print(output)
}
// WikiPageDetails prints a wiki page.
func WikiPageDetails(page *gitea.WikiPage, output string) error {
if output == "" {
markdown, err := renderWikiPageMarkdown(page)
if err != nil {
return err
}
return outputMarkdown(markdown, getRepoURL(page.HTMLURL))
}
t := wikiPageDetailsTable(page, isMachineReadable(output))
return t.print(output)
}
// WikiRevisionsList prints a listing of wiki page revisions.
func WikiRevisionsList(revisions []*gitea.WikiCommit, output string, fields []string) error {
t := wikiRevisionsTable(revisions, fields, isMachineReadable(output))
return t.print(output)
}
func wikiPagesTable(pages []*gitea.WikiPageMetaData, fields []string, machineReadable bool) table {
printables := make([]printable, len(pages))
for i, page := range pages {
printables[i] = printableWikiPageMetaData{page}
}
return tableFromItems(fields, printables, machineReadable)
}
func wikiPageDetailsTable(page *gitea.WikiPage, machineReadable bool) table {
content, _ := decodeWikiContent(page.ContentBase64)
fields := []string{"title", "content", "commits", "path", "url", "sha", "author", "updated", "message"}
return tableFromItems(fields, []printable{printableWikiPage{page: page, content: content}}, machineReadable)
}
func wikiRevisionsTable(revisions []*gitea.WikiCommit, fields []string, machineReadable bool) table {
printables := make([]printable, len(revisions))
for i, revision := range revisions {
printables[i] = printableWikiRevision{revision}
}
return tableFromItems(fields, printables, machineReadable)
}
func renderWikiPageMarkdown(page *gitea.WikiPage) (string, error) {
content, err := decodeWikiContent(page.ContentBase64)
if err != nil {
return "", err
}
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
return content, nil
}
func decodeWikiContent(contentBase64 string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(contentBase64)
if err != nil {
return "", err
}
return string(decoded), nil
}
type printableWikiPageMetaData struct{ *gitea.WikiPageMetaData }
type printableWikiPage struct {
page *gitea.WikiPage
content string
}
type printableWikiRevision struct{ *gitea.WikiCommit }
func (x printableWikiPageMetaData) FormatField(field string, machineReadable bool) string {
switch field {
case "title":
return x.Title
case "path":
return x.SubURL
case "url":
return x.HTMLURL
case "sha":
return shortWikiCommitID(x.LastCommit)
case "author":
return wikiCommitAuthor(x.LastCommit)
case "updated":
return wikiCommitDate(x.LastCommit, machineReadable)
case "message":
return wikiCommitMessage(x.LastCommit)
}
return ""
}
func (x printableWikiPage) FormatField(field string, machineReadable bool) string {
switch field {
case "title":
return x.page.Title
case "content":
return x.content
case "commits":
return fmt.Sprintf("%d", x.page.CommitCount)
case "path":
return x.page.SubURL
case "url":
return x.page.HTMLURL
case "sha":
return shortWikiCommitID(x.page.LastCommit)
case "author":
return wikiCommitAuthor(x.page.LastCommit)
case "updated":
return wikiCommitDate(x.page.LastCommit, machineReadable)
case "message":
return wikiCommitMessage(x.page.LastCommit)
}
return ""
}
func (x printableWikiRevision) FormatField(field string, machineReadable bool) string {
switch field {
case "sha":
return shortWikiCommitID(x.WikiCommit)
case "message":
return x.Message
case "author":
return wikiCommitAuthor(x.WikiCommit)
case "date":
return wikiCommitDate(x.WikiCommit, machineReadable)
}
return ""
}
func shortWikiCommitID(commit *gitea.WikiCommit) string {
if commit == nil {
return ""
}
if len(commit.ID) <= 7 {
return commit.ID
}
return commit.ID[:7]
}
func wikiCommitAuthor(commit *gitea.WikiCommit) string {
if commit == nil || commit.Author == nil {
return ""
}
if commit.Author.Name != "" {
return commit.Author.Name
}
return commit.Author.Email
}
func wikiCommitDate(commit *gitea.WikiCommit, machineReadable bool) string {
if commit == nil || commit.Author == nil || commit.Author.Date == "" {
return ""
}
parsed, err := time.Parse(time.RFC3339, commit.Author.Date)
if err != nil {
return commit.Author.Date
}
return FormatTime(parsed, machineReadable)
}
func wikiCommitMessage(commit *gitea.WikiCommit) string {
if commit == nil {
return ""
}
return commit.Message
}
-110
View File
@@ -1,110 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"io"
"os"
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWikiPagesTableJSON(t *testing.T) {
pages := []*gitea.WikiPageMetaData{{
Title: "Home",
HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home",
SubURL: "/octo/tea/wiki/Home",
LastCommit: &gitea.WikiCommit{
ID: "abcdef123456",
Message: "update home",
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "alice"}, Date: "2026-05-21T01:02:03Z"},
},
}}
tbl := wikiPagesTable(pages, []string{"title", "author", "updated", "sha", "url"}, true)
buf := &bytes.Buffer{}
require.NoError(t, tbl.fprint(buf, "json"))
var rows []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.Len(t, rows, 1)
assert.Equal(t, "Home", rows[0]["title"])
assert.Equal(t, "alice", rows[0]["author"])
assert.Equal(t, "2026-05-21T01:02:03Z", rows[0]["updated"])
assert.Equal(t, "abcdef1", rows[0]["sha"])
assert.Equal(t, "https://gitea.example.com/octo/tea/wiki/Home", rows[0]["url"])
}
func TestWikiRevisionsTableJSON(t *testing.T) {
revisions := []*gitea.WikiCommit{{
ID: "1234567890abcdef",
Message: "second revision",
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "bob"}, Date: "2026-05-22T03:04:05Z"},
}}
tbl := wikiRevisionsTable(revisions, []string{"sha", "message", "author", "date"}, true)
buf := &bytes.Buffer{}
require.NoError(t, tbl.fprint(buf, "json"))
var rows []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.Len(t, rows, 1)
assert.Equal(t, "1234567", rows[0]["sha"])
assert.Equal(t, "second revision", rows[0]["message"])
assert.Equal(t, "bob", rows[0]["author"])
assert.Equal(t, "2026-05-22T03:04:05Z", rows[0]["date"])
}
func TestRenderWikiPageMarkdownReturnsDecodedWikiContent(t *testing.T) {
page := &gitea.WikiPage{
Title: "Home",
HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home",
ContentBase64: "IyBIZWxsbyB3aWtp",
LastCommit: &gitea.WikiCommit{
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "carol"}, Date: "2026-05-23T06:07:08Z"},
},
}
out, err := renderWikiPageMarkdown(page)
require.NoError(t, err)
assert.Equal(t, "# Hello wiki\n", out)
}
func TestWikiPagesListEmptyHumanOutput(t *testing.T) {
out := captureStdout(t, func() {
require.NoError(t, WikiPagesList(nil, "", []string{"title"}))
})
assert.Equal(t, "No wiki pages found\n", out)
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
reader, writer, err := os.Pipe()
require.NoError(t, err)
os.Stdout = writer
defer func() {
os.Stdout = oldStdout
}()
done := make(chan string, 1)
go func() {
var outBuf bytes.Buffer
_, _ = io.Copy(&outBuf, reader)
done <- outBuf.String()
}()
fn()
require.NoError(t, writer.Close())
output := <-done
require.NoError(t, reader.Close())
return output
}