Compare commits
54 Commits
v1.26.3-gitcaddy
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
706b39861d | ||
|
|
c71f3eeff4 | ||
|
|
13fc6ec45d | ||
|
|
d15996c3ee | ||
|
|
b27f338d4d | ||
|
|
68c64d1716 | ||
|
|
5e359c6231 | ||
|
|
b4513b55ba | ||
|
|
1b1a4c0903 | ||
|
|
6ed635306c | ||
|
|
d0b565c94e | ||
|
|
dc0d1e0645 | ||
|
|
5e412826b6 | ||
|
|
6c3bd42839 | ||
|
|
45f4f5a6c3 | ||
|
|
3bbd048204 | ||
|
|
15bd1d61c4 | ||
|
|
b569c3f8a8 | ||
|
|
ded40c34c5 | ||
|
|
e53c8fd040 | ||
|
|
a3c1aa3011 | ||
|
|
469551095b | ||
|
|
5ca3661c33 | ||
|
|
a68d691750 | ||
| 3a1075d6a0 | |||
| 6795122e00 | |||
| 2fc3e5a1c7 | |||
| 1af82412c0 | |||
| 5832d93f0a | |||
| 44f04a7866 | |||
| 2ba34c0abb | |||
| 1717a0c45c | |||
| e871e65342 | |||
| 8b8812f81c | |||
| 67ff066157 | |||
| 3fb751bc24 | |||
| 6cfd51e4c7 | |||
| 659e08da6c | |||
| d664ce29d8 | |||
| 4580e5c87f | |||
| 11b2ee48e9 | |||
| 85d73a2d85 | |||
| 54510ce582 | |||
| 1986d90df0 | |||
| 5b0442d357 | |||
| d44fea18d5 | |||
| e57b4f1654 | |||
| 69d7c72ba8 | |||
|
|
919746c756 | ||
|
|
853ff29ae2 | ||
|
|
7292421334 | ||
|
|
84adad19bf | ||
|
|
01c9563d1d | ||
| c7a7d8cd67 |
@@ -22,7 +22,7 @@ jobs:
|
||||
# Lint job - must pass
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# Unit tests with SQLite (no external database needed)
|
||||
test-unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
# Integration tests with PostgreSQL
|
||||
test-pgsql:
|
||||
name: Integration Tests (PostgreSQL)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux-latest
|
||||
services:
|
||||
pgsql:
|
||||
image: postgres:15
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
# Create release job - runs first to create the release before build jobs upload
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
outputs:
|
||||
release_id: ${{ steps.create.outputs.release_id }}
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
# Try to get existing release first
|
||||
EXISTING=$(curl -sf \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$TAG" 2>/dev/null || echo "")
|
||||
"https://direct.git.marketally.com/api/v1/repos/${{ github.repository }}/releases/tags/$TAG" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$EXISTING" | grep -q '"id":[0-9]'; then
|
||||
RELEASE_ID=$(echo "$EXISTING" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name":"'"$TAG"'","name":"Gitea '"$TAG"'","body":"Official release of Gitea '"$TAG"'.","draft":false,"prerelease":false}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" 2>&1)
|
||||
"https://direct.git.marketally.com/api/v1/repos/${{ github.repository }}/releases" 2>&1)
|
||||
|
||||
if echo "$RESPONSE" | grep -q '"id":[0-9]'; then
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
# Build job for binaries
|
||||
build:
|
||||
name: Build Binaries
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux-latest
|
||||
needs: [lint, create-release]
|
||||
if: always() && needs.lint.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped')
|
||||
strategy:
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
UPLOAD_RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
-F "attachment=@$file" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$filename" 2>&1 || echo "")
|
||||
"https://direct.git.marketally.com/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$filename" 2>&1 || echo "")
|
||||
|
||||
if echo "$UPLOAD_RESPONSE" | grep -q '"id":[0-9]'; then
|
||||
echo "✓ Uploaded $filename successfully"
|
||||
|
||||
154
cmd/mcp-server/main.go
Normal file
154
cmd/mcp-server/main.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Gitea MCP Server - Model Context Protocol server for Gitea Actions
|
||||
//
|
||||
// This standalone server implements the MCP protocol over stdio,
|
||||
// proxying requests to a Gitea instance's /api/v2/mcp endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// gitea-mcp-server --url https://git.example.com --token YOUR_API_TOKEN
|
||||
//
|
||||
// Configure in Claude Code's settings.json:
|
||||
//
|
||||
// {
|
||||
// "mcpServers": {
|
||||
// "gitea": {
|
||||
// "command": "gitea-mcp-server",
|
||||
// "args": ["--url", "https://git.example.com", "--token", "YOUR_TOKEN"]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
)
|
||||
|
||||
var (
|
||||
giteaURL string
|
||||
giteaToken string
|
||||
debug bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&giteaURL, "url", "", "Gitea server URL (e.g., https://git.example.com)")
|
||||
flag.StringVar(&giteaToken, "token", "", "Gitea API token")
|
||||
flag.BoolVar(&debug, "debug", false, "Enable debug logging to stderr")
|
||||
flag.Parse()
|
||||
|
||||
// Also check environment variables
|
||||
if giteaURL == "" {
|
||||
giteaURL = os.Getenv("GITEA_URL")
|
||||
}
|
||||
if giteaToken == "" {
|
||||
giteaToken = os.Getenv("GITEA_TOKEN")
|
||||
}
|
||||
|
||||
if giteaURL == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --url or GITEA_URL is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
debugLog("Gitea MCP Server starting")
|
||||
debugLog("Connecting to: %s", giteaURL)
|
||||
|
||||
// Read JSON-RPC messages from stdin, forward to Gitea, write responses to stdout
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
debugLog("EOF received, exiting")
|
||||
break
|
||||
}
|
||||
debugLog("Read error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
debugLog("Received: %s", string(line))
|
||||
|
||||
// Forward to Gitea's MCP endpoint
|
||||
response, err := forwardToGitea(line)
|
||||
if err != nil {
|
||||
debugLog("Forward error: %v", err)
|
||||
// Send error response
|
||||
errorResp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": nil,
|
||||
"error": map[string]any{
|
||||
"code": -32603,
|
||||
"message": "Internal error",
|
||||
"data": err.Error(),
|
||||
},
|
||||
}
|
||||
writeResponse(errorResp)
|
||||
continue
|
||||
}
|
||||
|
||||
debugLog("Response: %s", string(response))
|
||||
|
||||
// Write response to stdout
|
||||
fmt.Println(string(response))
|
||||
}
|
||||
}
|
||||
|
||||
func forwardToGitea(request []byte) ([]byte, error) {
|
||||
mcpURL := giteaURL + "/api/v2/mcp"
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, mcpURL, bytes.NewReader(request))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if giteaToken != "" {
|
||||
req.Header.Set("Authorization", "token "+giteaToken)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("http status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func writeResponse(resp any) {
|
||||
data, _ := json.Marshal(resp)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func debugLog(format string, args ...any) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
4
go.mod
4
go.mod
@@ -25,6 +25,7 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
|
||||
github.com/Masterminds/semver/v3 v3.4.0
|
||||
github.com/ProtonMail/go-crypto v1.3.0
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0
|
||||
@@ -145,7 +146,6 @@ require (
|
||||
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/DataDog/zstd v1.5.7 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
@@ -314,7 +314,7 @@ replace github.com/nektos/act => gitea.com/gitea/act v0.261.7-0.20251003180512-a
|
||||
replace git.sr.ht/~mariusor/go-xsd-duration => gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078
|
||||
|
||||
// Use GitCaddy fork with capability support
|
||||
replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.3
|
||||
replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.7
|
||||
|
||||
exclude github.com/gofrs/uuid v3.2.0+incompatible
|
||||
|
||||
|
||||
6
go.sum
6
go.sum
@@ -29,8 +29,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
git.marketally.com/gitcaddy/actions-proto-go v0.5.3 h1:tf625YKv1Bykxr9CIcoqilC2MWiO/yBN3srlJYnFQqM=
|
||||
git.marketally.com/gitcaddy/actions-proto-go v0.5.3/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
|
||||
git.marketally.com/gitcaddy/actions-proto-go v0.5.7 h1:RUbafr3Vkw2l4WfSwa+oF+Ihakbm05W0FlAmXuQrDJc=
|
||||
git.marketally.com/gitcaddy/actions-proto-go v0.5.7/go.mod h1:RPu21UoRD3zSAujoZR6LJwuVNa2uFRBveadslczCRfQ=
|
||||
gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763 h1:ohdxegvslDEllZmRNDqpKun6L4Oq81jNdEDtGgHEV2c=
|
||||
gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok=
|
||||
gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:BAFmdZpRW7zMQZQDClaCWobRj9uL1MR3MzpCVJvc5s4=
|
||||
@@ -78,8 +78,6 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
|
||||
github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
|
||||
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
|
||||
github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
|
||||
@@ -64,6 +64,8 @@ type ActionRunner struct {
|
||||
Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"`
|
||||
// CapabilitiesJSON stores structured capability information for AI consumption
|
||||
CapabilitiesJSON string `xorm:"TEXT"`
|
||||
// BandwidthTestRequestedAt tracks when a bandwidth test was requested by admin
|
||||
BandwidthTestRequestedAt timeutil.TimeStamp `xorm:"index"`
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
|
||||
@@ -144,10 +144,10 @@ func GetMemberPublicVisibility(ctx context.Context, orgID, userID int64) (bool,
|
||||
|
||||
// OrgOverviewStats represents statistics for the organization overview
|
||||
type OrgOverviewStats struct {
|
||||
MemberCount int64
|
||||
RepoCount int64
|
||||
PublicRepoCount int64
|
||||
TeamCount int64
|
||||
TotalRepos int64
|
||||
TotalMembers int64
|
||||
TotalTeams int64
|
||||
TotalStars int64
|
||||
}
|
||||
|
||||
// GetOrgMemberAndTeamCounts returns member and team counts for an organization
|
||||
|
||||
@@ -142,6 +142,12 @@ func UpdatePagesDomain(ctx context.Context, domain *PagesDomain) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ActivatePagesDomainSSL sets SSL status to active for a domain
|
||||
func ActivatePagesDomainSSL(ctx context.Context, id int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(id).Cols("ssl_status").Update(&PagesDomain{SSLStatus: SSLStatusActive})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePagesDomain deletes a pages domain
|
||||
func DeletePagesDomain(ctx context.Context, id int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(PagesDomain))
|
||||
|
||||
@@ -968,6 +968,17 @@ func CountNullArchivedRepository(ctx context.Context) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(builder.IsNull{"is_archived"}).Count(new(Repository))
|
||||
}
|
||||
|
||||
// CountOrgRepoStars returns the total number of stars across all repos owned by an organization
|
||||
func CountOrgRepoStars(ctx context.Context, orgID int64) (int64, error) {
|
||||
var total int64
|
||||
_, err := db.GetEngine(ctx).
|
||||
Table("repository").
|
||||
Where("owner_id = ?", orgID).
|
||||
Select("COALESCE(SUM(num_stars), 0)").
|
||||
Get(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
// FixNullArchivedRepository sets is_archived to false where it is null
|
||||
func FixNullArchivedRepository(ctx context.Context) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(builder.IsNull{"is_archived"}).Cols("is_archived").NoAutoTime().Update(&Repository{
|
||||
|
||||
@@ -147,9 +147,10 @@ type User struct {
|
||||
RepoAdminChangeTeamAccess bool `xorm:"NOT NULL DEFAULT false"`
|
||||
|
||||
// Preferences
|
||||
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
|
||||
Theme string `xorm:"NOT NULL DEFAULT ''"`
|
||||
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
|
||||
Theme string `xorm:"NOT NULL DEFAULT ''"`
|
||||
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ShowHeatmapOnProfile bool `xorm:"NOT NULL DEFAULT false"`
|
||||
}
|
||||
|
||||
// Meta defines the meta information of a user, to be stored in the K/V table
|
||||
|
||||
137
models/user/user_pinned.go
Normal file
137
models/user/user_pinned.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// PinnedRepo represents a pinned repository for a user's profile
|
||||
type PinnedRepo struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"INDEX NOT NULL"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
DisplayOrder int `xorm:"DEFAULT 0"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
|
||||
Repo any `xorm:"-"` // Will be loaded by caller to avoid import cycle
|
||||
}
|
||||
|
||||
// TableName returns the table name for PinnedRepo
|
||||
func (p *PinnedRepo) TableName() string {
|
||||
return "user_pinned_repo"
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(PinnedRepo))
|
||||
}
|
||||
|
||||
// MaxPinnedRepos is the maximum number of repos a user can pin
|
||||
const MaxPinnedRepos = 6
|
||||
|
||||
// GetPinnedRepos returns all pinned repos for a user
|
||||
func GetPinnedRepos(ctx context.Context, userID int64) ([]*PinnedRepo, error) {
|
||||
pinnedRepos := make([]*PinnedRepo, 0, MaxPinnedRepos)
|
||||
err := db.GetEngine(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
OrderBy("display_order ASC, id ASC").
|
||||
Find(&pinnedRepos)
|
||||
return pinnedRepos, err
|
||||
}
|
||||
|
||||
// CountPinnedRepos returns the count of pinned repos for a user
|
||||
func CountPinnedRepos(ctx context.Context, userID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("user_id = ?", userID).Count(new(PinnedRepo))
|
||||
}
|
||||
|
||||
// IsRepoPinnedByUser checks if a repo is pinned by a user
|
||||
func IsRepoPinnedByUser(ctx context.Context, userID, repoID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Exist(new(PinnedRepo))
|
||||
}
|
||||
|
||||
// PinRepoToUser pins a repo to a user's profile
|
||||
func PinRepoToUser(ctx context.Context, userID, repoID int64) error {
|
||||
// Check if already pinned
|
||||
exists, err := IsRepoPinnedByUser(ctx, userID, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil // Already pinned
|
||||
}
|
||||
|
||||
// Check max limit
|
||||
count, err := CountPinnedRepos(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= MaxPinnedRepos {
|
||||
return ErrPinnedRepoLimit{UserID: userID, Limit: MaxPinnedRepos}
|
||||
}
|
||||
|
||||
// Get next display order
|
||||
var maxOrder int
|
||||
_, err = db.GetEngine(ctx).
|
||||
Table("user_pinned_repo").
|
||||
Where("user_id = ?", userID).
|
||||
Select("COALESCE(MAX(display_order), 0)").
|
||||
Get(&maxOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pinnedRepo := &PinnedRepo{
|
||||
UserID: userID,
|
||||
RepoID: repoID,
|
||||
DisplayOrder: maxOrder + 1,
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).Insert(pinnedRepo)
|
||||
return err
|
||||
}
|
||||
|
||||
// UnpinRepoFromUser unpins a repo from a user's profile
|
||||
func UnpinRepoFromUser(ctx context.Context, userID, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Delete(new(PinnedRepo))
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePinnedRepoOrder updates the display order of pinned repos
|
||||
func UpdatePinnedRepoOrder(ctx context.Context, userID int64, repoIDs []int64) error {
|
||||
for i, repoID := range repoIDs {
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("user_id = ? AND repo_id = ?", userID, repoID).
|
||||
Cols("display_order").
|
||||
Update(&PinnedRepo{DisplayOrder: i})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePinnedReposByRepoID deletes all pins for a repo (when repo is deleted)
|
||||
func DeletePinnedReposByRepoID(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(PinnedRepo))
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrPinnedRepoLimit represents an error when user has reached pin limit
|
||||
type ErrPinnedRepoLimit struct {
|
||||
UserID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (err ErrPinnedRepoLimit) Error() string {
|
||||
return "user has reached the maximum number of pinned repositories"
|
||||
}
|
||||
|
||||
// IsErrPinnedRepoLimit checks if error is ErrPinnedRepoLimit
|
||||
func IsErrPinnedRepoLimit(err error) bool {
|
||||
_, ok := err.(ErrPinnedRepoLimit)
|
||||
return ok
|
||||
}
|
||||
@@ -16,12 +16,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMinioStorageIterator(t *testing.T) {
|
||||
if os.Getenv("CI") == "" {
|
||||
t.Skip("minioStorage not present outside of CI")
|
||||
return
|
||||
}
|
||||
testStorageIterator(t, setting.MinioStorageType, &setting.Storage{
|
||||
// minioTestConfig returns the Minio storage config for tests.
|
||||
func minioTestConfig() *setting.Storage {
|
||||
return &setting.Storage{
|
||||
MinioConfig: setting.MinioStorageConfig{
|
||||
Endpoint: "minio:9000",
|
||||
AccessKeyID: "123456",
|
||||
@@ -29,7 +26,36 @@ func TestMinioStorageIterator(t *testing.T) {
|
||||
Bucket: "gitea",
|
||||
Location: "us-east-1",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// skipIfNoMinio skips the test if Minio service is not available.
|
||||
// In CI, we always skip since Minio is not available in our runner environment.
|
||||
func skipIfNoMinio(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Skip("minioStorage requires Minio service which is not available in CI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinioStorageIterator(t *testing.T) {
|
||||
skipIfNoMinio(t)
|
||||
|
||||
cfg := minioTestConfig()
|
||||
// Try to create storage to verify Minio is available
|
||||
s, err := NewStorage(setting.MinioStorageType, cfg)
|
||||
if err != nil {
|
||||
t.Skipf("minioStorage not available: %v", err)
|
||||
}
|
||||
// Clean up any existing test files
|
||||
_ = s.Delete("a/1.txt")
|
||||
_ = s.Delete("ab/1.txt")
|
||||
_ = s.Delete("b/1.txt")
|
||||
_ = s.Delete("b/2.txt")
|
||||
_ = s.Delete("b/3.txt")
|
||||
_ = s.Delete("b/x 4.txt")
|
||||
|
||||
testStorageIterator(t, setting.MinioStorageType, cfg)
|
||||
}
|
||||
|
||||
func TestMinioStoragePath(t *testing.T) {
|
||||
@@ -67,10 +93,8 @@ func TestMinioStoragePath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestS3StorageBadRequest(t *testing.T) {
|
||||
if os.Getenv("CI") == "" {
|
||||
t.Skip("S3Storage not present outside of CI")
|
||||
return
|
||||
}
|
||||
skipIfNoMinio(t)
|
||||
|
||||
cfg := &setting.Storage{
|
||||
MinioConfig: setting.MinioStorageConfig{
|
||||
Endpoint: "minio:9000",
|
||||
|
||||
@@ -3,10 +3,36 @@
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// BandwidthInfo holds network bandwidth test results
|
||||
type BandwidthInfo struct {
|
||||
DownloadMbps float64 `json:"download_mbps"`
|
||||
UploadMbps float64 `json:"upload_mbps,omitempty"`
|
||||
Latency float64 `json:"latency_ms,omitempty"`
|
||||
TestedAt time.Time `json:"tested_at"`
|
||||
}
|
||||
|
||||
// DiskInfo holds disk space information for a runner
|
||||
type DiskInfo struct {
|
||||
Total uint64 `json:"total_bytes"`
|
||||
Free uint64 `json:"free_bytes"`
|
||||
Used uint64 `json:"used_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
}
|
||||
|
||||
// DistroInfo holds Linux distribution information
|
||||
type DistroInfo struct {
|
||||
ID string `json:"id,omitempty"` // e.g., "ubuntu", "debian", "fedora"
|
||||
VersionID string `json:"version_id,omitempty"` // e.g., "24.04", "12"
|
||||
PrettyName string `json:"pretty_name,omitempty"` // e.g., "Ubuntu 24.04 LTS"
|
||||
}
|
||||
|
||||
// RunnerCapability represents the detailed capabilities of a runner
|
||||
type RunnerCapability struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Distro *DistroInfo `json:"distro,omitempty"`
|
||||
Docker bool `json:"docker"`
|
||||
DockerCompose bool `json:"docker_compose"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
@@ -14,6 +40,9 @@ type RunnerCapability struct {
|
||||
Tools map[string][]string `json:"tools,omitempty"`
|
||||
Features *CapabilityFeatures `json:"features,omitempty"`
|
||||
Limitations []string `json:"limitations,omitempty"`
|
||||
Disk *DiskInfo `json:"disk,omitempty"`
|
||||
Bandwidth *BandwidthInfo `json:"bandwidth,omitempty"`
|
||||
SuggestedLabels []string `json:"suggested_labels,omitempty"`
|
||||
}
|
||||
|
||||
// CapabilityFeatures represents feature support flags
|
||||
|
||||
@@ -179,10 +179,10 @@ type OrgOverview struct {
|
||||
|
||||
// OrgOverviewStats represents organization statistics
|
||||
type OrgOverviewStats struct {
|
||||
MemberCount int64 `json:"member_count"`
|
||||
RepoCount int64 `json:"repo_count"`
|
||||
PublicRepoCount int64 `json:"public_repo_count"`
|
||||
TeamCount int64 `json:"team_count"`
|
||||
TotalRepos int64 `json:"total_repos"`
|
||||
TotalMembers int64 `json:"total_members"`
|
||||
TotalTeams int64 `json:"total_teams"`
|
||||
TotalStars int64 `json:"total_stars"`
|
||||
}
|
||||
|
||||
// OrgProfileContent represents the organization profile content
|
||||
|
||||
@@ -38,6 +38,8 @@ type AddPagesDomainOption struct {
|
||||
// The custom domain to add
|
||||
// required: true
|
||||
Domain string `json:"domain" binding:"Required"`
|
||||
// Mark SSL as handled externally (e.g., by Cloudflare)
|
||||
SSLExternal bool `json:"ssl_external"`
|
||||
}
|
||||
|
||||
// PagesInfo represents the full pages information for a repository
|
||||
|
||||
@@ -46,10 +46,19 @@ func NewFuncMap() template.FuncMap {
|
||||
"PathEscapeSegments": util.PathEscapeSegments,
|
||||
|
||||
// utils
|
||||
"StringUtils": NewStringUtils,
|
||||
"SliceUtils": NewSliceUtils,
|
||||
"JsonUtils": NewJsonUtils,
|
||||
"DateUtils": NewDateUtils,
|
||||
"StringUtils": NewStringUtils,
|
||||
"SliceUtils": NewSliceUtils,
|
||||
"newSlice": func() []any { return []any{} },
|
||||
"Append": func(s []any, v any) []any { return append(s, v) },
|
||||
"Int64ToFloat64": func(i uint64) float64 { return float64(i) },
|
||||
"DivideFloat64": func(a, b float64) float64 {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
},
|
||||
"JsonUtils": NewJsonUtils,
|
||||
"DateUtils": NewDateUtils,
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// svg / avatar / icon / color
|
||||
|
||||
@@ -33,3 +33,17 @@ func (su *SliceUtils) Contains(s, v any) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Append appends an element to a slice and returns the new slice
|
||||
func (su *SliceUtils) Append(s, v any) any {
|
||||
if s == nil {
|
||||
return []any{v}
|
||||
}
|
||||
sv := reflect.ValueOf(s)
|
||||
if sv.Kind() != reflect.Slice {
|
||||
panic(fmt.Sprintf("invalid type, expected slice, but got: %T", s))
|
||||
}
|
||||
// Create a new slice with the appended element
|
||||
newSlice := reflect.Append(sv, reflect.ValueOf(v))
|
||||
return newSlice.Interface()
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ func (su *StringUtils) ToUpper(s string) string {
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
func (su *StringUtils) ToLower(s string) string {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
func (su *StringUtils) TrimPrefix(s, prefix string) string {
|
||||
return strings.TrimPrefix(s, prefix)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -115,10 +115,10 @@ func (s *Service) Declare(
|
||||
req *connect.Request[runnerv1.DeclareRequest],
|
||||
) (*connect.Response[runnerv1.DeclareResponse], error) {
|
||||
runner := GetRunner(ctx)
|
||||
runner.AgentLabels = req.Msg.Labels
|
||||
runner.Version = req.Msg.Version
|
||||
runner.CapabilitiesJSON = req.Msg.CapabilitiesJson
|
||||
if err := actions_model.UpdateRunner(ctx, runner, "agent_labels", "version", "capabilities_json"); err != nil {
|
||||
if err := actions_model.UpdateRunner(ctx, runner, "version", "capabilities_json"); err != nil {
|
||||
log.Error("Declare: failed to update runner %d: %v", runner.ID, err)
|
||||
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
|
||||
}
|
||||
|
||||
@@ -141,6 +141,15 @@ func (s *Service) FetchTask(
|
||||
) (*connect.Response[runnerv1.FetchTaskResponse], error) {
|
||||
runner := GetRunner(ctx)
|
||||
|
||||
// Update runner capabilities if provided
|
||||
if capsJSON := req.Msg.GetCapabilitiesJson(); capsJSON != "" && capsJSON != runner.CapabilitiesJSON {
|
||||
runner.CapabilitiesJSON = capsJSON
|
||||
if err := actions_model.UpdateRunner(ctx, runner, "capabilities_json"); err != nil {
|
||||
log.Warn("failed to update runner capabilities: %v", err)
|
||||
// Don't return error, just log warning - capabilities update is not critical
|
||||
}
|
||||
}
|
||||
|
||||
var task *runnerv1.Task
|
||||
tasksVersion := req.Msg.TasksVersion // task version from runner
|
||||
latestVersion, err := actions_model.GetTasksVersionByScope(ctx, runner.OwnerID, runner.RepoID)
|
||||
@@ -167,9 +176,22 @@ func (s *Service) FetchTask(
|
||||
task = t
|
||||
}
|
||||
}
|
||||
|
||||
// Check if admin requested a bandwidth test
|
||||
requestBandwidthTest := false
|
||||
if runner.BandwidthTestRequestedAt > 0 {
|
||||
requestBandwidthTest = true
|
||||
// Clear the request after sending
|
||||
runner.BandwidthTestRequestedAt = 0
|
||||
if err := actions_model.UpdateRunner(ctx, runner, "bandwidth_test_requested_at"); err != nil {
|
||||
log.Warn("failed to clear bandwidth test request: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
res := connect.NewResponse(&runnerv1.FetchTaskResponse{
|
||||
Task: task,
|
||||
TasksVersion: latestVersion,
|
||||
Task: task,
|
||||
TasksVersion: latestVersion,
|
||||
RequestBandwidthTest: requestBandwidthTest,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -92,10 +92,10 @@ func GetOverview(ctx *context.APIContext) {
|
||||
PublicMembers: apiPublicMembers,
|
||||
TotalMembers: totalMembers,
|
||||
Stats: &api.OrgOverviewStats{
|
||||
MemberCount: stats.MemberCount,
|
||||
RepoCount: stats.RepoCount,
|
||||
PublicRepoCount: stats.PublicRepoCount,
|
||||
TeamCount: stats.TeamCount,
|
||||
TotalRepos: stats.TotalRepos,
|
||||
TotalMembers: stats.TotalMembers,
|
||||
TotalTeams: stats.TotalTeams,
|
||||
TotalStars: stats.TotalStars,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ func AddPagesDomain(ctx *context.APIContext) {
|
||||
|
||||
form := web.GetForm(ctx).(*api.AddPagesDomainOption)
|
||||
|
||||
domain, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, form.Domain)
|
||||
domain, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, form.Domain, form.SSLExternal)
|
||||
if err != nil {
|
||||
if repo_model.IsErrPagesDomainAlreadyExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "Domain already exists")
|
||||
|
||||
@@ -86,6 +86,9 @@ func Routes() *web.Router {
|
||||
m.Get("/component/{component}", ComponentHealthCheck)
|
||||
})
|
||||
|
||||
// MCP Protocol endpoint for AI tool integration
|
||||
m.Post("/mcp", MCPHandler)
|
||||
|
||||
// Operation progress endpoints (SSE)
|
||||
m.Group("/operations", func() {
|
||||
m.Get("/{id}/progress", OperationProgress)
|
||||
@@ -140,6 +143,8 @@ func Routes() *web.Router {
|
||||
// Actions v2 API - AI-friendly runner capability discovery
|
||||
m.Group("/repos/{owner}/{repo}/actions", func() {
|
||||
m.Get("/runners/capabilities", repoAssignment(), GetActionsCapabilities)
|
||||
m.Get("/runners/status", repoAssignment(), ListRunnersStatus)
|
||||
m.Get("/runners/{runner_id}/status", repoAssignment(), GetRunnerStatus)
|
||||
m.Post("/workflows/validate", repoAssignment(), reqToken(), web.Bind(api.WorkflowValidationRequest{}), ValidateWorkflow)
|
||||
})
|
||||
|
||||
|
||||
781
routers/api/v2/mcp.go
Normal file
781
routers/api/v2/mcp.go
Normal file
@@ -0,0 +1,781 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/actions"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// RawMessage is a raw encoded JSON value (equivalent to encoding/json.RawMessage)
|
||||
type RawMessage []byte
|
||||
|
||||
// MCP Protocol Types (JSON-RPC 2.0)
|
||||
|
||||
type MCPRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type MCPResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *MCPError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MCPError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// MCP Tool definitions
|
||||
type MCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type MCPToolsListResult struct {
|
||||
Tools []MCPTool `json:"tools"`
|
||||
}
|
||||
|
||||
type MCPToolCallParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
type MCPToolCallResult struct {
|
||||
Content []MCPContent `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
type MCPContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type MCPInitializeParams struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities map[string]any `json:"capabilities"`
|
||||
ClientInfo map[string]string `json:"clientInfo"`
|
||||
}
|
||||
|
||||
type MCPInitializeResult struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities map[string]any `json:"capabilities"`
|
||||
ServerInfo map[string]string `json:"serverInfo"`
|
||||
}
|
||||
|
||||
// Available MCP tools
|
||||
var mcpTools = []MCPTool{
|
||||
{
|
||||
Name: "list_runners",
|
||||
Description: "List all runners with their status, capabilities, and current workload",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository owner (optional, lists global runners if omitted)",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name (optional)",
|
||||
},
|
||||
"status": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"online", "offline", "all"},
|
||||
"description": "Filter by runner status",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_runner",
|
||||
Description: "Get detailed information about a specific runner including capabilities, disk space, and bandwidth",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"runner_id": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The runner ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"runner_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_workflow_runs",
|
||||
Description: "List workflow runs for a repository with status and timing information",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository owner",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"status": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"pending", "running", "success", "failure", "cancelled", "all"},
|
||||
"description": "Filter by run status",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of runs to return (default 20)",
|
||||
},
|
||||
},
|
||||
"required": []string{"owner", "repo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_workflow_run",
|
||||
Description: "Get detailed information about a specific workflow run including all jobs and their status",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository owner",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"run_id": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The workflow run ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"owner", "repo", "run_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_job_logs",
|
||||
Description: "Get logs from a specific job in a workflow run",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository owner",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"job_id": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The job ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"owner", "repo", "job_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_releases",
|
||||
Description: "List releases for a repository",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository owner",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of releases to return (default 10)",
|
||||
},
|
||||
},
|
||||
"required": []string{"owner", "repo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_release",
|
||||
Description: "Get details of a specific release including all assets",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository owner",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"tag": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Release tag (e.g., v1.0.0)",
|
||||
},
|
||||
},
|
||||
"required": []string{"owner", "repo", "tag"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// MCPHandler handles MCP protocol requests
|
||||
// @Summary MCP Protocol Endpoint
|
||||
// @Description Handles Model Context Protocol requests for AI tool integration
|
||||
// @Tags mcp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} MCPResponse
|
||||
// @Router /mcp [post]
|
||||
func MCPHandler(ctx *context.APIContext) {
|
||||
body, err := io.ReadAll(ctx.Req.Body)
|
||||
if err != nil {
|
||||
sendMCPError(ctx, nil, -32700, "Parse error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req MCPRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
sendMCPError(ctx, nil, -32700, "Parse error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.JSONRPC != "2.0" {
|
||||
sendMCPError(ctx, req.ID, -32600, "Invalid Request", "jsonrpc must be 2.0")
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("MCP request: method=%s id=%v", req.Method, req.ID)
|
||||
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
handleInitialize(ctx, &req)
|
||||
case "tools/list":
|
||||
handleToolsList(ctx, &req)
|
||||
case "tools/call":
|
||||
handleToolsCall(ctx, &req)
|
||||
case "ping":
|
||||
sendMCPResult(ctx, req.ID, map[string]string{})
|
||||
default:
|
||||
sendMCPError(ctx, req.ID, -32601, "Method not found", "Unknown method: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func handleInitialize(ctx *context.APIContext, req *MCPRequest) {
|
||||
result := MCPInitializeResult{
|
||||
ProtocolVersion: "2024-11-05",
|
||||
Capabilities: map[string]any{
|
||||
"tools": map[string]any{},
|
||||
},
|
||||
ServerInfo: map[string]string{
|
||||
"name": "gitea-actions",
|
||||
"version": setting.AppVer,
|
||||
},
|
||||
}
|
||||
sendMCPResult(ctx, req.ID, result)
|
||||
}
|
||||
|
||||
func handleToolsList(ctx *context.APIContext, req *MCPRequest) {
|
||||
result := MCPToolsListResult{Tools: mcpTools}
|
||||
sendMCPResult(ctx, req.ID, result)
|
||||
}
|
||||
|
||||
func handleToolsCall(ctx *context.APIContext, req *MCPRequest) {
|
||||
var params MCPToolCallParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
sendMCPError(ctx, req.ID, -32602, "Invalid params", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var result any
|
||||
var err error
|
||||
|
||||
switch params.Name {
|
||||
case "list_runners":
|
||||
result, err = toolListRunners(ctx, params.Arguments)
|
||||
case "get_runner":
|
||||
result, err = toolGetRunner(ctx, params.Arguments)
|
||||
case "list_workflow_runs":
|
||||
result, err = toolListWorkflowRuns(ctx, params.Arguments)
|
||||
case "get_workflow_run":
|
||||
result, err = toolGetWorkflowRun(ctx, params.Arguments)
|
||||
case "get_job_logs":
|
||||
result, err = toolGetJobLogs(ctx, params.Arguments)
|
||||
case "list_releases":
|
||||
result, err = toolListReleases(ctx, params.Arguments)
|
||||
case "get_release":
|
||||
result, err = toolGetRelease(ctx, params.Arguments)
|
||||
default:
|
||||
sendMCPError(ctx, req.ID, -32602, "Unknown tool", params.Name)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
sendMCPToolResult(ctx, req.ID, err.Error(), true)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert result to JSON text
|
||||
jsonBytes, _ := json.MarshalIndent(result, "", " ")
|
||||
sendMCPToolResult(ctx, req.ID, string(jsonBytes), false)
|
||||
}
|
||||
|
||||
func sendMCPResult(ctx *context.APIContext, id, result any) {
|
||||
ctx.JSON(http.StatusOK, MCPResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
|
||||
func sendMCPError(ctx *context.APIContext, id any, code int, message, data string) {
|
||||
ctx.JSON(http.StatusOK, MCPResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &MCPError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func sendMCPToolResult(ctx *context.APIContext, id any, text string, isError bool) {
|
||||
ctx.JSON(http.StatusOK, MCPResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: MCPToolCallResult{
|
||||
Content: []MCPContent{{Type: "text", Text: text}},
|
||||
IsError: isError,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Tool implementations
|
||||
|
||||
func toolListRunners(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
var runners actions_model.RunnerList
|
||||
var err error
|
||||
|
||||
owner, _ := args["owner"].(string)
|
||||
repo, _ := args["repo"].(string)
|
||||
|
||||
if owner != "" && repo != "" {
|
||||
// Get repo-specific runners
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", owner, repo)
|
||||
}
|
||||
runners, err = actions_model.GetRunnersOfRepo(ctx, repository.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Get all runners (admin)
|
||||
opts := actions_model.FindRunnerOptions{}
|
||||
opts.PageSize = 100
|
||||
runners, err = db.Find[actions_model.ActionRunner](ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
statusFilter, _ := args["status"].(string)
|
||||
|
||||
result := make([]map[string]any, 0, len(runners))
|
||||
for _, runner := range runners {
|
||||
isOnline := runner.IsOnline()
|
||||
|
||||
if statusFilter == "online" && !isOnline {
|
||||
continue
|
||||
}
|
||||
if statusFilter == "offline" && isOnline {
|
||||
continue
|
||||
}
|
||||
|
||||
r := map[string]any{
|
||||
"id": runner.ID,
|
||||
"name": runner.Name,
|
||||
"is_online": isOnline,
|
||||
"status": runner.Status().String(),
|
||||
"version": runner.Version,
|
||||
"labels": runner.AgentLabels,
|
||||
"last_online": runner.LastOnline.AsTime().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Parse capabilities if available
|
||||
if runner.CapabilitiesJSON != "" {
|
||||
var caps api.RunnerCapability
|
||||
if json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps) == nil {
|
||||
r["capabilities"] = caps
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, r)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"runners": result,
|
||||
"count": len(result),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetRunner(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
runnerIDFloat, ok := args["runner_id"].(float64)
|
||||
if !ok {
|
||||
return nil, errors.New("runner_id is required")
|
||||
}
|
||||
runnerID := int64(runnerIDFloat)
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("runner not found: %d", runnerID)
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"id": runner.ID,
|
||||
"name": runner.Name,
|
||||
"is_online": runner.IsOnline(),
|
||||
"status": runner.Status().String(),
|
||||
"version": runner.Version,
|
||||
"labels": runner.AgentLabels,
|
||||
"last_online": runner.LastOnline.AsTime().Format(time.RFC3339),
|
||||
"repo_id": runner.RepoID,
|
||||
"owner_id": runner.OwnerID,
|
||||
}
|
||||
|
||||
if runner.CapabilitiesJSON != "" {
|
||||
var caps api.RunnerCapability
|
||||
if json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps) == nil {
|
||||
result["capabilities"] = caps
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toolListWorkflowRuns(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
owner, _ := args["owner"].(string)
|
||||
repo, _ := args["repo"].(string)
|
||||
|
||||
if owner == "" || repo == "" {
|
||||
return nil, errors.New("owner and repo are required")
|
||||
}
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if l, ok := args["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
|
||||
opts := actions_model.FindRunOptions{
|
||||
RepoID: repository.ID,
|
||||
}
|
||||
opts.PageSize = limit
|
||||
|
||||
runs, err := db.Find[actions_model.ActionRun](ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statusFilter, _ := args["status"].(string)
|
||||
|
||||
result := make([]map[string]any, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
status := run.Status.String()
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" && !strings.EqualFold(status, statusFilter) {
|
||||
continue
|
||||
}
|
||||
|
||||
r := map[string]any{
|
||||
"id": run.ID,
|
||||
"title": run.Title,
|
||||
"status": status,
|
||||
"event": string(run.Event),
|
||||
"workflow_id": run.WorkflowID,
|
||||
"ref": run.Ref,
|
||||
"commit_sha": run.CommitSHA,
|
||||
"started": run.Started.AsTime().Format(time.RFC3339),
|
||||
"stopped": run.Stopped.AsTime().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
result = append(result, r)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"runs": result,
|
||||
"count": len(result),
|
||||
"repo": fmt.Sprintf("%s/%s", owner, repo),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetWorkflowRun(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
owner, _ := args["owner"].(string)
|
||||
repo, _ := args["repo"].(string)
|
||||
runIDFloat, ok := args["run_id"].(float64)
|
||||
|
||||
if owner == "" || repo == "" || !ok {
|
||||
return nil, errors.New("owner, repo, and run_id are required")
|
||||
}
|
||||
runID := int64(runIDFloat)
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run not found: %d", runID)
|
||||
}
|
||||
|
||||
// Get jobs for this run
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jobResults := make([]map[string]any, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
j := map[string]any{
|
||||
"id": job.ID,
|
||||
"name": job.Name,
|
||||
"status": job.Status.String(),
|
||||
"started": job.Started.AsTime().Format(time.RFC3339),
|
||||
"stopped": job.Stopped.AsTime().Format(time.RFC3339),
|
||||
"task_id": job.TaskID,
|
||||
}
|
||||
jobResults = append(jobResults, j)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": run.ID,
|
||||
"title": run.Title,
|
||||
"status": run.Status.String(),
|
||||
"event": string(run.Event),
|
||||
"workflow_id": run.WorkflowID,
|
||||
"ref": run.Ref,
|
||||
"commit_sha": run.CommitSHA,
|
||||
"started": run.Started.AsTime().Format(time.RFC3339),
|
||||
"stopped": run.Stopped.AsTime().Format(time.RFC3339),
|
||||
"jobs": jobResults,
|
||||
"job_count": len(jobResults),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetJobLogs(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
owner, _ := args["owner"].(string)
|
||||
repo, _ := args["repo"].(string)
|
||||
jobIDFloat, ok := args["job_id"].(float64)
|
||||
|
||||
if owner == "" || repo == "" || !ok {
|
||||
return nil, errors.New("owner, repo, and job_id are required")
|
||||
}
|
||||
jobID := int64(jobIDFloat)
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
job, err := actions_model.GetRunJobByID(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job not found: %d", jobID)
|
||||
}
|
||||
|
||||
// Verify job belongs to this repo
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, job.RunID)
|
||||
if err != nil {
|
||||
return nil, errors.New("job not found in repository")
|
||||
}
|
||||
_ = run
|
||||
|
||||
// Get the task for this job
|
||||
if job.TaskID == 0 {
|
||||
return map[string]any{
|
||||
"job_id": jobID,
|
||||
"job_name": job.Name,
|
||||
"status": job.Status.String(),
|
||||
"message": "Job has not started yet - no task assigned",
|
||||
}, nil
|
||||
}
|
||||
|
||||
task, err := actions_model.GetTaskByID(ctx, job.TaskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task not found for job: %d", jobID)
|
||||
}
|
||||
|
||||
// Check if logs are expired
|
||||
if task.LogExpired {
|
||||
return map[string]any{
|
||||
"job_id": jobID,
|
||||
"job_name": job.Name,
|
||||
"status": job.Status.String(),
|
||||
"message": "Logs have expired",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get steps for this task
|
||||
steps := actions.FullSteps(task)
|
||||
|
||||
stepLogs := make([]map[string]any, 0, len(steps))
|
||||
for i, step := range steps {
|
||||
stepInfo := map[string]any{
|
||||
"step": i,
|
||||
"name": step.Name,
|
||||
"status": step.Status.String(),
|
||||
"duration": step.Duration().String(),
|
||||
}
|
||||
|
||||
// Read logs for this step
|
||||
if step.LogLength > 0 && step.LogIndex < int64(len(task.LogIndexes)) {
|
||||
offset := task.LogIndexes[step.LogIndex]
|
||||
logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, step.LogLength)
|
||||
if err != nil {
|
||||
stepInfo["error"] = fmt.Sprintf("failed to read logs: %v", err)
|
||||
} else {
|
||||
lines := make([]string, 0, len(logRows))
|
||||
for _, row := range logRows {
|
||||
lines = append(lines, row.Content)
|
||||
}
|
||||
stepInfo["lines"] = lines
|
||||
stepInfo["line_count"] = len(lines)
|
||||
}
|
||||
}
|
||||
|
||||
stepLogs = append(stepLogs, stepInfo)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"job_id": jobID,
|
||||
"job_name": job.Name,
|
||||
"status": job.Status.String(),
|
||||
"task_id": task.ID,
|
||||
"log_expired": task.LogExpired,
|
||||
"steps": stepLogs,
|
||||
"step_count": len(stepLogs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolListReleases(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
owner, _ := args["owner"].(string)
|
||||
repo, _ := args["repo"].(string)
|
||||
|
||||
if owner == "" || repo == "" {
|
||||
return nil, errors.New("owner and repo are required")
|
||||
}
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if l, ok := args["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
|
||||
opts := repo_model.FindReleasesOptions{
|
||||
RepoID: repository.ID,
|
||||
}
|
||||
opts.PageSize = limit
|
||||
|
||||
releases, err := db.Find[repo_model.Release](ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(releases))
|
||||
for _, release := range releases {
|
||||
r := map[string]any{
|
||||
"id": release.ID,
|
||||
"tag_name": release.TagName,
|
||||
"title": release.Title,
|
||||
"is_draft": release.IsDraft,
|
||||
"is_prerelease": release.IsPrerelease,
|
||||
"created_at": release.CreatedUnix.AsTime().Format(time.RFC3339),
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"releases": result,
|
||||
"count": len(result),
|
||||
"repo": fmt.Sprintf("%s/%s", owner, repo),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetRelease(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
owner, _ := args["owner"].(string)
|
||||
repo, _ := args["repo"].(string)
|
||||
tag, _ := args["tag"].(string)
|
||||
|
||||
if owner == "" || repo == "" || tag == "" {
|
||||
return nil, errors.New("owner, repo, and tag are required")
|
||||
}
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
release, err := repo_model.GetRelease(ctx, repository.ID, tag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release not found: %s", tag)
|
||||
}
|
||||
|
||||
// Load attachments
|
||||
if err := release.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assets := make([]map[string]any, 0, len(release.Attachments))
|
||||
for _, att := range release.Attachments {
|
||||
assets = append(assets, map[string]any{
|
||||
"id": att.ID,
|
||||
"name": att.Name,
|
||||
"size": att.Size,
|
||||
"download_count": att.DownloadCount,
|
||||
"download_url": fmt.Sprintf("%s/%s/%s/releases/download/%s/%s",
|
||||
setting.AppURL, owner, repo, tag, att.Name),
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": release.ID,
|
||||
"tag_name": release.TagName,
|
||||
"title": release.Title,
|
||||
"body": release.Note,
|
||||
"is_draft": release.IsDraft,
|
||||
"is_prerelease": release.IsPrerelease,
|
||||
"created_at": release.CreatedUnix.AsTime().Format(time.RFC3339),
|
||||
"assets": assets,
|
||||
"asset_count": len(assets),
|
||||
}, nil
|
||||
}
|
||||
134
routers/api/v2/runners.go
Normal file
134
routers/api/v2/runners.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// RunnerStatusResponse represents the runner status for API/polling
|
||||
type RunnerStatusResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Labels []string `json:"labels"`
|
||||
LastOnline *time.Time `json:"last_online,omitempty"`
|
||||
Capabilities *api.RunnerCapability `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// GetRunnerStatus returns the current status of a runner
|
||||
// @Summary Get runner status
|
||||
// @Description Returns current runner status including online state, capabilities, disk, and bandwidth
|
||||
// @Tags actions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param owner path string true "owner of the repo"
|
||||
// @Param repo path string true "name of the repo"
|
||||
// @Param runner_id path int64 true "runner ID"
|
||||
// @Success 200 {object} RunnerStatusResponse
|
||||
// @Router /repos/{owner}/{repo}/actions/runners/{runner_id}/status [get]
|
||||
func GetRunnerStatus(ctx *context.APIContext) {
|
||||
runnerID := ctx.PathParamInt64("runner_id")
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check access - runner must belong to this repo or be global
|
||||
repo := ctx.Repo.Repository
|
||||
if runner.RepoID != 0 && runner.RepoID != repo.ID {
|
||||
ctx.APIErrorNotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
response := buildRunnerStatusResponse(runner)
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAdminRunnerStatus returns the current status of a runner (admin endpoint)
|
||||
// @Summary Get runner status (admin)
|
||||
// @Description Returns current runner status for admin panel AJAX polling
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param runner_id path int64 true "runner ID"
|
||||
// @Success 200 {object} RunnerStatusResponse
|
||||
// @Router /admin/actions/runners/{runner_id}/status [get]
|
||||
func GetAdminRunnerStatus(ctx *context.APIContext) {
|
||||
runnerID := ctx.PathParamInt64("runner_id")
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return
|
||||
}
|
||||
|
||||
response := buildRunnerStatusResponse(runner)
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// buildRunnerStatusResponse creates a status response from a runner
|
||||
func buildRunnerStatusResponse(runner *actions_model.ActionRunner) *RunnerStatusResponse {
|
||||
response := &RunnerStatusResponse{
|
||||
ID: runner.ID,
|
||||
Name: runner.Name,
|
||||
IsOnline: runner.IsOnline(),
|
||||
Status: runner.Status().String(),
|
||||
Version: runner.Version,
|
||||
Labels: runner.AgentLabels,
|
||||
}
|
||||
|
||||
// Add last online time if available
|
||||
if runner.LastOnline.AsTime().Unix() > 0 {
|
||||
lastOnline := runner.LastOnline.AsTime()
|
||||
response.LastOnline = &lastOnline
|
||||
}
|
||||
|
||||
// Parse capabilities JSON if available
|
||||
if runner.CapabilitiesJSON != "" {
|
||||
var caps api.RunnerCapability
|
||||
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps); err == nil {
|
||||
response.Capabilities = &caps
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// ListRunnersStatus returns status for all runners accessible to the repo
|
||||
// @Summary List runner statuses
|
||||
// @Description Returns status for all runners available to the repository
|
||||
// @Tags actions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param owner path string true "owner of the repo"
|
||||
// @Param repo path string true "name of the repo"
|
||||
// @Success 200 {array} RunnerStatusResponse
|
||||
// @Router /repos/{owner}/{repo}/actions/runners/status [get]
|
||||
func ListRunnersStatus(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
runners, err := actions_model.GetRunnersOfRepo(ctx, repo.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*RunnerStatusResponse, 0, len(runners))
|
||||
for _, runner := range runners {
|
||||
responses = append(responses, buildRunnerStatusResponse(runner))
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, responses)
|
||||
}
|
||||
@@ -17,12 +17,21 @@ import (
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
org_service "code.gitea.io/gitea/services/org"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
// RecentRepoActivity holds repo and its latest commit info
|
||||
type RecentRepoActivity struct {
|
||||
Repo *repo_model.Repository
|
||||
CommitMessage string
|
||||
CommitTime timeutil.TimeStamp
|
||||
}
|
||||
|
||||
const tplOrgHome templates.TplName = "org/home"
|
||||
|
||||
// Home show organization home page
|
||||
@@ -103,6 +112,43 @@ func home(ctx *context.Context, viewRepositories bool) {
|
||||
ctx.Data["Teams"] = ctx.Org.Teams
|
||||
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
|
||||
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
|
||||
ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
|
||||
|
||||
// Load recently updated repositories for activity section
|
||||
// Only show private repos if user is signed in and is org member
|
||||
showPrivate := ctx.IsSigned && ctx.Org.IsMember
|
||||
recentRepos, _, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: 10,
|
||||
Page: 1,
|
||||
},
|
||||
OwnerID: org.ID,
|
||||
OrderBy: db.SearchOrderByRecentUpdated,
|
||||
Private: showPrivate,
|
||||
Actor: ctx.Doer,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("SearchRepository for recent repos: %v", err)
|
||||
} else {
|
||||
// Load commit info for each repo
|
||||
var recentActivity []*RecentRepoActivity
|
||||
for _, repo := range recentRepos {
|
||||
activity := &RecentRepoActivity{Repo: repo}
|
||||
|
||||
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err == nil {
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err == nil {
|
||||
activity.CommitMessage = commit.Summary()
|
||||
activity.CommitTime = timeutil.TimeStamp(commit.Author.When.Unix())
|
||||
}
|
||||
|
||||
gitRepo.Close()
|
||||
}
|
||||
recentActivity = append(recentActivity, activity)
|
||||
}
|
||||
ctx.Data["RecentActivity"] = recentActivity
|
||||
}
|
||||
|
||||
prepareResult, err := shared_user.RenderUserOrgHeader(ctx)
|
||||
if err != nil {
|
||||
@@ -157,12 +203,10 @@ func home(ctx *context.Context, viewRepositories bool) {
|
||||
}
|
||||
ctx.Data["OrgStats"] = orgStats
|
||||
|
||||
// if no profile readme, it still means "view repositories"
|
||||
isViewOverview := !viewRepositories && prepareOrgProfileReadme(ctx, prepareResult)
|
||||
// Also show overview if there are pinned repos even without profile readme
|
||||
if !viewRepositories && len(pinnedRepos) > 0 {
|
||||
isViewOverview = true
|
||||
}
|
||||
// Always show overview by default for organizations
|
||||
isViewOverview := !viewRepositories
|
||||
// Load profile readme if available
|
||||
prepareOrgProfileReadme(ctx, prepareResult)
|
||||
ctx.Data["PageIsViewRepositories"] = !isViewOverview
|
||||
ctx.Data["PageIsViewOverview"] = isViewOverview
|
||||
ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
|
||||
@@ -242,3 +286,45 @@ func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.Pr
|
||||
ctx.Data["IsViewingOrgAsMember"] = viewAsMember
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateProfileRepo creates a .profile repository with README for the organization
|
||||
func CreateProfileRepo(ctx *context.Context) {
|
||||
org := ctx.Org.Organization
|
||||
|
||||
// Check if user can create repos in this org
|
||||
if !ctx.Org.CanCreateOrgRepo {
|
||||
ctx.Flash.Error(ctx.Tr("org.profile_repo_no_permission"))
|
||||
ctx.Redirect(org.AsUser().HomeLink())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if .profile repo already exists
|
||||
exists, err := repo_model.IsRepositoryModelExist(ctx, org.AsUser(), ".profile")
|
||||
if err != nil {
|
||||
ctx.ServerError("IsRepositoryExist", err)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
ctx.Redirect(org.AsUser().HomeLink() + "/.profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Create the .profile repository
|
||||
repo, err := repo_service.CreateRepository(ctx, ctx.Doer, org.AsUser(), repo_service.CreateRepoOptions{
|
||||
Name: ".profile",
|
||||
Description: "Organization profile",
|
||||
AutoInit: true,
|
||||
Readme: "Default",
|
||||
DefaultBranch: "main",
|
||||
IsPrivate: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("CreateProfileRepo: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("org.profile_repo_create_failed"))
|
||||
ctx.Redirect(org.AsUser().HomeLink())
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to edit the README
|
||||
ctx.Redirect(repo.Link() + "/_edit/main/README.md")
|
||||
}
|
||||
|
||||
@@ -70,15 +70,19 @@ func getRepoFromRequest(ctx *context.Context) (*repo_model.Repository, *pages_mo
|
||||
return repo, config, nil
|
||||
}
|
||||
|
||||
// Parse subdomain: {repo}.{owner}.pages.{domain}
|
||||
// This is a simplified implementation
|
||||
// Parse subdomain: {repo}-{owner}.{domain}
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 4 {
|
||||
if len(parts) < 2 {
|
||||
return nil, nil, errors.New("invalid pages subdomain")
|
||||
}
|
||||
|
||||
repoName := parts[0]
|
||||
ownerName := parts[1]
|
||||
// First part is {repo}-{owner}
|
||||
repoOwner := strings.SplitN(parts[0], "-", 2)
|
||||
if len(repoOwner) != 2 {
|
||||
return nil, nil, errors.New("invalid pages subdomain format")
|
||||
}
|
||||
repoName := repoOwner[0]
|
||||
ownerName := repoOwner[1]
|
||||
|
||||
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
|
||||
if err != nil {
|
||||
|
||||
128
routers/web/repo/pin.go
Normal file
128
routers/web/repo/pin.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// Pin handles pinning a repo to user profile or organization
|
||||
func Pin(ctx *context.Context) {
|
||||
pinType := ctx.FormString("type")
|
||||
redirectTo := ctx.FormString("redirect_to")
|
||||
if redirectTo == "" {
|
||||
redirectTo = ctx.Repo.RepoLink
|
||||
}
|
||||
|
||||
switch pinType {
|
||||
case "user":
|
||||
if err := user_model.PinRepoToUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil {
|
||||
if user_model.IsErrPinnedRepoLimit(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_limit"))
|
||||
} else {
|
||||
log.Error("PinRepoToUser failed: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
|
||||
}
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.pin.success_profile"))
|
||||
}
|
||||
|
||||
case "org":
|
||||
if !ctx.Repo.Repository.Owner.IsOrganization() {
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_org"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is a member of the org
|
||||
isMember, err := organization.IsOrganizationMember(ctx, ctx.Repo.Repository.OwnerID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
log.Error("IsOrganizationMember failed: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
if !isMember {
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_member"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
// Use CreateOrgPinnedRepo
|
||||
pinnedRepo := &organization.OrgPinnedRepo{
|
||||
OrgID: ctx.Repo.Repository.OwnerID,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
}
|
||||
if err := organization.CreateOrgPinnedRepo(ctx, pinnedRepo); err != nil {
|
||||
if _, ok := err.(organization.ErrOrgPinnedRepoAlreadyExist); ok {
|
||||
ctx.Flash.Info(ctx.Tr("repo.pin.already_pinned_org"))
|
||||
} else {
|
||||
log.Error("CreateOrgPinnedRepo failed: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
|
||||
}
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.pin.success_org"))
|
||||
}
|
||||
|
||||
default:
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_invalid_type"))
|
||||
}
|
||||
|
||||
ctx.Redirect(redirectTo)
|
||||
}
|
||||
|
||||
// Unpin handles unpinning a repo from user profile or organization
|
||||
func Unpin(ctx *context.Context) {
|
||||
pinType := ctx.FormString("type")
|
||||
redirectTo := ctx.FormString("redirect_to")
|
||||
if redirectTo == "" {
|
||||
redirectTo = ctx.Repo.RepoLink
|
||||
}
|
||||
|
||||
switch pinType {
|
||||
case "user":
|
||||
if err := user_model.UnpinRepoFromUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil {
|
||||
log.Error("UnpinRepoFromUser failed: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.pin.unpin_success_profile"))
|
||||
}
|
||||
|
||||
case "org":
|
||||
if !ctx.Repo.Repository.Owner.IsOrganization() {
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_org"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is a member of the org
|
||||
isMember, err := organization.IsOrganizationMember(ctx, ctx.Repo.Repository.OwnerID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
log.Error("IsOrganizationMember failed: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
if !isMember {
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_member"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
if err := organization.DeleteOrgPinnedRepo(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID); err != nil {
|
||||
log.Error("DeleteOrgPinnedRepo failed: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.pin.unpin_success_org"))
|
||||
}
|
||||
|
||||
default:
|
||||
ctx.Flash.Error(ctx.Tr("repo.pin.error_invalid_type"))
|
||||
}
|
||||
|
||||
ctx.Redirect(redirectTo)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func Pages(ctx *context.Context) {
|
||||
|
||||
// Generate subdomain
|
||||
ctx.Data["PagesSubdomain"] = pages_service.GetPagesSubdomain(ctx.Repo.Repository)
|
||||
ctx.Data["PagesURL"] = pages_service.GetPagesURL(ctx.Repo.Repository)
|
||||
|
||||
// Available templates
|
||||
ctx.Data["PagesTemplates"] = []string{"simple", "documentation", "product", "portfolio"}
|
||||
@@ -92,7 +93,8 @@ func PagesPost(ctx *context.Context) {
|
||||
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages")
|
||||
return
|
||||
}
|
||||
_, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, domain)
|
||||
sslExternal := ctx.FormBool("ssl_external")
|
||||
_, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, domain, sslExternal)
|
||||
if err != nil {
|
||||
if repo_model.IsErrPagesDomainAlreadyExist(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_exists"))
|
||||
@@ -112,6 +114,14 @@ func PagesPost(ctx *context.Context) {
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_deleted"))
|
||||
|
||||
case "activate_ssl":
|
||||
domainID := ctx.FormInt64("domain_id")
|
||||
if err := repo_model.ActivatePagesDomainSSL(ctx, domainID); err != nil {
|
||||
ctx.ServerError("ActivatePagesDomainSSL", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.pages.ssl_activated"))
|
||||
|
||||
case "verify_domain":
|
||||
domainID := ctx.FormInt64("domain_id")
|
||||
if err := pages_service.VerifyDomain(ctx, domainID); err != nil {
|
||||
|
||||
@@ -5,8 +5,12 @@ package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||
@@ -295,6 +300,45 @@ func ResetRunnerRegistrationToken(ctx *context.Context) {
|
||||
ctx.JSONRedirect(redirectTo)
|
||||
}
|
||||
|
||||
// RunnerRequestBandwidthTest handles admin request to trigger a bandwidth test
|
||||
func RunnerRequestBandwidthTest(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runnerID := ctx.PathParamInt64("runnerid")
|
||||
ownerID := rCtx.OwnerID
|
||||
repoID := rCtx.RepoID
|
||||
redirectTo := rCtx.RedirectLink + url.PathEscape(ctx.PathParam("runnerid"))
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
log.Warn("RunnerRequestBandwidthTest.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL)
|
||||
ctx.ServerError("RunnerRequestBandwidthTest.GetRunnerByID", err)
|
||||
return
|
||||
}
|
||||
if !runner.EditableInContext(ownerID, repoID) {
|
||||
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
|
||||
return
|
||||
}
|
||||
|
||||
// Set the bandwidth test request timestamp
|
||||
runner.BandwidthTestRequestedAt = timeutil.TimeStampNow()
|
||||
err = actions_model.UpdateRunner(ctx, runner, "bandwidth_test_requested_at")
|
||||
if err != nil {
|
||||
log.Warn("RunnerRequestBandwidthTest.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
|
||||
ctx.Flash.Warning(ctx.Tr("actions.runners.bandwidth_test_request_failed"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("RunnerRequestBandwidthTest success: %s", ctx.Req.URL)
|
||||
ctx.Flash.Success(ctx.Tr("actions.runners.bandwidth_test_requested"))
|
||||
ctx.Redirect(redirectTo)
|
||||
}
|
||||
|
||||
// RunnerDeletePost response for deleting runner
|
||||
func RunnerDeletePost(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
@@ -368,3 +412,306 @@ func findActionsRunner(ctx *context.Context, rCtx *runnersCtx) *actions_model.Ac
|
||||
|
||||
return got[0]
|
||||
}
|
||||
|
||||
// RunnerAddLabel adds a single label to a runner
|
||||
func RunnerAddLabel(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runner := findActionsRunner(ctx, rCtx)
|
||||
if runner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
label := ctx.FormString("label")
|
||||
if label == "" {
|
||||
ctx.Flash.Warning("No label specified")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if label already exists
|
||||
if slices.Contains(runner.AgentLabels, label) {
|
||||
ctx.Flash.Info("Label already exists")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
// Add the label
|
||||
runner.AgentLabels = append(runner.AgentLabels, label)
|
||||
|
||||
err = actions_model.UpdateRunner(ctx, runner, "agent_labels")
|
||||
if err != nil {
|
||||
log.Warn("RunnerAddLabel.UpdateRunner failed: %v", err)
|
||||
ctx.Flash.Warning("Failed to add label")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success("Label added: " + label)
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
}
|
||||
|
||||
// RunnerRemoveLabel removes a single label from a runner
|
||||
func RunnerRemoveLabel(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runner := findActionsRunner(ctx, rCtx)
|
||||
if runner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
label := ctx.FormString("label")
|
||||
if label == "" {
|
||||
ctx.Flash.Warning("No label specified")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the label
|
||||
newLabels := make([]string, 0, len(runner.AgentLabels))
|
||||
found := false
|
||||
for _, existing := range runner.AgentLabels {
|
||||
if existing == label {
|
||||
found = true
|
||||
} else {
|
||||
newLabels = append(newLabels, existing)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
ctx.Flash.Info("Label not found")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
runner.AgentLabels = newLabels
|
||||
|
||||
err = actions_model.UpdateRunner(ctx, runner, "agent_labels")
|
||||
if err != nil {
|
||||
log.Warn("RunnerRemoveLabel.UpdateRunner failed: %v", err)
|
||||
ctx.Flash.Warning("Failed to remove label")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success("Label removed: " + label)
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
}
|
||||
|
||||
// RunnerUseSuggestedLabels adds all suggested labels based on capabilities
|
||||
func RunnerUseSuggestedLabels(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runner := findActionsRunner(ctx, rCtx)
|
||||
if runner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse capabilities to get suggested labels
|
||||
if runner.CapabilitiesJSON == "" {
|
||||
ctx.Flash.Warning("No capabilities data available")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
var caps structs.RunnerCapability
|
||||
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps); err != nil {
|
||||
ctx.Flash.Warning("Failed to parse capabilities")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
// Build suggested labels
|
||||
suggestedLabels := []string{}
|
||||
existingSet := make(map[string]bool)
|
||||
for _, label := range runner.AgentLabels {
|
||||
existingSet[label] = true
|
||||
}
|
||||
|
||||
// OS-based labels
|
||||
switch caps.OS {
|
||||
case "linux":
|
||||
suggestedLabels = append(suggestedLabels, "linux", "linux-latest")
|
||||
case "windows":
|
||||
suggestedLabels = append(suggestedLabels, "windows", "windows-latest")
|
||||
case "darwin":
|
||||
suggestedLabels = append(suggestedLabels, "macos", "macos-latest")
|
||||
}
|
||||
|
||||
// Distro-based labels
|
||||
if caps.Distro != nil && caps.Distro.ID != "" {
|
||||
suggestedLabels = append(suggestedLabels, caps.Distro.ID, caps.Distro.ID+"-latest")
|
||||
}
|
||||
|
||||
// Add only new labels
|
||||
added := []string{}
|
||||
for _, label := range suggestedLabels {
|
||||
if !existingSet[label] {
|
||||
runner.AgentLabels = append(runner.AgentLabels, label)
|
||||
added = append(added, label)
|
||||
existingSet[label] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(added) == 0 {
|
||||
ctx.Flash.Info("All suggested labels already exist")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
err = actions_model.UpdateRunner(ctx, runner, "agent_labels")
|
||||
if err != nil {
|
||||
log.Warn("RunnerUseSuggestedLabels.UpdateRunner failed: %v", err)
|
||||
ctx.Flash.Warning("Failed to add labels")
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success("Added labels: " + strings.Join(added, ", "))
|
||||
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
||||
}
|
||||
|
||||
// RunnerStatusJSON returns runner status as JSON for AJAX polling
|
||||
func RunnerStatusJSON(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runner := findActionsRunner(ctx, rCtx)
|
||||
if runner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse capabilities
|
||||
var caps *structs.RunnerCapability
|
||||
if runner.CapabilitiesJSON != "" {
|
||||
caps = &structs.RunnerCapability{}
|
||||
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), caps); err != nil {
|
||||
caps = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Build response matching the tile structure
|
||||
response := map[string]any{
|
||||
"id": runner.ID,
|
||||
"name": runner.Name,
|
||||
"is_online": runner.IsOnline(),
|
||||
"status": runner.StatusLocaleName(ctx.Locale),
|
||||
"version": runner.Version,
|
||||
"labels": runner.AgentLabels,
|
||||
}
|
||||
|
||||
if runner.LastOnline.AsTime().Unix() > 0 {
|
||||
response["last_online"] = runner.LastOnline.AsTime().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
if caps != nil {
|
||||
if caps.Disk != nil {
|
||||
response["disk"] = map[string]any{
|
||||
"total_bytes": caps.Disk.Total,
|
||||
"free_bytes": caps.Disk.Free,
|
||||
"used_bytes": caps.Disk.Used,
|
||||
"used_percent": caps.Disk.UsedPercent,
|
||||
}
|
||||
}
|
||||
if caps.Bandwidth != nil {
|
||||
bw := map[string]any{
|
||||
"download_mbps": caps.Bandwidth.DownloadMbps,
|
||||
"latency_ms": caps.Bandwidth.Latency,
|
||||
}
|
||||
if !caps.Bandwidth.TestedAt.IsZero() {
|
||||
bw["tested_at"] = caps.Bandwidth.TestedAt.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
response["bandwidth"] = bw
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// RunnersStatusJSON returns status for all runners as JSON for AJAX polling on the list page
|
||||
func RunnersStatusJSON(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
opts := actions_model.FindRunnerOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 100,
|
||||
},
|
||||
}
|
||||
if rCtx.IsRepo {
|
||||
opts.RepoID = rCtx.RepoID
|
||||
opts.WithAvailable = true
|
||||
} else if rCtx.IsOrg || rCtx.IsUser {
|
||||
opts.OwnerID = rCtx.OwnerID
|
||||
opts.WithAvailable = true
|
||||
}
|
||||
|
||||
runners, _, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindRunners", err)
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]map[string]any, 0, len(runners))
|
||||
for _, runner := range runners {
|
||||
item := map[string]any{
|
||||
"id": runner.ID,
|
||||
"is_online": runner.IsOnline(),
|
||||
"status": runner.StatusLocaleName(ctx.Locale),
|
||||
"version": runner.Version,
|
||||
}
|
||||
if runner.LastOnline.AsTime().Unix() > 0 {
|
||||
item["last_online"] = runner.LastOnline.AsTime().Format("2006-01-02T15:04:05Z")
|
||||
// Calculate relative time
|
||||
duration := time.Since(runner.LastOnline.AsTime())
|
||||
var relativeTime string
|
||||
if duration < time.Minute {
|
||||
relativeTime = "just now"
|
||||
} else if duration < time.Hour {
|
||||
mins := int(duration.Minutes())
|
||||
if mins == 1 {
|
||||
relativeTime = "1 minute ago"
|
||||
} else {
|
||||
relativeTime = fmt.Sprintf("%d minutes ago", mins)
|
||||
}
|
||||
} else if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
relativeTime = "1 hour ago"
|
||||
} else {
|
||||
relativeTime = fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
} else {
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
relativeTime = "1 day ago"
|
||||
} else {
|
||||
relativeTime = fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
item["last_online_relative"] = relativeTime
|
||||
}
|
||||
response = append(response, item)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -79,15 +79,10 @@ func userProfile(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) {
|
||||
// if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page
|
||||
// if there is not a profile readme, the overview tab should be treated as the repositories tab
|
||||
// Default to overview page for users
|
||||
tab := ctx.FormString("tab")
|
||||
if tab == "" || tab == "overview" {
|
||||
if profileReadme != nil {
|
||||
tab = "overview"
|
||||
} else {
|
||||
tab = "repositories"
|
||||
}
|
||||
if tab == "" {
|
||||
tab = "overview"
|
||||
}
|
||||
ctx.Data["TabName"] = tab
|
||||
ctx.Data["HasUserProfileReadme"] = profileReadme != nil
|
||||
@@ -252,16 +247,46 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
||||
|
||||
total = int(count)
|
||||
case "overview":
|
||||
if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
|
||||
log.Error("failed to GetBlobContent: %v", err)
|
||||
} else {
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
|
||||
})
|
||||
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
|
||||
log.Error("failed to RenderString: %v", err)
|
||||
// Load heatmap if user has it enabled
|
||||
if ctx.ContextUser.ShowHeatmapOnProfile && setting.Service.EnableUserHeatmap {
|
||||
data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
|
||||
if err != nil {
|
||||
log.Error("GetUserHeatmapDataByUser: %v", err)
|
||||
} else {
|
||||
ctx.Data["ProfileReadmeContent"] = profileContent
|
||||
ctx.Data["HeatmapData"] = data
|
||||
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Load pinned repositories
|
||||
pinnedRepos, err := user_model.GetPinnedRepos(ctx, ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
log.Error("GetPinnedRepos: %v", err)
|
||||
} else {
|
||||
// Load repo details for each pinned repo
|
||||
for _, p := range pinnedRepos {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, p.RepoID)
|
||||
if err == nil {
|
||||
p.Repo = repo
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["UserPinnedRepos"] = pinnedRepos
|
||||
ctx.Data["IsContextUserProfile"] = ctx.Doer != nil && ctx.Doer.ID == ctx.ContextUser.ID
|
||||
|
||||
// Load profile README
|
||||
if profileReadme != nil {
|
||||
if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
|
||||
log.Error("failed to GetBlobContent: %v", err)
|
||||
} else {
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
|
||||
})
|
||||
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
|
||||
log.Error("failed to RenderString: %v", err)
|
||||
} else {
|
||||
ctx.Data["ProfileReadmeContent"] = profileContent
|
||||
}
|
||||
}
|
||||
}
|
||||
case "organizations":
|
||||
|
||||
@@ -97,12 +97,13 @@ func ProfilePost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
|
||||
Description: optional.Some(form.Description),
|
||||
Website: optional.Some(form.Website),
|
||||
Location: optional.Some(form.Location),
|
||||
Visibility: optional.Some(form.Visibility),
|
||||
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
|
||||
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
|
||||
Description: optional.Some(form.Description),
|
||||
Website: optional.Some(form.Website),
|
||||
Location: optional.Some(form.Location),
|
||||
Visibility: optional.Some(form.Visibility),
|
||||
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
|
||||
ShowHeatmapOnProfile: optional.Some(form.ShowHeatmapOnProfile),
|
||||
}
|
||||
|
||||
if form.FullName != "" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
@@ -302,6 +303,44 @@ var optSignInFromAnyOrigin = verifyAuthWithOptions(&common.VerifyOptions{Disable
|
||||
|
||||
// registerWebRoutes register routes
|
||||
func registerWebRoutes(m *web.Router) {
|
||||
// Check for Pages subdomain and custom domain requests first
|
||||
m.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
host := req.Host
|
||||
// Remove port if present
|
||||
if idx := strings.Index(host, ":"); idx > 0 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
// Check if this is a subdomain of our main domain
|
||||
mainDomain := setting.Domain
|
||||
if subdomain, found := strings.CutSuffix(host, "."+mainDomain); found {
|
||||
// Skip known subdomains
|
||||
if subdomain != "" && subdomain != "www" && subdomain != "api" && subdomain != "git" && strings.Contains(subdomain, "-") {
|
||||
// This looks like a Pages subdomain ({repo}-{owner})
|
||||
ctx := context.GetWebContext(req.Context())
|
||||
if ctx != nil {
|
||||
log.Trace("Pages subdomain request: %s", host)
|
||||
pages.ServeLandingPage(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if host != mainDomain && host != "www."+mainDomain {
|
||||
// Check if this is a custom domain for Pages
|
||||
domain, err := repo_model.GetPagesDomainByDomain(req.Context(), host)
|
||||
if err == nil && domain != nil && domain.Verified {
|
||||
ctx := context.GetWebContext(req.Context())
|
||||
if ctx != nil {
|
||||
log.Trace("Pages custom domain request: %s -> repo %d", host, domain.RepoID)
|
||||
pages.ServeLandingPage(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
})
|
||||
|
||||
// required to be signed in or signed out
|
||||
reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true})
|
||||
reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true})
|
||||
@@ -470,6 +509,12 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Combo("/{runnerid}").Get(shared_actions.RunnersEdit).
|
||||
Post(web.Bind(forms.EditRunnerForm{}), shared_actions.RunnersEditPost)
|
||||
m.Post("/{runnerid}/delete", shared_actions.RunnerDeletePost)
|
||||
m.Post("/{runnerid}/bandwidth-test", shared_actions.RunnerRequestBandwidthTest)
|
||||
m.Post("/{runnerid}/add-label", shared_actions.RunnerAddLabel)
|
||||
m.Post("/{runnerid}/remove-label", shared_actions.RunnerRemoveLabel)
|
||||
m.Post("/{runnerid}/use-suggested-labels", shared_actions.RunnerUseSuggestedLabels)
|
||||
m.Get("/status", shared_actions.RunnersStatusJSON)
|
||||
m.Get("/{runnerid}/status", shared_actions.RunnerStatusJSON)
|
||||
m.Post("/reset_registration_token", shared_actions.ResetRunnerRegistrationToken)
|
||||
})
|
||||
}
|
||||
@@ -908,6 +953,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones)
|
||||
m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones)
|
||||
m.Post("/members/action/{action}", org.MembersAction)
|
||||
m.Post("/create-profile-repo", org.CreateProfileRepo)
|
||||
m.Get("/teams", org.Teams)
|
||||
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}))
|
||||
|
||||
@@ -1670,6 +1716,8 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar)
|
||||
m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch)
|
||||
m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
|
||||
m.Get("/action/pin", reqSignIn, repo.Pin)
|
||||
m.Get("/action/unpin", reqSignIn, repo.Unpin)
|
||||
}, optSignIn, context.RepoAssignment)
|
||||
|
||||
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, repo.CorsHandler(), optSignInFromAnyOrigin) // "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
unit_model "code.gitea.io/gitea/models/unit"
|
||||
@@ -415,6 +416,21 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
|
||||
|
||||
ctx.Repo.Repository = repo
|
||||
ctx.Data["RepoName"] = ctx.Repo.Repository.Name
|
||||
|
||||
// Check if repo is pinned (for pin dropdown)
|
||||
if ctx.Doer != nil {
|
||||
isPinnedToUser, _ := user_model.IsRepoPinnedByUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
ctx.Data["IsRepoPinnedToUser"] = isPinnedToUser
|
||||
}
|
||||
if ctx.Repo.Repository.Owner.IsOrganization() {
|
||||
isPinnedToOrg, _ := organization.IsRepoPinned(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
|
||||
ctx.Data["IsRepoPinnedToOrg"] = isPinnedToOrg
|
||||
// Check if user is a member of the org for pin dropdown
|
||||
if ctx.Doer != nil {
|
||||
isMember, _ := organization.IsOrganizationMember(ctx, ctx.Repo.Repository.OwnerID, ctx.Doer.ID)
|
||||
ctx.Data["IsOrganizationMember"] = isMember
|
||||
}
|
||||
}
|
||||
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
|
||||
}
|
||||
|
||||
|
||||
@@ -211,14 +211,15 @@ func (f *IntrospectTokenForm) Validate(req *http.Request, errs binding.Errors) b
|
||||
|
||||
// UpdateProfileForm form for updating profile
|
||||
type UpdateProfileForm struct {
|
||||
Name string `binding:"Username;MaxSize(40)"`
|
||||
FullName string `binding:"MaxSize(100)"`
|
||||
KeepEmailPrivate bool
|
||||
Website string `binding:"ValidSiteUrl;MaxSize(255)"`
|
||||
Location string `binding:"MaxSize(50)"`
|
||||
Description string `binding:"MaxSize(255)"`
|
||||
Visibility structs.VisibleType
|
||||
KeepActivityPrivate bool
|
||||
Name string `binding:"Username;MaxSize(40)"`
|
||||
FullName string `binding:"MaxSize(100)"`
|
||||
KeepEmailPrivate bool
|
||||
Website string `binding:"ValidSiteUrl;MaxSize(255)"`
|
||||
Location string `binding:"MaxSize(50)"`
|
||||
Description string `binding:"MaxSize(255)"`
|
||||
Visibility structs.VisibleType
|
||||
KeepActivityPrivate bool
|
||||
ShowHeatmapOnProfile bool
|
||||
}
|
||||
|
||||
// Validate validates the fields
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
)
|
||||
|
||||
// GetOrgPinnedReposWithDetails returns all pinned repos with repo and group details loaded
|
||||
@@ -38,9 +37,13 @@ func GetOrgPinnedReposWithDetails(ctx context.Context, orgID int64) ([]*organiza
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Attach repos
|
||||
// Attach repos and load attributes (including primary language)
|
||||
for _, p := range pinnedRepos {
|
||||
p.Repo = repos[p.RepoID]
|
||||
repo := repos[p.RepoID]
|
||||
if repo != nil {
|
||||
_ = repo.LoadAttributes(ctx)
|
||||
}
|
||||
p.Repo = repo
|
||||
}
|
||||
|
||||
return pinnedRepos, nil
|
||||
@@ -54,23 +57,22 @@ func GetOrgOverviewStats(ctx context.Context, orgID int64) (*organization.OrgOve
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.MemberCount = memberCount
|
||||
stats.TeamCount = teamCount
|
||||
stats.TotalMembers = memberCount
|
||||
stats.TotalTeams = teamCount
|
||||
|
||||
// Repo counts
|
||||
stats.RepoCount, err = repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{
|
||||
// Repo count
|
||||
stats.TotalRepos, err = repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{
|
||||
OwnerID: orgID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats.PublicRepoCount, err = repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{
|
||||
OwnerID: orgID,
|
||||
Private: optional.Some(false),
|
||||
})
|
||||
// Total stars across all repos
|
||||
stats.TotalStars, err = repo_model.CountOrgRepoStars(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Non-fatal, just log and continue
|
||||
stats.TotalStars = 0
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
|
||||
@@ -44,6 +44,10 @@ func GetPagesConfig(ctx context.Context, repo *repo_model.Repository) (*pages_mo
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
// If Pages is enabled but no config file, return a default config
|
||||
if dbConfig != nil && dbConfig.Enabled {
|
||||
return getDefaultConfig(repo, string(dbConfig.Template)), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -93,6 +97,25 @@ func GetPagesConfig(ctx context.Context, repo *repo_model.Repository) (*pages_mo
|
||||
}
|
||||
|
||||
// loadConfigFromRepo loads the landing.yaml configuration from the repository
|
||||
|
||||
// getDefaultConfig returns a default landing page configuration
|
||||
func getDefaultConfig(repo *repo_model.Repository, template string) *pages_module.LandingConfig {
|
||||
if template == "" {
|
||||
template = "simple"
|
||||
}
|
||||
return &pages_module.LandingConfig{
|
||||
Enabled: true,
|
||||
Template: template,
|
||||
Hero: pages_module.HeroConfig{
|
||||
Title: repo.Name,
|
||||
Tagline: repo.Description,
|
||||
},
|
||||
Branding: pages_module.BrandingConfig{
|
||||
PrimaryColor: "#4183c4",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigFromRepo(ctx context.Context, repo *repo_model.Repository) (*pages_module.LandingConfig, string, error) {
|
||||
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err != nil {
|
||||
@@ -172,16 +195,17 @@ func DisablePages(ctx context.Context, repo *repo_model.Repository) error {
|
||||
|
||||
// GetPagesSubdomain returns the subdomain for a repository's pages
|
||||
func GetPagesSubdomain(repo *repo_model.Repository) string {
|
||||
// Format: {repo}.{owner}.pages.{domain}
|
||||
return fmt.Sprintf("%s.%s", strings.ToLower(repo.Name), strings.ToLower(repo.OwnerName))
|
||||
// Format: {repo}-{owner}.{domain}
|
||||
return fmt.Sprintf("%s-%s", strings.ToLower(repo.Name), strings.ToLower(repo.OwnerName))
|
||||
}
|
||||
|
||||
// GetPagesURL returns the full URL for a repository's pages
|
||||
func GetPagesURL(repo *repo_model.Repository) string {
|
||||
subdomain := GetPagesSubdomain(repo)
|
||||
// This should be configurable
|
||||
pagesDomain := setting.AppURL // TODO: Add proper pages domain setting
|
||||
return fmt.Sprintf("https://%s.pages.%s", subdomain, pagesDomain)
|
||||
// Extract domain from settings
|
||||
domain := setting.Domain
|
||||
return fmt.Sprintf("https://%s.%s", subdomain, domain)
|
||||
}
|
||||
|
||||
// GetPagesDomains returns all custom domains for a repository's pages
|
||||
@@ -190,7 +214,7 @@ func GetPagesDomains(ctx context.Context, repoID int64) ([]*repo_model.PagesDoma
|
||||
}
|
||||
|
||||
// AddPagesDomain adds a custom domain for pages
|
||||
func AddPagesDomain(ctx context.Context, repoID int64, domain string) (*repo_model.PagesDomain, error) {
|
||||
func AddPagesDomain(ctx context.Context, repoID int64, domain string, sslExternal bool) (*repo_model.PagesDomain, error) {
|
||||
// Normalize domain
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
|
||||
@@ -200,9 +224,15 @@ func AddPagesDomain(ctx context.Context, repoID int64, domain string) (*repo_mod
|
||||
return nil, repo_model.ErrPagesDomainAlreadyExist{Domain: domain}
|
||||
}
|
||||
|
||||
sslStatus := repo_model.SSLStatusPending
|
||||
if sslExternal {
|
||||
sslStatus = repo_model.SSLStatusActive
|
||||
}
|
||||
|
||||
pagesDomain := &repo_model.PagesDomain{
|
||||
RepoID: repoID,
|
||||
Domain: domain,
|
||||
RepoID: repoID,
|
||||
Domain: domain,
|
||||
SSLStatus: sslStatus,
|
||||
}
|
||||
|
||||
if err := repo_model.CreatePagesDomain(ctx, pagesDomain); err != nil {
|
||||
|
||||
@@ -47,6 +47,7 @@ type UpdateOptions struct {
|
||||
IsRestricted optional.Option[bool]
|
||||
Visibility optional.Option[structs.VisibleType]
|
||||
KeepActivityPrivate optional.Option[bool]
|
||||
ShowHeatmapOnProfile optional.Option[bool]
|
||||
Language optional.Option[string]
|
||||
Theme optional.Option[string]
|
||||
DiffViewStyle optional.Option[string]
|
||||
@@ -158,6 +159,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
|
||||
|
||||
cols = append(cols, "keep_activity_private")
|
||||
}
|
||||
if opts.ShowHeatmapOnProfile.Has() {
|
||||
u.ShowHeatmapOnProfile = opts.ShowHeatmapOnProfile.Value()
|
||||
|
||||
cols = append(cols, "show_heatmap_on_profile")
|
||||
}
|
||||
|
||||
if opts.AllowCreateOrganization.Has() {
|
||||
u.AllowCreateOrganization = opts.AllowCreateOrganization.Value()
|
||||
|
||||
@@ -4,119 +4,191 @@
|
||||
|
||||
<div class="ui container">
|
||||
<div class="ui mobile reversed stackable grid">
|
||||
<div class="ui {{if .ShowMemberAndTeamTab}}eleven wide{{end}} column">
|
||||
<div class="ui eleven wide column">
|
||||
{{/* Profile README Section */}}
|
||||
{{if .ProfileReadmeContent}}
|
||||
<div id="readme_profile" class="render-content markup" data-profile-view-as-member="{{.IsViewingOrgAsMember}}">{{.ProfileReadmeContent}}</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Pinned Repositories Section */}}
|
||||
{{if and .PageIsViewOverview .HasPinnedRepos}}
|
||||
<div class="ui segment pinned-repos-section">
|
||||
<h4 class="ui header">
|
||||
{{svg "octicon-pin" 16}} {{ctx.Locale.Tr "org.pinned_repos"}}
|
||||
</h4>
|
||||
{{/* Overview Tab Content */}}
|
||||
{{if .PageIsViewOverview}}
|
||||
{{/* Pinned Repositories Section */}}
|
||||
<div class="ui segment pinned-repos-section">
|
||||
<h4 class="ui header tw-flex tw-items-center">
|
||||
{{svg "octicon-pin" 16}} {{ctx.Locale.Tr "org.pinned_repos"}}
|
||||
{{if .IsOrganizationOwner}}
|
||||
<a class="tw-ml-auto ui mini button" href="{{.OrgLink}}/settings">
|
||||
{{svg "octicon-gear" 14}} {{ctx.Locale.Tr "org.settings.pinned.manage"}}
|
||||
</a>
|
||||
{{end}}
|
||||
</h4>
|
||||
|
||||
{{/* Ungrouped pinned repos */}}
|
||||
{{if .UngroupedPinned}}
|
||||
<div class="ui three stackable cards pinned-repos">
|
||||
{{range .UngroupedPinned}}
|
||||
{{if .Repo}}
|
||||
<a class="ui card" href="{{.Repo.Link}}">
|
||||
<div class="content">
|
||||
<div class="header text truncate">
|
||||
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 16}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 16}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 16}}{{else}}{{svg "octicon-repo" 16}}{{end}}
|
||||
{{.Repo.Name}}
|
||||
{{if .HasPinnedRepos}}
|
||||
{{/* Ungrouped pinned repos */}}
|
||||
{{if .UngroupedPinned}}
|
||||
<div class="ui three stackable cards pinned-repos">
|
||||
{{range .UngroupedPinned}}
|
||||
{{if .Repo}}
|
||||
<a class="ui card" href="{{.Repo.Link}}">
|
||||
<div class="content tw-text-center">
|
||||
{{if .Repo.Avatar}}
|
||||
<img class="tw-inline-block tw-rounded" style="max-width: 80px; max-height: 80px; object-fit: contain;" src="{{.Repo.RelAvatarLink ctx}}" alt="">
|
||||
{{else}}
|
||||
<div class="tw-inline-block tw-p-4">
|
||||
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 48}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 48}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 48}}{{else}}{{svg "octicon-repo" 48}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="header tw-mt-2">{{.Repo.Name}}</div>
|
||||
{{if .Repo.Description}}
|
||||
<div class="description text grey tw-text-sm tw-mt-1">{{.Repo.Description}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="extra content">
|
||||
{{if .Repo.PrimaryLanguage}}
|
||||
<span class="tw-mr-2">
|
||||
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
|
||||
{{.Repo.PrimaryLanguage.Language}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumStars}}
|
||||
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumForks}}
|
||||
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Grouped pinned repos */}}
|
||||
{{range .PinnedGroups}}
|
||||
{{$groupRepos := index $.PinnedByGroup .ID}}
|
||||
{{if $groupRepos}}
|
||||
<div class="pinned-group tw-mt-4">
|
||||
<h5 class="ui header tw-mb-2">
|
||||
{{svg "octicon-chevron-down" 14}} {{.Name}}
|
||||
</h5>
|
||||
<div class="ui three stackable cards pinned-repos">
|
||||
{{range $groupRepos}}
|
||||
{{if .Repo}}
|
||||
<a class="ui card" href="{{.Repo.Link}}">
|
||||
<div class="content tw-text-center">
|
||||
{{if .Repo.Avatar}}
|
||||
<img class="tw-inline-block tw-rounded" style="max-width: 80px; max-height: 80px; object-fit: contain;" src="{{.Repo.RelAvatarLink ctx}}" alt="">
|
||||
{{else}}
|
||||
<div class="tw-inline-block tw-p-4">
|
||||
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 48}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 48}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 48}}{{else}}{{svg "octicon-repo" 48}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="header tw-mt-2">{{.Repo.Name}}</div>
|
||||
{{if .Repo.Description}}
|
||||
<div class="description text grey tw-text-sm tw-mt-1">{{.Repo.Description}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="extra content">
|
||||
{{if .Repo.PrimaryLanguage}}
|
||||
<span class="tw-mr-2">
|
||||
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
|
||||
{{.Repo.PrimaryLanguage.Language}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumStars}}
|
||||
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumForks}}
|
||||
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{/* Empty state for pinned repos */}}
|
||||
<div class="ui placeholder segment tw-text-center">
|
||||
<div class="ui icon header">
|
||||
{{svg "octicon-pin" 48}}
|
||||
<div class="content">
|
||||
{{ctx.Locale.Tr "org.pinned_repos_empty_title"}}
|
||||
<div class="sub header">
|
||||
{{ctx.Locale.Tr "org.pinned_repos_empty_desc"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .IsOrganizationOwner}}
|
||||
<div class="tw-mt-4">
|
||||
<a class="ui primary button" href="{{.OrgLink}}/settings">
|
||||
{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "org.settings.pinned.setup"}}
|
||||
</a>
|
||||
</div>
|
||||
{{if .Repo.Description}}
|
||||
<div class="description text truncate">{{.Repo.Description}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="extra content">
|
||||
{{if .Repo.PrimaryLanguage}}
|
||||
<span class="tw-mr-2">
|
||||
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
|
||||
{{.Repo.PrimaryLanguage.Language}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumStars}}
|
||||
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumForks}}
|
||||
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* Profile README Empty State */}}
|
||||
{{if and (not .ProfileReadmeContent) .IsOrganizationOwner}}
|
||||
<div class="ui segment tw-mt-4">
|
||||
<div class="ui placeholder segment tw-text-center">
|
||||
<div class="ui icon header">
|
||||
{{svg "octicon-book" 32}}
|
||||
<div class="content">
|
||||
{{ctx.Locale.Tr "org.profile_readme_empty_title"}}
|
||||
<div class="sub header">
|
||||
{{ctx.Locale.Tr "org.profile_readme_empty_desc"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<div class="tw-mt-4">
|
||||
<form action="{{.OrgLink}}/create-profile-repo" method="post">{{.CsrfTokenHtml}}<button class="ui primary button" type="submit">
|
||||
{{svg "octicon-plus" 16}} {{ctx.Locale.Tr "org.create_profile_repo"}}
|
||||
</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Grouped pinned repos */}}
|
||||
{{range .PinnedGroups}}
|
||||
{{$groupRepos := index $.PinnedByGroup .ID}}
|
||||
{{if $groupRepos}}
|
||||
<div class="pinned-group tw-mt-4">
|
||||
<h5 class="ui header tw-mb-2">
|
||||
{{svg "octicon-chevron-down" 14}} {{.Name}}
|
||||
</h5>
|
||||
<div class="ui three stackable cards pinned-repos">
|
||||
{{range $groupRepos}}
|
||||
{{if .Repo}}
|
||||
<a class="ui card" href="{{.Repo.Link}}">
|
||||
<div class="content">
|
||||
<div class="header text truncate">
|
||||
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 16}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 16}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 16}}{{else}}{{svg "octicon-repo" 16}}{{end}}
|
||||
{{.Repo.Name}}
|
||||
{{/* Recent Activity Section */}}
|
||||
{{if .RecentActivity}}
|
||||
<div class="ui segment tw-mt-4">
|
||||
<h4 class="ui header tw-flex tw-items-center">
|
||||
{{svg "octicon-pulse" 16}} {{ctx.Locale.Tr "org.recent_activity"}}
|
||||
</h4>
|
||||
<div class="ui relaxed divided list">
|
||||
{{range .RecentActivity}}
|
||||
<div class="item">
|
||||
<div class="tw-flex tw-items-center tw-gap-3">
|
||||
{{if .Repo.Avatar}}
|
||||
<img style="width: 32px; height: 32px; border-radius: 4px; object-fit: cover;" src="{{.Repo.RelAvatarLink ctx}}" alt="">
|
||||
{{else}}
|
||||
<div class="tw-w-8 tw-h-8 tw-flex tw-items-center tw-justify-center">
|
||||
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 20}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 20}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 20}}{{else}}{{svg "octicon-repo" 20}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="tw-flex-1 tw-min-w-0">
|
||||
<a href="{{.Repo.Link}}" class="tw-font-semibold">{{.Repo.Name}}</a>
|
||||
{{if .CommitMessage}}
|
||||
<p class="text grey tw-text-sm tw-truncate tw-mb-0">{{.CommitMessage}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="tw-text-right tw-text-sm text grey tw-flex-shrink-0">
|
||||
<span title="{{DateUtils.FullTime .CommitTime}}">{{DateUtils.TimeSince .CommitTime}}</span>
|
||||
</div>
|
||||
{{if .Repo.Description}}
|
||||
<div class="description text truncate">{{.Repo.Description}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="extra content">
|
||||
{{if .Repo.PrimaryLanguage}}
|
||||
<span class="tw-mr-2">
|
||||
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
|
||||
{{.Repo.PrimaryLanguage.Language}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumStars}}
|
||||
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumForks}}
|
||||
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Public Members Section (on overview) */}}
|
||||
{{if and .PageIsViewOverview .PublicMembers}}
|
||||
<div class="ui segment public-members-section tw-mt-4">
|
||||
<h4 class="ui header tw-flex tw-items-center">
|
||||
{{svg "octicon-people" 16}} {{ctx.Locale.Tr "org.public_members"}}
|
||||
{{if .HasMorePublicMembers}}
|
||||
<a class="tw-ml-auto text grey tw-text-sm" href="{{.OrgLink}}/members">{{ctx.Locale.Tr "org.view_all_members" .TotalPublicMembers}}</a>
|
||||
{{end}}
|
||||
</h4>
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-2">
|
||||
{{range .PublicMembers}}
|
||||
<a href="{{.User.HomeLink}}" title="{{.User.Name}} ({{.Role}})" class="tw-flex tw-flex-col tw-items-center tw-p-2">
|
||||
{{ctx.AvatarUtils.Avatar .User 48}}
|
||||
<span class="tw-text-sm tw-mt-1">{{.User.Name}}</span>
|
||||
<span class="tw-text-xs text grey">{{.Role}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Repositories Tab Content */}}
|
||||
{{if .PageIsViewRepositories}}
|
||||
{{template "shared/repo/search" .}}
|
||||
{{template "shared/repo/list" .}}
|
||||
@@ -124,7 +196,6 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .ShowMemberAndTeamTab}}
|
||||
<div class="ui five wide column">
|
||||
{{if .CanCreateOrgRepo}}
|
||||
<div class="tw-flex tw-flex-wrap tw-justify-center tw-gap-x-1 tw-gap-y-2 tw-mb-4">
|
||||
@@ -136,7 +207,7 @@
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
|
||||
{{if and .ShowMemberAndTeamTab .ShowOrgProfileReadmeSelector}}
|
||||
{{if .ShowOrgProfileReadmeSelector}}
|
||||
<div class="tw-my-4">
|
||||
<div id="org-home-view-as-dropdown" class="ui dropdown jump">
|
||||
{{- $viewAsRole := Iif (.IsViewingOrgAsMember) (ctx.Locale.Tr "org.members.member") (ctx.Locale.Tr "settings.visibility.public") -}}
|
||||
@@ -151,28 +222,56 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-my-2">
|
||||
<div class="tw-my-2 text grey">
|
||||
{{if .IsViewingOrgAsMember}}{{ctx.Locale.Tr "org.view_as_member_hint"}}{{else}}{{ctx.Locale.Tr "org.view_as_public_hint"}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .NumMembers}}
|
||||
<h4 class="ui top attached header tw-flex">
|
||||
{{/* Organization Stats - Sidebar Card */}}
|
||||
{{if .OrgStats}}
|
||||
<div class="ui top attached header tw-flex">
|
||||
<strong class="tw-flex-1">{{svg "octicon-graph" 16}} {{ctx.Locale.Tr "org.stats"}}</strong>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||
<div class="tw-text-center tw-p-3" style="border: 1px solid var(--color-secondary); border-radius: 6px;">
|
||||
<div class="tw-text-2xl tw-font-bold">{{.OrgStats.TotalRepos}}</div>
|
||||
<div class="text grey tw-text-xs">{{ctx.Locale.Tr "org.repositories"}}</div>
|
||||
</div>
|
||||
<div class="tw-text-center tw-p-3" style="border: 1px solid var(--color-secondary); border-radius: 6px;">
|
||||
<div class="tw-text-2xl tw-font-bold">{{.OrgStats.TotalMembers}}</div>
|
||||
<div class="text grey tw-text-xs">{{ctx.Locale.Tr "org.members"}}</div>
|
||||
</div>
|
||||
<div class="tw-text-center tw-p-3" style="border: 1px solid var(--color-secondary); border-radius: 6px;">
|
||||
<div class="tw-text-2xl tw-font-bold">{{.OrgStats.TotalTeams}}</div>
|
||||
<div class="text grey tw-text-xs">{{ctx.Locale.Tr "org.teams"}}</div>
|
||||
</div>
|
||||
<div class="tw-text-center tw-p-3" style="border: 1px solid var(--color-secondary); border-radius: 6px;">
|
||||
<div class="tw-text-2xl tw-font-bold">{{.OrgStats.TotalStars}}</div>
|
||||
<div class="text grey tw-text-xs">{{ctx.Locale.Tr "repo.stars"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Members/Public Members Section */}}
|
||||
{{if .IsOrganizationMember}}
|
||||
{{/* Internal view - show all members */}}
|
||||
{{if .NumMembers}}
|
||||
<h4 class="ui top attached header tw-flex tw-mt-4">
|
||||
<strong class="tw-flex-1">{{ctx.Locale.Tr "org.members"}}</strong>
|
||||
<a class="text grey tw-flex tw-items-center" href="{{.OrgLink}}/members"><span>{{.NumMembers}}</span> {{svg "octicon-chevron-right"}}</a>
|
||||
</h4>
|
||||
<div class="ui attached segment members">
|
||||
{{$isMember := .IsOrganizationMember}}
|
||||
{{range .Members}}
|
||||
{{if or $isMember (call $.IsPublicMember .ID)}}
|
||||
<a href="{{.HomeLink}}" title="{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}">{{ctx.AvatarUtils.Avatar . 48}}</a>
|
||||
{{end}}
|
||||
<a href="{{.HomeLink}}" title="{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}">{{ctx.AvatarUtils.Avatar . 48}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .IsOrganizationMember}}
|
||||
<div class="ui top attached header tw-flex">
|
||||
{{end}}
|
||||
|
||||
{{/* Teams - only for members */}}
|
||||
<div class="ui top attached header tw-flex tw-mt-4">
|
||||
<strong class="tw-flex-1">{{ctx.Locale.Tr "org.teams"}}</strong>
|
||||
<a class="text grey tw-flex tw-items-center" href="{{.OrgLink}}/teams"><span>{{.Org.NumTeams}}</span> {{svg "octicon-chevron-right"}}</a>
|
||||
</div>
|
||||
@@ -192,9 +291,30 @@
|
||||
<a class="ui primary small button" href="{{.OrgLink}}/teams/new">{{ctx.Locale.Tr "org.create_new_team"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{/* Public view - show public members only */}}
|
||||
{{if .PublicMembers}}
|
||||
<h4 class="ui top attached header tw-flex tw-mt-4">
|
||||
<strong class="tw-flex-1">{{ctx.Locale.Tr "org.public_members"}}</strong>
|
||||
{{if .HasMorePublicMembers}}
|
||||
<a class="text grey tw-flex tw-items-center" href="{{.OrgLink}}/members"><span>{{.TotalPublicMembers}}</span> {{svg "octicon-chevron-right"}}</a>
|
||||
{{end}}
|
||||
</h4>
|
||||
<div class="ui attached segment members">
|
||||
{{range .PublicMembers}}
|
||||
<a href="{{.User.HomeLink}}" title="{{.User.Name}}{{if .User.FullName}} ({{.User.FullName}}){{end}}">{{ctx.AvatarUtils.Avatar .User 48}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<h4 class="ui top attached header tw-flex tw-mt-4">
|
||||
<strong class="tw-flex-1">{{ctx.Locale.Tr "org.public_members"}}</strong>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey tw-text-center">{{ctx.Locale.Tr "org.no_public_members"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<div class="ui container">
|
||||
<overflow-menu class="ui secondary pointing tabular borderless menu tw-mb-4">
|
||||
<div class="overflow-menu-items">
|
||||
{{if .HasOrgProfileReadme}}
|
||||
<a class="{{if .PageIsViewOverview}}active {{end}}item" href="{{$.Org.HomeLink}}">
|
||||
{{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsViewRepositories}}active {{end}}item" href="{{$.Org.HomeLink}}{{if .HasOrgProfileReadme}}/-/repositories{{end}}">
|
||||
<a class="{{if .PageIsViewOverview}}active {{end}}item" href="{{$.Org.HomeLink}}">
|
||||
{{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsViewRepositories}}active {{end}}item" href="{{$.Org.HomeLink}}/-/repositories">
|
||||
{{svg "octicon-repo"}} {{ctx.Locale.Tr "user.repositories"}}
|
||||
{{if .RepoCount}}
|
||||
<div class="ui small label">{{.RepoCount}}</div>
|
||||
|
||||
2
templates/pages/base_footer.tmpl
Normal file
2
templates/pages/base_footer.tmpl
Normal file
@@ -0,0 +1,2 @@
|
||||
</body>
|
||||
</html>
|
||||
170
templates/pages/base_head.tmpl
Normal file
170
templates/pages/base_head.tmpl
Normal file
@@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Config.Hero.Title}}{{.Config.Hero.Title}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.Name}}</title>
|
||||
<meta name="description" content="{{if .Config.Hero.Tagline}}{{.Config.Hero.Tagline}}{{else}}{{.Repository.Description}}{{end}}">
|
||||
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
|
||||
{{template "base/head_style" .}}
|
||||
<style>
|
||||
/* Pages standalone styles - no Gitea navbar */
|
||||
:root {
|
||||
--pages-primary: {{if .Config.Branding.PrimaryColor}}{{.Config.Branding.PrimaryColor}}{{else}}#4183c4{{end}};
|
||||
--pages-secondary: {{if .Config.Branding.SecondaryColor}}{{.Config.Branding.SecondaryColor}}{{else}}#6c757d{{end}};
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body.pages-body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #fff;
|
||||
}
|
||||
.pages-landing {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pages-main { flex: 1; }
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
/* Header styles */
|
||||
.pages-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
padding: 16px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.pages-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.pages-nav-brand {
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: #24292e;
|
||||
}
|
||||
.pages-nav-logo { height: 32px; }
|
||||
.pages-nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.pages-nav-link {
|
||||
color: #586069;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.pages-nav-link:hover { color: var(--pages-primary); }
|
||||
/* Hero styles */
|
||||
.pages-hero {
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f6f8fa 0%, #fff 100%);
|
||||
}
|
||||
.pages-logo {
|
||||
max-height: 80px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.pages-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px;
|
||||
color: #24292e;
|
||||
}
|
||||
.pages-tagline {
|
||||
font-size: 1.25rem;
|
||||
color: #586069;
|
||||
margin: 0 0 32px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.pages-cta { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
||||
/* Stats */
|
||||
.pages-stats {
|
||||
padding: 48px 0;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
.pages-stats-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 48px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pages-stat {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pages-stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #24292e;
|
||||
}
|
||||
.pages-stat-label {
|
||||
color: #586069;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* README */
|
||||
.pages-readme {
|
||||
padding: 64px 0;
|
||||
}
|
||||
.pages-readme .markup {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/* Footer */
|
||||
.pages-footer {
|
||||
background: #24292e;
|
||||
color: #fff;
|
||||
padding: 48px 0 24px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.pages-footer-links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.pages-footer-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.pages-footer-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.pages-footer-list li { margin-bottom: 8px; }
|
||||
.pages-footer-list a {
|
||||
color: #959da5;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.pages-footer-list a:hover { color: #fff; }
|
||||
.pages-footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #444d56;
|
||||
}
|
||||
.pages-footer-copyright, .pages-footer-powered {
|
||||
color: #959da5;
|
||||
font-size: 0.75rem;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.pages-footer-powered a { color: #79b8ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="pages-body">
|
||||
@@ -1,5 +1,118 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content pages-landing pages-documentation">
|
||||
{{template "pages/base_head" .}}
|
||||
<style>
|
||||
/* Documentation-specific styles */
|
||||
.pages-documentation {
|
||||
background: #fff;
|
||||
}
|
||||
.pages-documentation .pages-header {
|
||||
background: #24292e;
|
||||
border-bottom: none;
|
||||
}
|
||||
.pages-documentation .pages-nav-brand,
|
||||
.pages-documentation .pages-nav-link {
|
||||
color: #fff;
|
||||
}
|
||||
.pages-documentation .pages-nav-link:hover {
|
||||
color: #79b8ff;
|
||||
}
|
||||
.pages-docs-layout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 65px);
|
||||
}
|
||||
.pages-docs-sidebar {
|
||||
width: 280px;
|
||||
background: #f6f8fa;
|
||||
border-right: 1px solid #e1e4e8;
|
||||
padding: 24px;
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
height: calc(100vh - 65px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pages-docs-search {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.pages-search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.pages-docs-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.pages-docs-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #586069;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.pages-docs-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.pages-docs-list li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pages-docs-list a {
|
||||
color: #24292e;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pages-docs-list a:hover {
|
||||
background: #e1e4e8;
|
||||
color: var(--pages-primary);
|
||||
}
|
||||
.pages-docs-content {
|
||||
flex: 1;
|
||||
padding: 48px;
|
||||
max-width: 900px;
|
||||
}
|
||||
.pages-docs-article {
|
||||
line-height: 1.7;
|
||||
}
|
||||
.pages-docs-article h1,
|
||||
.pages-docs-article h2,
|
||||
.pages-docs-article h3 {
|
||||
margin-top: 32px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
}
|
||||
.pages-docs-article code {
|
||||
background: #f6f8fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.pages-docs-article pre {
|
||||
background: #24292e;
|
||||
color: #e1e4e8;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pages-docs-article pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.pages-docs-sidebar {
|
||||
display: none;
|
||||
}
|
||||
.pages-docs-content {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="pages-landing pages-documentation">
|
||||
{{template "pages/header" .}}
|
||||
|
||||
<div class="pages-docs-layout">
|
||||
@@ -21,6 +134,15 @@
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="pages-docs-section">
|
||||
<h4 class="pages-docs-section-title">Documentation</h4>
|
||||
<ul class="pages-docs-list">
|
||||
<li><a href="#">Getting Started</a></li>
|
||||
<li><a href="#">Installation</a></li>
|
||||
<li><a href="#">Configuration</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -32,12 +154,11 @@
|
||||
{{.ReadmeContent}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p>{{ctx.Locale.Tr "repo.no_desc"}}</p>
|
||||
<h1>{{.Repository.Name}}</h1>
|
||||
<p>{{if .Repository.Description}}{{.Repository.Description}}{{else}}Welcome to the documentation.{{end}}</p>
|
||||
{{end}}
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{template "pages/footer" .}}
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "pages/base_footer" .}}
|
||||
|
||||
@@ -1,5 +1,131 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content pages-landing pages-portfolio">
|
||||
{{template "pages/base_head" .}}
|
||||
<style>
|
||||
/* Portfolio-specific styles - Gallery/Creative design */
|
||||
.pages-portfolio {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
.pages-portfolio .pages-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.pages-portfolio .pages-nav-brand,
|
||||
.pages-portfolio .pages-nav-link {
|
||||
color: #fff;
|
||||
}
|
||||
.pages-portfolio .pages-nav-link:hover {
|
||||
color: var(--pages-primary);
|
||||
}
|
||||
.pages-portfolio .pages-hero {
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
padding: 100px 0 60px;
|
||||
}
|
||||
.pages-portfolio .pages-title {
|
||||
color: #fff;
|
||||
font-size: 2.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.pages-portfolio .pages-tagline {
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.pages-portfolio .pages-logo {
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
.pages-gallery {
|
||||
padding: 60px 0;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.pages-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns, 3), 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.pages-gallery-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
aspect-ratio: 1;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
.pages-gallery-item a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.pages-gallery-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
.pages-gallery-item:hover .pages-gallery-image {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
.pages-gallery-caption {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.pages-gallery-item:hover .pages-gallery-caption {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.pages-portfolio .pages-readme {
|
||||
background: #2d2d2d;
|
||||
padding: 60px 0;
|
||||
}
|
||||
.pages-portfolio .pages-readme .markup {
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
.pages-portfolio .pages-readme .markup h1,
|
||||
.pages-portfolio .pages-readme .markup h2,
|
||||
.pages-portfolio .pages-readme .markup h3 {
|
||||
color: #fff;
|
||||
}
|
||||
.pages-portfolio .pages-readme .markup a {
|
||||
color: var(--pages-primary);
|
||||
}
|
||||
.pages-portfolio .pages-readme .markup code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
}
|
||||
.pages-portfolio .pages-footer {
|
||||
background: #111;
|
||||
}
|
||||
/* Empty gallery placeholder */
|
||||
.pages-gallery-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
.pages-gallery-empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.pages-gallery-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.pages-gallery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="pages-landing pages-portfolio">
|
||||
{{template "pages/header" .}}
|
||||
|
||||
<main class="pages-main">
|
||||
@@ -20,7 +146,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Config.Hero.CTASecondary.Text}}
|
||||
<a href="{{.Config.Hero.CTASecondary.Link}}" class="ui button">
|
||||
<a href="{{.Config.Hero.CTASecondary.Link}}" class="ui button basic inverted">
|
||||
{{.Config.Hero.CTASecondary.Text}}
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -29,28 +155,31 @@
|
||||
</section>
|
||||
|
||||
<!-- Gallery Section -->
|
||||
{{if .Config.Gallery.Items}}
|
||||
<section class="pages-gallery">
|
||||
<div class="container">
|
||||
<div class="pages-gallery-grid" style="--columns: {{if .Config.Gallery.Columns}}{{.Config.Gallery.Columns}}{{else}}4{{end}}">
|
||||
{{if .Config.Gallery.Items}}
|
||||
<div class="pages-gallery-grid" style="--columns: {{if .Config.Gallery.Columns}}{{.Config.Gallery.Columns}}{{else}}3{{end}}">
|
||||
{{range .Config.Gallery.Items}}
|
||||
<div class="pages-gallery-item">
|
||||
{{if .Link}}
|
||||
<a href="{{.Link}}">
|
||||
{{end}}
|
||||
{{if .Link}}<a href="{{.Link}}">{{end}}
|
||||
<img src="{{.Image}}" alt="{{.Title}}" class="pages-gallery-image">
|
||||
{{if .Title}}
|
||||
<div class="pages-gallery-caption">{{.Title}}</div>
|
||||
{{end}}
|
||||
{{if .Link}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Link}}</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="pages-gallery-grid" style="--columns: 3">
|
||||
<div class="pages-gallery-empty">
|
||||
{{svg "octicon-image" 48}}
|
||||
<p>Add gallery items in your landing.yaml configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- README Section -->
|
||||
{{if .ReadmeContent}}
|
||||
@@ -66,4 +195,4 @@
|
||||
|
||||
{{template "pages/footer" .}}
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "pages/base_footer" .}}
|
||||
|
||||
@@ -1,11 +1,121 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content pages-landing pages-product">
|
||||
{{template "pages/base_head" .}}
|
||||
<style>
|
||||
/* Product-specific styles - Bold marketing design */
|
||||
.pages-product {
|
||||
background: #fff;
|
||||
}
|
||||
.pages-product .pages-header {
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
}
|
||||
.pages-product .pages-nav-brand,
|
||||
.pages-product .pages-nav-link {
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
.pages-hero-product {
|
||||
background: linear-gradient(135deg, var(--pages-primary) 0%, #1a365d 100%);
|
||||
color: #fff;
|
||||
padding: 120px 0 80px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pages-hero-product::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
.pages-hero-product .container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.pages-product .pages-title {
|
||||
color: #fff;
|
||||
font-size: 3.5rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.pages-product .pages-tagline {
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 1.5rem;
|
||||
max-width: 700px;
|
||||
}
|
||||
.pages-product .pages-logo {
|
||||
max-height: 100px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.pages-features {
|
||||
padding: 80px 0;
|
||||
background: #fff;
|
||||
}
|
||||
.pages-features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 48px;
|
||||
}
|
||||
.pages-feature {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
.pages-feature-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 24px;
|
||||
background: linear-gradient(135deg, var(--pages-primary) 0%, #1a365d 100%);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.pages-feature-icon img {
|
||||
max-width: 32px;
|
||||
max-height: 32px;
|
||||
}
|
||||
.pages-feature-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
color: #24292e;
|
||||
}
|
||||
.pages-feature-description {
|
||||
color: #586069;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.pages-product .pages-stats {
|
||||
background: linear-gradient(135deg, #f6f8fa 0%, #e1e4e8 100%);
|
||||
padding: 64px 0;
|
||||
}
|
||||
.pages-cta-bottom {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.pages-product .pages-readme {
|
||||
background: #fff;
|
||||
padding: 80px 0;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.pages-product .pages-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.pages-hero-product {
|
||||
padding: 100px 0 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="pages-landing pages-product">
|
||||
{{template "pages/header" .}}
|
||||
|
||||
<main class="pages-main">
|
||||
<!-- Hero Section -->
|
||||
<section class="pages-hero pages-hero-product" {{if .Config.Hero.Background}}style="background-image: url('{{.Config.Hero.Background}}')"{{end}}>
|
||||
<div class="pages-hero-overlay"></div>
|
||||
<div class="container">
|
||||
{{if .Config.Branding.Logo}}
|
||||
<img src="{{.Config.Branding.Logo}}" alt="{{.Repository.Name}}" class="pages-logo">
|
||||
@@ -16,12 +126,12 @@
|
||||
{{end}}
|
||||
<div class="pages-cta">
|
||||
{{if .Config.Hero.CTAPrimary.Text}}
|
||||
<a href="{{.Config.Hero.CTAPrimary.Link}}" class="ui large primary button">
|
||||
<a href="{{.Config.Hero.CTAPrimary.Link}}" class="ui large primary button" style="background: #fff; color: var(--pages-primary);">
|
||||
{{.Config.Hero.CTAPrimary.Text}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Config.Hero.CTASecondary.Text}}
|
||||
<a href="{{.Config.Hero.CTASecondary.Link}}" class="ui large button {{if eq .Config.Hero.CTASecondary.Style "outline"}}basic inverted{{end}}">
|
||||
<a href="{{.Config.Hero.CTASecondary.Link}}" class="ui large button basic inverted">
|
||||
{{.Config.Hero.CTASecondary.Text}}
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -38,11 +148,7 @@
|
||||
<div class="pages-feature">
|
||||
{{if .Icon}}
|
||||
<div class="pages-feature-icon">
|
||||
{{if hasPrefix .Icon "./"}}
|
||||
<img src="{{.Icon}}" alt="{{.Title}}">
|
||||
{{else}}
|
||||
{{svg (printf "octicon-%s" .Icon) 32}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<h3 class="pages-feature-title">{{.Title}}</h3>
|
||||
@@ -89,4 +195,4 @@
|
||||
|
||||
{{template "pages/footer" .}}
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "pages/base_footer" .}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content pages-landing pages-simple">
|
||||
{{template "pages/base_head" .}}
|
||||
<div class="pages-landing pages-simple">
|
||||
{{template "pages/header" .}}
|
||||
|
||||
<main class="pages-main">
|
||||
@@ -43,18 +43,15 @@
|
||||
<div class="container">
|
||||
<div class="pages-stats-grid">
|
||||
<div class="pages-stat">
|
||||
<span class="pages-stat-icon">{{svg "octicon-star"}}</span>
|
||||
<span class="pages-stat-value">{{.NumStars}}</span>
|
||||
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.stars"}}</span>
|
||||
</div>
|
||||
<div class="pages-stat">
|
||||
<span class="pages-stat-icon">{{svg "octicon-repo-forked"}}</span>
|
||||
<span class="pages-stat-value">{{.NumForks}}</span>
|
||||
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.forks"}}</span>
|
||||
</div>
|
||||
{{if .Repository.PrimaryLanguage}}
|
||||
<div class="pages-stat">
|
||||
<span class="pages-stat-icon language-color" style="background-color: {{.Repository.PrimaryLanguage.Color}}"></span>
|
||||
<span class="pages-stat-value">{{.Repository.PrimaryLanguage.Language}}</span>
|
||||
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.language"}}</span>
|
||||
</div>
|
||||
@@ -66,4 +63,4 @@
|
||||
|
||||
{{template "pages/footer" .}}
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "pages/base_footer" .}}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
{{if not $.DisableStars}}
|
||||
{{template "repo/star_unstar" $}}
|
||||
{{end}}
|
||||
{{template "repo/pin_unpin" $}}
|
||||
{{if and (not .IsEmpty) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}}
|
||||
<div class="ui labeled button
|
||||
{{if or (not $.IsSigned) (and (not $.CanSignedUserFork) (not $.UserAndOrgForks))}}
|
||||
@@ -154,7 +155,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if and .Repository.CanEnablePulls (.Permission.CanRead ctx.Consts.RepoUnitTypePullRequests)}}
|
||||
{{if and ctx.IsSigned .Repository.CanEnablePulls (.Permission.CanRead ctx.Consts.RepoUnitTypePullRequests)}}
|
||||
<a class="{{if .PageIsPullList}}active {{end}}item" href="{{.RepoLink}}/pulls">
|
||||
{{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.pulls"}}
|
||||
{{if .Repository.NumOpenPulls}}
|
||||
@@ -163,7 +164,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}}
|
||||
{{if and .EnableActions (.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) (not .IsEmptyRepo)}}
|
||||
<a class="{{if .PageIsActions}}active {{end}}item" href="{{.RepoLink}}/actions">
|
||||
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}
|
||||
{{if .Repository.NumOpenActionRuns}}
|
||||
@@ -179,7 +180,7 @@
|
||||
{{end}}
|
||||
|
||||
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
|
||||
{{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
|
||||
{{if and ctx.IsSigned (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
|
||||
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
|
||||
{{svg "octicon-project"}} {{ctx.Locale.Tr "repo.projects"}}
|
||||
{{if .Repository.NumOpenProjects}}
|
||||
@@ -209,7 +210,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if and (.Permission.CanReadAny ctx.Consts.RepoUnitTypePullRequests ctx.Consts.RepoUnitTypeIssues ctx.Consts.RepoUnitTypeReleases ctx.Consts.RepoUnitTypeCode) (not .IsEmptyRepo)}}
|
||||
{{if and (.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) (not .IsEmptyRepo)}}
|
||||
<a class="{{if .PageIsActivity}}active {{end}}item" href="{{.RepoLink}}/activity">
|
||||
{{svg "octicon-pulse"}} {{ctx.Locale.Tr "repo.activity"}}
|
||||
</a>
|
||||
|
||||
33
templates/repo/pin_unpin.tmpl
Normal file
33
templates/repo/pin_unpin.tmpl
Normal file
@@ -0,0 +1,33 @@
|
||||
{{if $.IsSigned}}
|
||||
<div class="ui labeled button" id="pin-repo-dropdown">
|
||||
<div class="ui compact small basic button dropdown" data-tooltip-content="{{ctx.Locale.Tr "repo.pin.tooltip"}}">
|
||||
{{svg "octicon-pin" 16}}<span class="text not-mobile">{{ctx.Locale.Tr "repo.pin"}}</span>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
{{/* Pin to user profile */}}
|
||||
{{if $.IsRepoPinnedToUser}}
|
||||
<a class="item" href="{{$.RepoLink}}/action/unpin?type=user&redirect_to={{$.Link}}">
|
||||
{{svg "octicon-pin-slash" 16}} {{ctx.Locale.Tr "repo.pin.unpin_from_profile"}}
|
||||
</a>
|
||||
{{else}}
|
||||
<a class="item" href="{{$.RepoLink}}/action/pin?type=user&redirect_to={{$.Link}}">
|
||||
{{svg "octicon-person" 16}} {{ctx.Locale.Tr "repo.pin.pin_to_profile"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{/* Pin to organization (if applicable) */}}
|
||||
{{if and .Repository.Owner.IsOrganization $.IsOrganizationMember}}
|
||||
{{if $.IsRepoPinnedToOrg}}
|
||||
<a class="item" href="{{$.RepoLink}}/action/unpin?type=org&redirect_to={{$.Link}}">
|
||||
{{svg "octicon-pin-slash" 16}} {{ctx.Locale.Tr "repo.pin.unpin_from_org"}}
|
||||
</a>
|
||||
{{else}}
|
||||
<a class="item" href="{{$.RepoLink}}/action/pin?type=org&redirect_to={{$.Link}}">
|
||||
{{svg "octicon-organization" 16}} {{ctx.Locale.Tr "repo.pin.pin_to_org"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -88,27 +88,147 @@
|
||||
{{$release.RenderedNote}}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<details class="download" {{if eq $idx 0}}open{{end}}>
|
||||
|
||||
{{/* For the first/latest release, show downloads directly without collapsible */}}
|
||||
{{if eq $idx 0}}
|
||||
<div class="download-section">
|
||||
{{else}}
|
||||
<details class="download">
|
||||
<summary>
|
||||
{{ctx.Locale.Tr "repo.release.downloads"}}
|
||||
</summary>
|
||||
<ul class="ui divided list attachment-list">
|
||||
{{range $att := $release.Attachments}}
|
||||
<li class="item">
|
||||
<a target="_blank" class="tw-flex-1 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
|
||||
<strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong>
|
||||
</a>
|
||||
<div class="attachment-right-info flex-text-inline">
|
||||
<span class="tw-pl-5">{{$att.Size | FileSize}}</span>
|
||||
<span class="flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber $att.DownloadCount)}}">
|
||||
{{svg "octicon-info"}}
|
||||
</span>
|
||||
<div class="tw-flex-1"></div>
|
||||
{{DateUtils.TimeSince $att.CreatedUnix}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
{{/* Group attachments by OS */}}
|
||||
{{$windowsFiles := newSlice}}
|
||||
{{$macosFiles := newSlice}}
|
||||
{{$linuxFiles := newSlice}}
|
||||
{{$otherFiles := newSlice}}
|
||||
|
||||
{{range $att := $release.Attachments}}
|
||||
{{$name := StringUtils.ToLower $att.Name}}
|
||||
{{if or (StringUtils.Contains $name "windows") (StringUtils.Contains $name "win64") (StringUtils.Contains $name "win32") (StringUtils.Contains $name "-win.") (StringUtils.Contains $name "_win.") (StringUtils.Contains $name "-win-") (StringUtils.Contains $name "_win_") (StringUtils.Contains $name ".exe") (StringUtils.Contains $name ".msi")}}
|
||||
{{$windowsFiles = Append $windowsFiles $att}}
|
||||
{{else if or (StringUtils.Contains $name "darwin") (StringUtils.Contains $name "macos") (StringUtils.Contains $name "-mac.") (StringUtils.Contains $name "_mac.") (StringUtils.Contains $name "-mac-") (StringUtils.Contains $name "_mac_") (StringUtils.Contains $name "osx") (StringUtils.Contains $name ".dmg") (StringUtils.Contains $name ".pkg")}}
|
||||
{{$macosFiles = Append $macosFiles $att}}
|
||||
{{else if or (StringUtils.Contains $name "linux") (StringUtils.Contains $name "-lin.") (StringUtils.Contains $name "_lin.") (StringUtils.Contains $name "-lin-") (StringUtils.Contains $name "_lin_") (StringUtils.Contains $name ".deb") (StringUtils.Contains $name ".rpm") (StringUtils.Contains $name ".appimage")}}
|
||||
{{$linuxFiles = Append $linuxFiles $att}}
|
||||
{{else}}
|
||||
{{$otherFiles = Append $otherFiles $att}}
|
||||
{{end}}
|
||||
{{if and (not $.DisableDownloadSourceArchives) (not $release.IsDraft) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}}
|
||||
{{end}}
|
||||
|
||||
{{/* Windows Downloads */}}
|
||||
{{if $windowsFiles}}
|
||||
<div class="tw-pt-2 tw-mb-3">
|
||||
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2 tw-font-medium">
|
||||
{{svg "octicon-device-desktop" 16}} Windows
|
||||
</h5>
|
||||
<ul class="ui divided list attachment-list tw-ml-4">
|
||||
{{range $att := $windowsFiles}}
|
||||
<li class="item">
|
||||
<a target="_blank" class="tw-flex-1 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
|
||||
<strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong>
|
||||
</a>
|
||||
<div class="attachment-right-info flex-text-inline">
|
||||
<span class="tw-pl-5">{{$att.Size | FileSize}}</span>
|
||||
<span class="flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber $att.DownloadCount)}}">
|
||||
{{svg "octicon-info"}}
|
||||
</span>
|
||||
<div class="tw-flex-1"></div>
|
||||
{{DateUtils.TimeSince $att.CreatedUnix}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* macOS Downloads */}}
|
||||
{{if $macosFiles}}
|
||||
<div class="tw-pt-2 tw-mb-3">
|
||||
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2 tw-font-medium">
|
||||
{{svg "octicon-device-desktop" 16}} macOS
|
||||
</h5>
|
||||
<ul class="ui divided list attachment-list tw-ml-4">
|
||||
{{range $att := $macosFiles}}
|
||||
<li class="item">
|
||||
<a target="_blank" class="tw-flex-1 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
|
||||
<strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong>
|
||||
</a>
|
||||
<div class="attachment-right-info flex-text-inline">
|
||||
<span class="tw-pl-5">{{$att.Size | FileSize}}</span>
|
||||
<span class="flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber $att.DownloadCount)}}">
|
||||
{{svg "octicon-info"}}
|
||||
</span>
|
||||
<div class="tw-flex-1"></div>
|
||||
{{DateUtils.TimeSince $att.CreatedUnix}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Linux Downloads */}}
|
||||
{{if $linuxFiles}}
|
||||
<div class="tw-pt-2 tw-mb-3">
|
||||
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2 tw-font-medium">
|
||||
{{svg "octicon-terminal" 16}} Linux
|
||||
</h5>
|
||||
<ul class="ui divided list attachment-list tw-ml-4">
|
||||
{{range $att := $linuxFiles}}
|
||||
<li class="item">
|
||||
<a target="_blank" class="tw-flex-1 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
|
||||
<strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong>
|
||||
</a>
|
||||
<div class="attachment-right-info flex-text-inline">
|
||||
<span class="tw-pl-5">{{$att.Size | FileSize}}</span>
|
||||
<span class="flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber $att.DownloadCount)}}">
|
||||
{{svg "octicon-info"}}
|
||||
</span>
|
||||
<div class="tw-flex-1"></div>
|
||||
{{DateUtils.TimeSince $att.CreatedUnix}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Other Downloads */}}
|
||||
{{if $otherFiles}}
|
||||
<div class="tw-pt-2 tw-mb-3">
|
||||
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2 tw-font-medium">
|
||||
{{svg "octicon-file" 16}} Other
|
||||
</h5>
|
||||
<ul class="ui divided list attachment-list tw-ml-4">
|
||||
{{range $att := $otherFiles}}
|
||||
<li class="item">
|
||||
<a target="_blank" class="tw-flex-1 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
|
||||
<strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong>
|
||||
</a>
|
||||
<div class="attachment-right-info flex-text-inline">
|
||||
<span class="tw-pl-5">{{$att.Size | FileSize}}</span>
|
||||
<span class="flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber $att.DownloadCount)}}">
|
||||
{{svg "octicon-info"}}
|
||||
</span>
|
||||
<div class="tw-flex-1"></div>
|
||||
{{DateUtils.TimeSince $att.CreatedUnix}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Source Code Archives */}}
|
||||
{{if and (not $.DisableDownloadSourceArchives) (not $release.IsDraft) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}}
|
||||
<div class="tw-pt-2 tw-mb-3">
|
||||
<h5 class="tw-flex tw-items-center tw-gap-2 tw-mb-2 tw-font-medium">
|
||||
{{svg "octicon-code" 16}} Source Code
|
||||
</h5>
|
||||
<ul class="ui divided list attachment-list tw-ml-4">
|
||||
<li class="item">
|
||||
<a class="archive-link" download href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.zip" rel="nofollow">
|
||||
<strong class="flex-text-inline">{{svg "octicon-file-zip" 16 "download-icon"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP)</strong>
|
||||
@@ -119,9 +239,15 @@
|
||||
<strong class="flex-text-inline">{{svg "octicon-file-zip" 16 "download-icon"}}{{ctx.Locale.Tr "repo.release.source_code"}} (TAR.GZ)</strong>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq $idx 0}}
|
||||
</div>
|
||||
{{else}}
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
|
||||
{{if $canReadReleases}}
|
||||
<div class="flex-text-block">
|
||||
<div class="tw-flex-1 tw-flex tw-items-center">
|
||||
<div class="tw-flex-1 tw-flex tw-items-center tw-gap-4">
|
||||
<h2 class="ui compact small menu small-menu-items">
|
||||
<a class="{{if and .PageIsReleaseList (not .PageIsSingleTag)}}active {{end}}item" href="{{.RepoLink}}/releases">{{ctx.Locale.PrettyNumber .NumReleases}} {{ctx.Locale.TrN .NumReleases "repo.release" "repo.releases"}}</a>
|
||||
{{if $canReadCode}}
|
||||
<a class="{{if or .PageIsTagList .PageIsSingleTag}}active {{end}}item" href="{{.RepoLink}}/tags">{{ctx.Locale.PrettyNumber .NumTags}} {{ctx.Locale.TrN .NumTags "repo.tag" "repo.tags"}}</a>
|
||||
{{end}}
|
||||
</h2>
|
||||
{{if and .PageIsReleaseList (not .PageIsSingleTag)}}
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" id="show-archived" {{if .ShowArchived}}checked{{end}} onchange="window.location.href='{{.RepoLink}}/releases?archived=' + (this.checked ? 'true' : 'false')">
|
||||
<label for="show-archived">{{ctx.Locale.Tr "repo.release.show_archived"}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .EnableFeed}}
|
||||
<a class="ui small button" href="{{.RepoLink}}/{{if .PageIsTagList}}tags{{else}}releases{{end}}.rss">
|
||||
@@ -22,14 +28,6 @@
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if and .PageIsReleaseList (not .PageIsSingleTag)}}
|
||||
<div class="tw-flex tw-items-center tw-mb-2">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" id="show-archived" {{if .ShowArchived}}checked{{end}} onchange="window.location.href='{{.RepoLink}}/releases?archived=' + (this.checked ? 'true' : 'false')">
|
||||
<label for="show-archived">{{ctx.Locale.Tr "repo.release.show_archived"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="divider"></div>
|
||||
{{else if $canReadCode}}
|
||||
{{/* if the "repo.releases" unit is disabled, only show the "commits / branches / tags" sub menu */}}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="ui positive message">
|
||||
<div class="header">{{ctx.Locale.Tr "repo.settings.pages.enabled"}}</div>
|
||||
<p>{{ctx.Locale.Tr "repo.settings.pages.enabled_desc"}}</p>
|
||||
<p><strong>{{ctx.Locale.Tr "repo.settings.pages.subdomain"}}:</strong> <code>{{.PagesSubdomain}}</code></p>
|
||||
<p><strong>{{ctx.Locale.Tr "repo.settings.pages.subdomain"}}:</strong> <a href="{{.PagesURL}}" target="_blank" rel="noopener noreferrer">{{.PagesURL}}</a></p>
|
||||
</div>
|
||||
|
||||
<form class="ui form" method="post">
|
||||
@@ -104,6 +104,13 @@
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="tw-text-right">
|
||||
{{if and .Verified (eq .SSLStatus "pending")}}
|
||||
<form method="post" class="tw-inline-block">
|
||||
<input type="hidden" name="action" value="activate_ssl">
|
||||
<input type="hidden" name="domain_id" value="{{.ID}}">
|
||||
<button class="ui green tiny button">{{ctx.Locale.Tr "repo.settings.pages.activate_ssl"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Verified}}
|
||||
<form method="post" class="tw-inline-block">
|
||||
<input type="hidden" name="action" value="verify_domain">
|
||||
@@ -118,7 +125,14 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{if not .Verified}}
|
||||
{{if and .Verified (eq .SSLStatus "pending")}}
|
||||
<form method="post" class="tw-inline-block">
|
||||
<input type="hidden" name="action" value="activate_ssl">
|
||||
<input type="hidden" name="domain_id" value="{{.ID}}">
|
||||
<button class="ui green tiny button">{{ctx.Locale.Tr "repo.settings.pages.activate_ssl"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Verified}}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="ui info message">
|
||||
@@ -138,6 +152,13 @@
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.add_domain"}}</label>
|
||||
<input name="domain" type="text" placeholder="example.com">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="ssl_external" id="ssl_external">
|
||||
<label for="ssl_external">{{ctx.Locale.Tr "repo.settings.pages.ssl_external"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.ssl_external_desc"}}</p>
|
||||
</div>
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.pages.add"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,73 +3,202 @@
|
||||
{{ctx.Locale.Tr "actions.runners.runner_title"}} {{.Runner.ID}} {{.Runner.Name}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post">
|
||||
{{template "base/disable_form_autofill"}}
|
||||
<div class="runner-basic-info">
|
||||
<div class="field tw-inline-block tw-mr-4">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.status"}}</label>
|
||||
<span class="ui {{if .Runner.IsOnline}}green{{else}}basic{{end}} label">{{.Runner.StatusLocaleName ctx.Locale}}</span>
|
||||
</div>
|
||||
<div class="field tw-inline-block tw-mr-4">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.last_online"}}</label>
|
||||
<span>{{if .Runner.LastOnline}}{{DateUtils.TimeSince .Runner.LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</span>
|
||||
</div>
|
||||
<div class="field tw-inline-block tw-mr-4">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.labels"}}</label>
|
||||
<span>
|
||||
{{range .Runner.AgentLabels}}
|
||||
<span class="ui label">{{.}}</span>
|
||||
{{end}}
|
||||
<!-- Health Status Tiles -->
|
||||
<div class="ui three column stackable grid tw-mb-4">
|
||||
<div class="column">
|
||||
<div class="ui segment tw-text-center">
|
||||
<div class="tw-text-sm tw-mb-1" style="opacity: 0.8;">Status</div>
|
||||
<span id="status-label" class="ui {{if .Runner.IsOnline}}green{{else}}red{{end}} large label">
|
||||
<span id="status-icon">{{if .Runner.IsOnline}}{{svg "octicon-check-circle" 16}}{{else}}{{svg "octicon-x-circle" 16}}{{end}}</span>
|
||||
<span id="status-text">{{.Runner.StatusLocaleName ctx.Locale}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="field tw-inline-block tw-mr-4">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.owner_type"}}</label>
|
||||
<span data-tooltip-content="{{.Runner.BelongsToOwnerName}}">{{.Runner.BelongsToOwnerType.LocaleString ctx.Locale}}</span>
|
||||
<div id="status-subtext" class="tw-text-xs tw-mt-2" style="opacity: 0.7;">
|
||||
{{if .Runner.IsOnline}}Connected{{else if .Runner.LastOnline}}Last seen {{DateUtils.TimeSince .Runner.LastOnline}}{{else}}Never connected{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ui segment tw-text-center">
|
||||
<div class="tw-text-sm tw-mb-1" style="opacity: 0.8;">Disk Space</div>
|
||||
{{if and .RunnerCapabilities .RunnerCapabilities.Disk}}
|
||||
{{$diskUsed := .RunnerCapabilities.Disk.UsedPercent}}
|
||||
{{$diskFreeGB := DivideFloat64 (Int64ToFloat64 .RunnerCapabilities.Disk.Free) 1073741824.0}}
|
||||
{{$diskTotalGB := DivideFloat64 (Int64ToFloat64 .RunnerCapabilities.Disk.Total) 1073741824.0}}
|
||||
<span id="disk-label" class="ui {{if ge $diskUsed 95.0}}red{{else if ge $diskUsed 85.0}}yellow{{else}}green{{end}} large label">
|
||||
<span id="disk-icon">{{if ge $diskUsed 95.0}}{{svg "octicon-alert" 16}}{{else if ge $diskUsed 85.0}}{{svg "octicon-alert" 16}}{{else}}{{svg "octicon-database" 16}}{{end}}</span>
|
||||
<span id="disk-text">{{printf "%.0f" $diskUsed}}% used</span>
|
||||
</span>
|
||||
<div id="disk-subtext" class="tw-text-xs tw-mt-2" style="opacity: 0.7;">
|
||||
{{printf "%.1f" $diskFreeGB}} GB free of {{printf "%.0f" $diskTotalGB}} GB
|
||||
</div>
|
||||
{{else}}
|
||||
<span id="disk-label" class="ui grey large label"><span id="disk-icon">{{svg "octicon-database" 16}}</span> <span id="disk-text">No data</span></span>
|
||||
<div id="disk-subtext" class="tw-text-xs tw-mt-2" style="opacity: 0.7;">Waiting for report</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ui segment tw-text-center">
|
||||
<div class="tw-text-sm tw-mb-1" style="opacity: 0.8;">Network</div>
|
||||
{{if and .RunnerCapabilities .RunnerCapabilities.Bandwidth}}
|
||||
<span id="bw-label" class="ui {{if ge .RunnerCapabilities.Bandwidth.DownloadMbps 100.0}}green{{else if ge .RunnerCapabilities.Bandwidth.DownloadMbps 10.0}}blue{{else}}yellow{{end}} large label">
|
||||
<span id="bw-icon">{{svg "octicon-arrow-down" 16}}</span>
|
||||
<span id="bw-text">{{printf "%.0f" .RunnerCapabilities.Bandwidth.DownloadMbps}} Mbps</span>
|
||||
</span>
|
||||
<div id="bw-subtext" class="tw-text-xs tw-mt-2" style="opacity: 0.7;">
|
||||
{{if gt .RunnerCapabilities.Bandwidth.Latency 0.0}}{{printf "%.0f" .RunnerCapabilities.Bandwidth.Latency}} ms latency{{end}}
|
||||
{{if .RunnerCapabilities.Bandwidth.TestedAt}}- tested {{DateUtils.TimeSince .RunnerCapabilities.Bandwidth.TestedAt}}{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<span id="bw-label" class="ui grey large label"><span id="bw-icon">{{svg "octicon-globe" 16}}</span> <span id="bw-text">No data</span></span>
|
||||
<div id="bw-subtext" class="tw-text-xs tw-mt-2" style="opacity: 0.7;">Waiting for test</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Runner.CapabilitiesJSON}}
|
||||
<div class="divider"></div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.capabilities"}}</label>
|
||||
<div class="ui two column stackable grid">
|
||||
<!-- Left Column: Runner Info & Controls -->
|
||||
<div class="column">
|
||||
<div class="ui segment">
|
||||
<h5 class="ui header">Runner Information</h5>
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 100px; opacity: 0.8;">Version</td>
|
||||
<td><span class="ui small blue label">{{.Runner.Version}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="opacity: 0.8;">Type</td>
|
||||
<td><span class="ui small label">{{.Runner.BelongsToOwnerType.LocaleString ctx.Locale}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="opacity: 0.8;">Owner</td>
|
||||
<td>{{if .Runner.BelongsToOwnerName}}{{.Runner.BelongsToOwnerName}}{{else}}<span style="opacity: 0.6;">System</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="opacity: 0.8;">Labels</td>
|
||||
<td>
|
||||
{{range .Runner.AgentLabels}}
|
||||
<form method="post" action="{{$.Link}}/remove-label" style="display:inline;">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="label" value="{{.}}">
|
||||
<button type="submit" class="ui small blue label tw-my-1" style="cursor:pointer;">{{.}} {{svg "octicon-x" 12}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Runner.AgentLabels}}<span style="opacity: 0.6;">No labels</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Suggested Labels Section -->
|
||||
{{if .RunnerCapabilities}}
|
||||
<div class="ui segment">
|
||||
<h5 class="ui header">{{svg "octicon-light-bulb" 16}} Suggested Labels</h5>
|
||||
<p class="tw-text-sm tw-mb-2" style="opacity: 0.7;">Based on detected capabilities. Click + to add individually.</p>
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-2" id="suggested-labels">
|
||||
{{$labels := .Runner.AgentLabels}}
|
||||
{{if eq .RunnerCapabilities.OS "linux"}}
|
||||
{{if not (SliceUtils.Contains $labels "linux")}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="linux"><button type="submit" class="ui small teal label" style="cursor:pointer;">{{svg "octicon-plus" 12}} linux</button></form>
|
||||
{{else}}<span class="ui small teal label">linux</span>{{end}}
|
||||
{{if not (SliceUtils.Contains $labels "linux-latest")}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="linux-latest"><button type="submit" class="ui small teal label" style="cursor:pointer;">{{svg "octicon-plus" 12}} linux-latest</button></form>
|
||||
{{else}}<span class="ui small teal label">linux-latest</span>{{end}}
|
||||
{{else if eq .RunnerCapabilities.OS "windows"}}
|
||||
{{if not (SliceUtils.Contains $labels "windows")}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="windows"><button type="submit" class="ui small teal label" style="cursor:pointer;">{{svg "octicon-plus" 12}} windows</button></form>
|
||||
{{else}}<span class="ui small teal label">windows</span>{{end}}
|
||||
{{if not (SliceUtils.Contains $labels "windows-latest")}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="windows-latest"><button type="submit" class="ui small teal label" style="cursor:pointer;">{{svg "octicon-plus" 12}} windows-latest</button></form>
|
||||
{{else}}<span class="ui small teal label">windows-latest</span>{{end}}
|
||||
{{else if eq .RunnerCapabilities.OS "darwin"}}
|
||||
{{if not (SliceUtils.Contains $labels "macos")}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="macos"><button type="submit" class="ui small teal label" style="cursor:pointer;">{{svg "octicon-plus" 12}} macos</button></form>
|
||||
{{else}}<span class="ui small teal label">macos</span>{{end}}
|
||||
{{if not (SliceUtils.Contains $labels "macos-latest")}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="macos-latest"><button type="submit" class="ui small teal label" style="cursor:pointer;">{{svg "octicon-plus" 12}} macos-latest</button></form>
|
||||
{{else}}<span class="ui small teal label">macos-latest</span>{{end}}
|
||||
{{end}}
|
||||
{{if and .RunnerCapabilities.Distro .RunnerCapabilities.Distro.ID}}
|
||||
{{$distro := .RunnerCapabilities.Distro.ID}}
|
||||
{{$distroLatest := printf "%s-latest" .RunnerCapabilities.Distro.ID}}
|
||||
{{if not (SliceUtils.Contains $labels $distro)}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="{{$distro}}"><button type="submit" class="ui small purple label" style="cursor:pointer;">{{svg "octicon-plus" 12}} {{$distro}}</button></form>
|
||||
{{else}}<span class="ui small purple label">{{$distro}}</span>{{end}}
|
||||
{{if not (SliceUtils.Contains $labels $distroLatest)}}
|
||||
<form method="post" action="{{$.Link}}/add-label" style="display:inline;">{{$.CsrfTokenHtml}}<input type="hidden" name="label" value="{{$distroLatest}}"><button type="submit" class="ui small purple label" style="cursor:pointer;">{{svg "octicon-plus" 12}} {{$distroLatest}}</button></form>
|
||||
{{else}}<span class="ui small purple label">{{$distroLatest}}</span>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form id="runner-form" class="ui form" method="post">
|
||||
{{template "base/disable_form_autofill"}}
|
||||
<div class="ui segment">
|
||||
<h5 class="ui header">AI Instructions</h5>
|
||||
<p class="tw-text-sm tw-mb-2" style="opacity: 0.7;">Additional context for AI when selecting this runner for jobs.</p>
|
||||
<div class="field">
|
||||
<textarea id="description" name="description" rows="3" placeholder="e.g., Use for heavy builds, has GPU, limited to 2 concurrent jobs...">{{.Runner.Description}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Capabilities -->
|
||||
<div class="column">
|
||||
{{if .Runner.CapabilitiesJSON}}
|
||||
<div class="ui segment runner-capabilities">
|
||||
<h5 class="ui header">{{ctx.Locale.Tr "actions.runners.capabilities"}}</h5>
|
||||
{{if .RunnerCapabilities}}
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-4">
|
||||
{{if .RunnerCapabilities.OS}}
|
||||
<div class="tw-inline-block tw-mr-4">
|
||||
<strong>{{ctx.Locale.Tr "actions.runners.capabilities.os"}}:</strong>
|
||||
<span class="ui label">{{.RunnerCapabilities.OS}}/{{.RunnerCapabilities.Arch}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .RunnerCapabilities.Docker}}
|
||||
<div class="tw-inline-block tw-mr-4">
|
||||
<strong>{{ctx.Locale.Tr "actions.runners.capabilities.docker"}}:</strong>
|
||||
<span class="ui green label">{{svg "octicon-check" 14}} {{ctx.Locale.Tr "actions.runners.capabilities.available"}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .RunnerCapabilities.Shell}}
|
||||
<div class="tw-inline-block tw-mr-4">
|
||||
<strong>{{ctx.Locale.Tr "actions.runners.capabilities.shells"}}:</strong>
|
||||
{{range .RunnerCapabilities.Shell}}
|
||||
<span class="ui label">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .RunnerCapabilities.OS}}
|
||||
<div class="field tw-mb-3">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.capabilities.os"}}</label>
|
||||
<span class="ui small blue label">{{.RunnerCapabilities.OS}}/{{.RunnerCapabilities.Arch}}</span>
|
||||
{{if and .RunnerCapabilities.Distro .RunnerCapabilities.Distro.PrettyName}}
|
||||
<span class="ui small label">{{.RunnerCapabilities.Distro.PrettyName}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .RunnerCapabilities.Tools}}
|
||||
<div class="tw-mt-2">
|
||||
<strong>{{ctx.Locale.Tr "actions.runners.capabilities.tools"}}:</strong>
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-2 tw-mt-1">
|
||||
{{range $tool, $versions := .RunnerCapabilities.Tools}}
|
||||
<span class="ui label">{{$tool}} {{range $versions}}{{.}} {{end}}</span>
|
||||
{{end}}
|
||||
|
||||
<div class="field tw-mb-3">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.capabilities.docker"}}</label>
|
||||
{{if .RunnerCapabilities.Docker}}
|
||||
<span class="ui small green label">{{svg "octicon-check" 14}} Available</span>
|
||||
{{else}}
|
||||
<span class="ui small orange label">{{svg "octicon-x" 14}} Not available</span>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .RunnerCapabilities.Shell}}
|
||||
<div class="field tw-mb-3">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.capabilities.shells"}}</label>
|
||||
<div>
|
||||
{{range .RunnerCapabilities.Shell}}
|
||||
<span class="ui small teal label tw-mr-1">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .RunnerCapabilities.Tools}}
|
||||
<div class="field tw-mb-3">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.capabilities.tools"}}</label>
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-1">
|
||||
{{range $tool, $versions := .RunnerCapabilities.Tools}}
|
||||
<span class="ui small purple label">{{$tool}} {{range $versions}}{{.}} {{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .RunnerCapabilities.Limitations}}
|
||||
<div class="tw-mt-2">
|
||||
<strong>{{ctx.Locale.Tr "actions.runners.capabilities.limitations"}}:</strong>
|
||||
<ul class="tw-mt-1 tw-ml-4">
|
||||
<div class="field tw-mb-3">
|
||||
<label>{{ctx.Locale.Tr "actions.runners.capabilities.limitations"}}</label>
|
||||
<ul class="tw-mt-1 tw-ml-4 tw-text-sm">
|
||||
{{range .RunnerCapabilities.Limitations}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
@@ -80,23 +209,39 @@
|
||||
<pre class="tw-text-sm"><code>{{.Runner.CapabilitiesJSON}}</code></pre>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="ui segment">
|
||||
<h5 class="ui header">{{ctx.Locale.Tr "actions.runners.capabilities"}}</h5>
|
||||
<p style="opacity: 0.7;">No capabilities reported</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons - Full Width -->
|
||||
<div class="tw-flex tw-gap-2 tw-flex-wrap tw-mt-4">
|
||||
<button class="ui primary button" form="runner-form" data-url="{{.Link}}">
|
||||
{{svg "octicon-check" 14}} Update Instructions
|
||||
</button>
|
||||
{{if .RunnerCapabilities}}
|
||||
<button class="ui teal button" type="button" onclick="document.getElementById('suggested-labels-form').submit()">
|
||||
{{svg "octicon-light-bulb" 14}} Use All Suggested Labels
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="ui secondary button" type="button" onclick="document.getElementById('bandwidth-form').submit()">
|
||||
{{svg "octicon-sync" 14}} Check Bandwidth
|
||||
</button>
|
||||
<button class="ui red button delete-button" data-url="{{.Link}}/delete" data-modal="#runner-delete-modal" type="button">
|
||||
{{svg "octicon-trash" 14}} Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">{{ctx.Locale.Tr "actions.runners.description"}}</label>
|
||||
<input id="description" name="description" value="{{.Runner.Description}}">
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button" data-url="{{.Link}}">{{ctx.Locale.Tr "actions.runners.update_runner"}}</button>
|
||||
<button class="ui red button delete-button" data-url="{{.Link}}/delete" data-modal="#runner-delete-modal">
|
||||
{{ctx.Locale.Tr "actions.runners.delete_runner"}}</button>
|
||||
</div>
|
||||
<!-- Hidden Forms -->
|
||||
<form id="bandwidth-form" method="post" action="{{.Link}}/bandwidth-test" style="display:none">
|
||||
{{.CsrfTokenHtml}}
|
||||
</form>
|
||||
<form id="suggested-labels-form" method="post" action="{{.Link}}/use-suggested-labels" style="display:none">
|
||||
{{.CsrfTokenHtml}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -148,3 +293,90 @@
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const statusUrl = '{{.Link}}/status';
|
||||
const pollInterval = 10000; // 10 seconds
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const gb = bytes / 1073741824;
|
||||
return gb.toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
fetch(statusUrl, {
|
||||
headers: {'Accept': 'application/json'}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update status tile - only change class and text, preserve icons
|
||||
const statusLabel = document.getElementById('status-label');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const statusSubtext = document.getElementById('status-subtext');
|
||||
|
||||
if (statusLabel) {
|
||||
statusLabel.className = 'ui ' + (data.is_online ? 'green' : 'red') + ' large label';
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.textContent = data.status;
|
||||
}
|
||||
if (statusSubtext) {
|
||||
if (data.is_online) {
|
||||
statusSubtext.textContent = 'Connected';
|
||||
} else if (data.last_online) {
|
||||
statusSubtext.textContent = 'Last seen ' + new Date(data.last_online).toLocaleString();
|
||||
} else {
|
||||
statusSubtext.textContent = 'Never connected';
|
||||
}
|
||||
}
|
||||
|
||||
// Update disk tile - only change class and text
|
||||
if (data.disk) {
|
||||
const diskLabel = document.getElementById('disk-label');
|
||||
const diskText = document.getElementById('disk-text');
|
||||
const diskSubtext = document.getElementById('disk-subtext');
|
||||
const usedPct = data.disk.used_percent;
|
||||
|
||||
if (diskLabel) {
|
||||
const color = usedPct >= 95 ? 'red' : (usedPct >= 85 ? 'yellow' : 'green');
|
||||
diskLabel.className = 'ui ' + color + ' large label';
|
||||
}
|
||||
if (diskText) {
|
||||
diskText.textContent = Math.round(usedPct) + '% used';
|
||||
}
|
||||
if (diskSubtext) {
|
||||
diskSubtext.textContent = formatBytes(data.disk.free_bytes) + ' free of ' + formatBytes(data.disk.total_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Update bandwidth tile - only change class and text
|
||||
if (data.bandwidth) {
|
||||
const bwLabel = document.getElementById('bw-label');
|
||||
const bwText = document.getElementById('bw-text');
|
||||
const bwSubtext = document.getElementById('bw-subtext');
|
||||
const mbps = data.bandwidth.download_mbps;
|
||||
|
||||
if (bwLabel) {
|
||||
const color = mbps >= 100 ? 'green' : (mbps >= 10 ? 'blue' : 'yellow');
|
||||
bwLabel.className = 'ui ' + color + ' large label';
|
||||
}
|
||||
if (bwText) {
|
||||
bwText.textContent = Math.round(mbps) + ' Mbps';
|
||||
}
|
||||
if (bwSubtext && data.bandwidth.latency_ms) {
|
||||
let text = Math.round(data.bandwidth.latency_ms) + ' ms latency';
|
||||
if (data.bandwidth.tested_at) {
|
||||
text += ' - tested ' + new Date(data.bandwidth.tested_at).toLocaleString();
|
||||
}
|
||||
bwSubtext.textContent = text;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.log('Status poll error:', err));
|
||||
}
|
||||
|
||||
// Start polling
|
||||
setInterval(updateStatus, pollInterval);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</button>
|
||||
<div class="menu">
|
||||
<div class="item">
|
||||
<a href="https://docs.gitea.com/usage/actions/act-runner">{{ctx.Locale.Tr "actions.runners.new_notice"}}</a>
|
||||
<a href="https://git.marketally.com/gitcaddy/act_runner/src/branch/main/HOWTOSTART.md">{{ctx.Locale.Tr "actions.runners.new_notice"}}</a>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="header">
|
||||
@@ -63,18 +63,18 @@
|
||||
<th>{{ctx.Locale.Tr "edit"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="runner-list-body">
|
||||
{{range .Runners}}
|
||||
<tr>
|
||||
<td><span class="ui label {{if .IsOnline}}green{{end}}">{{.StatusLocaleName ctx.Locale}}</span></td>
|
||||
<tr data-runner-id="{{.ID}}">
|
||||
<td><span id="status-{{.ID}}" class="ui label {{if .IsOnline}}green{{end}}">{{.StatusLocaleName ctx.Locale}}</span></td>
|
||||
<td>{{.ID}}</td>
|
||||
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>
|
||||
<td>{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}}</td>
|
||||
<td id="version-{{.ID}}">{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}}</td>
|
||||
<td><span data-tooltip-content="{{.BelongsToOwnerName}}">{{.BelongsToOwnerType.LocaleString ctx.Locale}}</span></td>
|
||||
<td>
|
||||
<span class="flex-text-inline">{{range .AgentLabels}}<span class="ui label">{{.}}</span>{{end}}</span>
|
||||
</td>
|
||||
<td>{{if .LastOnline}}{{DateUtils.TimeSince .LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</td>
|
||||
<td id="lastonline-{{.ID}}">{{if .LastOnline}}{{DateUtils.TimeSince .LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</td>
|
||||
<td>
|
||||
{{if .EditableInContext $.RunnerOwnerID $.RunnerRepoID}}
|
||||
<a href="{{$.Link}}/{{.ID}}">{{svg "octicon-pencil"}}</a>
|
||||
@@ -93,3 +93,46 @@
|
||||
{{template "base/paginate" .}}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const statusUrl = '{{$.Link}}/status';
|
||||
const pollInterval = 30000; // 30 seconds
|
||||
|
||||
function updateRunners() {
|
||||
fetch(statusUrl, {
|
||||
headers: {'Accept': 'application/json'}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(runners => {
|
||||
runners.forEach(runner => {
|
||||
// Update status
|
||||
const statusEl = document.getElementById('status-' + runner.id);
|
||||
if (statusEl) {
|
||||
statusEl.className = 'ui label ' + (runner.is_online ? 'green' : '');
|
||||
statusEl.textContent = runner.status;
|
||||
}
|
||||
|
||||
// Update version
|
||||
const versionEl = document.getElementById('version-' + runner.id);
|
||||
if (versionEl && runner.version) {
|
||||
versionEl.textContent = runner.version;
|
||||
}
|
||||
|
||||
// Update last online
|
||||
const lastOnlineEl = document.getElementById('lastonline-' + runner.id);
|
||||
if (lastOnlineEl && runner.last_online_relative) {
|
||||
lastOnlineEl.textContent = runner.last_online_relative;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(err => console.error('Failed to update runner status:', err));
|
||||
}
|
||||
|
||||
// Poll every 30 seconds
|
||||
setInterval(updateRunners, pollInterval);
|
||||
|
||||
// Also update immediately on page load after a short delay
|
||||
setTimeout(updateRunners, 2000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{{end}}
|
||||
<div class="tw-mt-8 tw-text-center">
|
||||
{{if or .SignedUser.IsAdmin .ShowFooterVersion}}<p>{{ctx.Locale.Tr "admin.config.app_ver"}}: {{AppVer}}</p>{{end}}
|
||||
{{if .SignedUser.IsAdmin}}<p>{{ctx.Locale.Tr "error.report_message" "https://github.com/go-gitea/gitea/issues"}}</p>{{end}}
|
||||
{{if .SignedUser.IsAdmin}}<p>{{ctx.Locale.Tr "error.report_message" "https://git.marketally.com/gitcaddy/gitea/issues"}}</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<overflow-menu class="ui secondary pointing tabular borderless menu">
|
||||
<div class="overflow-menu-items">
|
||||
{{if and .HasUserProfileReadme .ContextUser.IsIndividual}}
|
||||
{{if .ContextUser.IsIndividual}}
|
||||
<a class="{{if eq .TabName "overview"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=overview">
|
||||
{{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}}
|
||||
</a>
|
||||
|
||||
@@ -26,7 +26,87 @@
|
||||
{{else if eq .TabName "followers"}}
|
||||
{{template "repo/user_cards" .}}
|
||||
{{else if eq .TabName "overview"}}
|
||||
{{/* Activity Heatmap on Overview */}}
|
||||
{{if and .ContextUser.ShowHeatmapOnProfile .HeatmapData}}
|
||||
<div class="ui segment tw-mb-4">
|
||||
<h4 class="ui header tw-flex tw-items-center">
|
||||
{{svg "octicon-graph" 16}} {{ctx.Locale.Tr "user.activity_heatmap"}}
|
||||
</h4>
|
||||
{{template "user/heatmap" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Pinned Repositories Section */}}
|
||||
{{if or .UserPinnedRepos .IsContextUserProfile}}
|
||||
<div class="ui segment pinned-repos-section tw-mb-4">
|
||||
<h4 class="ui header tw-flex tw-items-center">
|
||||
{{svg "octicon-pin" 16}} {{ctx.Locale.Tr "user.pinned_repos"}}
|
||||
{{if .IsContextUserProfile}}
|
||||
<span class="tw-ml-auto text grey tw-text-sm">{{ctx.Locale.Tr "user.pinned_repos_hint"}}</span>
|
||||
{{end}}
|
||||
</h4>
|
||||
|
||||
{{if .UserPinnedRepos}}
|
||||
<div class="ui three stackable cards pinned-repos">
|
||||
{{range .UserPinnedRepos}}
|
||||
{{if .Repo}}
|
||||
<a class="ui card" href="{{.Repo.Link}}">
|
||||
<div class="content tw-text-center">
|
||||
{{if .Repo.Avatar}}
|
||||
<img class="tw-inline-block tw-rounded" style="max-width: 80px; max-height: 80px; object-fit: contain;" src="{{.Repo.RelAvatarLink ctx}}" alt="">
|
||||
{{else}}
|
||||
<div class="tw-inline-block tw-p-4">
|
||||
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 48}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 48}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 48}}{{else}}{{svg "octicon-repo" 48}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="header tw-mt-2">
|
||||
{{if and .Repo.Owner (ne .Repo.OwnerID $.ContextUser.ID)}}
|
||||
<span class="text grey">{{.Repo.Owner.Name}}/</span>
|
||||
{{end}}
|
||||
{{.Repo.Name}}
|
||||
</div>
|
||||
{{if .Repo.Description}}
|
||||
<div class="description text grey tw-text-sm tw-mt-1">{{.Repo.Description}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="extra content">
|
||||
{{if .Repo.PrimaryLanguage}}
|
||||
<span class="tw-mr-2">
|
||||
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
|
||||
{{.Repo.PrimaryLanguage.Language}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumStars}}
|
||||
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
|
||||
{{end}}
|
||||
{{if .Repo.NumForks}}
|
||||
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if .IsContextUserProfile}}
|
||||
<div class="ui placeholder segment tw-text-center">
|
||||
<div class="ui icon header">
|
||||
{{svg "octicon-pin" 32}}
|
||||
<div class="content">
|
||||
{{ctx.Locale.Tr "user.pinned_repos_empty_title"}}
|
||||
<div class="sub header">
|
||||
{{ctx.Locale.Tr "user.pinned_repos_empty_desc"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Profile README */}}
|
||||
{{if .ProfileReadmeContent}}
|
||||
<div id="readme_profile" class="render-content markup">{{.ProfileReadmeContent}}</div>
|
||||
{{end}}
|
||||
{{else if eq .TabName "organizations"}}
|
||||
{{template "repo/user_cards" .}}
|
||||
{{else}}
|
||||
|
||||
@@ -88,6 +88,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="ui checkbox" id="show-heatmap-on-profile">
|
||||
<label data-tooltip-content="{{ctx.Locale.Tr "settings.show_heatmap_on_profile_popup"}}"><strong>{{ctx.Locale.Tr "settings.show_heatmap_on_profile"}}</strong></label>
|
||||
<input name="show_heatmap_on_profile" type="checkbox" {{if .SignedUser.ShowHeatmapOnProfile}}checked{{end}}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -57,3 +57,23 @@
|
||||
then the layout from top to bottom is: size, filename, progress */
|
||||
top: 7em;
|
||||
}
|
||||
|
||||
/* Fix dark mode dropzone details */
|
||||
.dropzone .dz-preview .dz-details {
|
||||
background: var(--color-body) !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.dropzone .dz-preview .dz-details .dz-size,
|
||||
.dropzone .dz-preview .dz-details .dz-filename {
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.dropzone .dz-preview .dz-details .dz-filename span {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.dropzone .dz-preview .dz-success-mark,
|
||||
.dropzone .dz-preview .dz-error-mark {
|
||||
background: var(--color-body) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user