Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d593260e | ||
|
|
6f7e600645 | ||
|
|
f382591ef1 | ||
|
|
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 | |||
| d3bf936570 | |||
| 212117f077 | |||
| 18bb922839 | |||
| e475d98c88 | |||
| 016d209858 | |||
| 7a8740d85d | |||
| ab3cf76297 | |||
| e0ba7c9c00 | |||
| 71fbcb5251 | |||
| badc4e4be3 | |||
| 75ee700ff2 | |||
| ada0024b09 | |||
| aff5a11391 | |||
| ef63f23694 | |||
| 5d0a9f64e5 | |||
| fbd5da0acb |
@@ -13,82 +13,193 @@ on:
|
||||
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
GOPRIVATE: git.marketally.com
|
||||
GONOSUMDB: git.marketally.com
|
||||
GO_VERSION: "1.25"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
# Lint and test job
|
||||
lint-test:
|
||||
name: Lint and Test
|
||||
runs-on: ubuntu-latest
|
||||
# Lint job - must pass
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: linux-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for existing Go
|
||||
id: go-check
|
||||
run: |
|
||||
if command -v go &> /dev/null; then
|
||||
GO_VER=$(go version | grep -oP 'go\d+\.\d+' | head -1)
|
||||
echo "version=$GO_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Go: $(go version)"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.go-check.outputs.version == ''
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Check for existing Node.js
|
||||
id: node-check
|
||||
run: |
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_VER=$(node --version)
|
||||
echo "version=$NODE_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Node.js: $NODE_VER"
|
||||
fi
|
||||
cache: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.node-check.outputs.version == ''
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Check for existing pnpm
|
||||
id: pnpm-check
|
||||
run: |
|
||||
if command -v pnpm &> /dev/null; then
|
||||
PNPM_VER=$(pnpm --version)
|
||||
echo "version=$PNPM_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found pnpm: $PNPM_VER"
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.pnpm-check.outputs.version == ''
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: make deps-frontend deps-backend
|
||||
|
||||
- name: Run linters
|
||||
run: make lint-go lint-frontend
|
||||
- name: Run Go linter
|
||||
run: make lint-go
|
||||
|
||||
- name: Run frontend linter
|
||||
run: make lint-frontend
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
continue-on-error: true
|
||||
# Unit tests with SQLite (no external database needed)
|
||||
test-unit:
|
||||
name: Unit Tests
|
||||
runs-on: linux-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
# Skip tests that require external services (Redis, Elasticsearch, Meilisearch, Azure, SHA256 git)
|
||||
go test -tags="sqlite sqlite_unlock_notify" -race \
|
||||
-skip "TestRepoStatsIndex|TestRenderHelper|Sha256|SHA256|Redis|redis|Elasticsearch|Meilisearch|AzureBlob|TestLockAndDo|TestLocker|TestBaseRedis" \
|
||||
./modules/... \
|
||||
./services/...
|
||||
env:
|
||||
GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT: true
|
||||
|
||||
# Integration tests with PostgreSQL
|
||||
test-pgsql:
|
||||
name: Integration Tests (PostgreSQL)
|
||||
runs-on: linux-latest
|
||||
services:
|
||||
pgsql:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_DB: testgitea
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: make deps-frontend deps-backend
|
||||
|
||||
- name: Build frontend
|
||||
run: make frontend
|
||||
|
||||
- name: Generate bindata
|
||||
run: make generate
|
||||
env:
|
||||
TAGS: bindata
|
||||
|
||||
- name: Build test binary
|
||||
run: |
|
||||
go build -tags="bindata sqlite sqlite_unlock_notify" -o gitea .
|
||||
|
||||
- name: Generate test config
|
||||
run: |
|
||||
make generate-ini-pgsql
|
||||
env:
|
||||
TEST_PGSQL_HOST: localhost:5432
|
||||
TEST_PGSQL_DBNAME: testgitea
|
||||
TEST_PGSQL_USERNAME: postgres
|
||||
TEST_PGSQL_PASSWORD: postgres
|
||||
TEST_PGSQL_SCHEMA: gtestschema
|
||||
|
||||
- name: Run PostgreSQL integration tests
|
||||
run: |
|
||||
make test-pgsql
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_PGSQL_HOST: localhost:5432
|
||||
TEST_PGSQL_DBNAME: testgitea
|
||||
TEST_PGSQL_USERNAME: postgres
|
||||
TEST_PGSQL_PASSWORD: postgres
|
||||
TEST_PGSQL_SCHEMA: gtestschema
|
||||
GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT: true
|
||||
|
||||
# Create release job - runs first to create the release before build jobs upload
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: linux-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
outputs:
|
||||
release_id: ${{ steps.create.outputs.release_id }}
|
||||
steps:
|
||||
- name: Create or get release
|
||||
id: create
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
echo "Creating/getting release for tag: $TAG"
|
||||
|
||||
# Try to get existing release first
|
||||
EXISTING=$(curl -sf \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
"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)
|
||||
echo "Found existing release: $RELEASE_ID"
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create new release
|
||||
echo "Creating new release..."
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-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}' \
|
||||
"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)
|
||||
echo "Created release: $RELEASE_ID"
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ERROR: Failed to create release: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build job for binaries
|
||||
build:
|
||||
name: Build Binaries
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-test
|
||||
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:
|
||||
matrix:
|
||||
include:
|
||||
@@ -108,48 +219,18 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for existing Go
|
||||
id: go-check
|
||||
run: |
|
||||
if command -v go &> /dev/null; then
|
||||
GO_VER=$(go version | grep -oP 'go\d+\.\d+' | head -1)
|
||||
echo "version=$GO_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Go: $(go version)"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.go-check.outputs.version == ''
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Check for existing Node.js
|
||||
id: node-check
|
||||
run: |
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_VER=$(node --version)
|
||||
echo "version=$NODE_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Node.js: $NODE_VER"
|
||||
fi
|
||||
cache: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.node-check.outputs.version == ''
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Check for existing pnpm
|
||||
id: pnpm-check
|
||||
run: |
|
||||
if command -v pnpm &> /dev/null; then
|
||||
PNPM_VER=$(pnpm --version)
|
||||
echo "version=$PNPM_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found pnpm: $PNPM_VER"
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.pnpm-check.outputs.version == ''
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -186,47 +267,42 @@ jobs:
|
||||
|
||||
- name: Upload to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
run: |
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
|
||||
echo "Uploading binaries for $VERSION"
|
||||
set -e
|
||||
echo "Uploading binaries to release ID: $RELEASE_ID"
|
||||
|
||||
# Get or create release
|
||||
TAG="${{ github.ref_name }}"
|
||||
EXISTING=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$TAG")
|
||||
|
||||
if echo "$EXISTING" | grep -q '"id":[0-9]'; then
|
||||
RELEASE_ID=$(echo "$EXISTING" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
echo "Found existing release: $RELEASE_ID"
|
||||
else
|
||||
echo "Creating release..."
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name":"'"$TAG"'","name":"Gitea '"$TAG"'","body":"Official release.","draft":false,"prerelease":false}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
echo "Created release: $RELEASE_ID"
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: No release ID provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload files
|
||||
# Upload files with retry
|
||||
for file in dist/*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
# Delete existing asset if present
|
||||
ASSETS=$(curl -s -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID")
|
||||
ASSET_ID=$(echo "$ASSETS" | grep -o "\"id\":[0-9]*,\"name\":\"$filename\"" | grep -o '"id":[0-9]*' | cut -d: -f2)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo "Deleting existing $filename"
|
||||
curl -s -X DELETE -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets/$ASSET_ID"
|
||||
fi
|
||||
echo "Uploading $filename..."
|
||||
curl -s -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"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
UPLOAD_RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
-F "attachment=@$file" \
|
||||
"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"
|
||||
break
|
||||
else
|
||||
if [ $attempt -lt 3 ]; then
|
||||
echo "Attempt $attempt failed, retrying in 5s..."
|
||||
sleep 5
|
||||
else
|
||||
echo "✗ Failed to upload $filename after 3 attempts: $UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "All uploads complete!"
|
||||
|
||||
@@ -8,60 +8,32 @@ on:
|
||||
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
GOPRIVATE: git.marketally.com
|
||||
GONOSUMDB: git.marketally.com
|
||||
GO_VERSION: "1.25"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
# Quick checks for PRs
|
||||
checks:
|
||||
name: Code Quality Checks
|
||||
# Quick lint checks - must pass
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for existing Go
|
||||
id: go-check
|
||||
run: |
|
||||
if command -v go &> /dev/null; then
|
||||
GO_VER=$(go version | grep -oP 'go\d+\.\d+' | head -1)
|
||||
echo "version=$GO_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Go: $(go version)"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.go-check.outputs.version == ''
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Check for existing Node.js
|
||||
id: node-check
|
||||
run: |
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_VER=$(node --version)
|
||||
echo "version=$NODE_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Node.js: $NODE_VER"
|
||||
fi
|
||||
cache: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.node-check.outputs.version == ''
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Check for existing pnpm
|
||||
id: pnpm-check
|
||||
run: |
|
||||
if command -v pnpm &> /dev/null; then
|
||||
PNPM_VER=$(pnpm --version)
|
||||
echo "version=$PNPM_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found pnpm: $PNPM_VER"
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.pnpm-check.outputs.version == ''
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install Go dependencies
|
||||
@@ -78,44 +50,39 @@ jobs:
|
||||
- name: Go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Go linter
|
||||
run: |
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 run
|
||||
|
||||
- name: Check for build errors
|
||||
run: go build -v ./...
|
||||
|
||||
# Unit tests
|
||||
unit-tests:
|
||||
test-unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for existing Go
|
||||
id: go-check
|
||||
run: |
|
||||
if command -v go &> /dev/null; then
|
||||
GO_VER=$(go version | grep -oP 'go\d+\.\d+' | head -1)
|
||||
echo "version=$GO_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Go: $(go version)"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.go-check.outputs.version == ''
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
go test -v -race -coverprofile=coverage.out ./...
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage.out
|
||||
retention-days: 7
|
||||
# Skip tests that require external services (Redis, Elasticsearch, Meilisearch, Azure, SHA256 git)
|
||||
go test -tags="sqlite sqlite_unlock_notify" -race \
|
||||
-skip "TestRepoStatsIndex|TestRenderHelper|Sha256|SHA256|Redis|redis|Elasticsearch|Meilisearch|AzureBlob|TestLockAndDo|TestLocker|TestBaseRedis" \
|
||||
./modules/... \
|
||||
./services/...
|
||||
env:
|
||||
GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT: true
|
||||
|
||||
# Frontend checks
|
||||
frontend:
|
||||
@@ -125,32 +92,12 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for existing Node.js
|
||||
id: node-check
|
||||
run: |
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_VER=$(node --version)
|
||||
echo "version=$NODE_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found Node.js: $NODE_VER"
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.node-check.outputs.version == ''
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Check for existing pnpm
|
||||
id: pnpm-check
|
||||
run: |
|
||||
if command -v pnpm &> /dev/null; then
|
||||
PNPM_VER=$(pnpm --version)
|
||||
echo "version=$PNPM_VER" >> $GITHUB_OUTPUT
|
||||
echo "Found pnpm: $PNPM_VER"
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.pnpm-check.outputs.version == ''
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
571
README.md
571
README.md
@@ -1,213 +1,416 @@
|
||||
# Gitea
|
||||
# GitCaddy
|
||||
|
||||
[](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly")
|
||||
[](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
|
||||
[](https://goreportcard.com/report/code.gitea.io/gitea "Go Report Card")
|
||||
[](https://pkg.go.dev/code.gitea.io/gitea "GoDoc")
|
||||
[](https://github.com/go-gitea/gitea/releases/latest "GitHub release")
|
||||
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
||||
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
||||
[](https://opensource.org/licenses/MIT "License: MIT")
|
||||
[](https://gitpod.io/#https://github.com/go-gitea/gitea)
|
||||
[](https://translate.gitea.com "Crowdin")
|
||||
The AI-native Git platform. Self-hosted, fast, and designed for the age of AI-assisted development.
|
||||
|
||||
[繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md)
|
||||
## What is GitCaddy?
|
||||
|
||||
## Purpose
|
||||
GitCaddy transforms Git hosting into an AI-ready platform. While traditional Git servers treat AI tools as an afterthought, GitCaddy is built from the ground up with structured APIs, capability discovery, and intelligent context that AI assistants need to write correct code, generate valid CI/CD workflows, and understand your projects deeply.
|
||||
|
||||
The goal of this project is to make the easiest, fastest, and most
|
||||
painless way of setting up a self-hosted Git service.
|
||||
**Key differentiators:**
|
||||
|
||||
As Gitea is written in Go, it works across **all** the platforms and
|
||||
architectures that are supported by Go, including Linux, macOS, and
|
||||
Windows on x86, amd64, ARM and PowerPC architectures.
|
||||
This project has been
|
||||
[forked](https://blog.gitea.com/welcome-to-gitea/) from
|
||||
[Gogs](https://gogs.io) since November of 2016, but a lot has changed.
|
||||
- **V2 API** - Modern, AI-optimized endpoints with batch operations, streaming, and structured errors
|
||||
- **Runner Capability Discovery** - AI tools query runner capabilities before generating workflows
|
||||
- **Action Compatibility Database** - Curated compatibility matrix prevents workflow errors
|
||||
- **AI Context APIs** - Rich, structured repository and issue intelligence
|
||||
- **Workflow Validation** - Pre-flight checks for CI/CD workflows before commit
|
||||
|
||||
For online demonstrations, you can visit [demo.gitea.com](https://demo.gitea.com).
|
||||
## Features
|
||||
|
||||
For accessing free Gitea service (with a limited number of repositories), you can visit [gitea.com](https://gitea.com/user/login).
|
||||
### V2 API - Modern, AI-Optimized Interface
|
||||
|
||||
To quickly deploy your own dedicated Gitea instance on Gitea Cloud, you can start a free trial at [cloud.gitea.com](https://cloud.gitea.com).
|
||||
A complete API redesign focused on AI tool consumption:
|
||||
|
||||
## Documentation
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Batch Operations** | Fetch up to 100 files in a single request |
|
||||
| **Streaming** | NDJSON streams for progressive processing |
|
||||
| **Idempotency** | Built-in support for safe request retries |
|
||||
| **Structured Errors** | Machine-readable error codes, not just HTTP status |
|
||||
| **Request Tracking** | Every request gets a unique ID for debugging |
|
||||
| **Health Checks** | Kubernetes-compatible liveness/readiness probes |
|
||||
| **Operation Progress** | Server-Sent Events for long-running operations |
|
||||
|
||||
You can find comprehensive documentation on our official [documentation website](https://docs.gitea.com/).
|
||||
```
|
||||
GET /api/v2/batch/files # Bulk file retrieval
|
||||
POST /api/v2/stream/files # NDJSON streaming
|
||||
GET /api/v2/operations/{id} # Operation status
|
||||
GET /api/v2/health/ready # Readiness probe
|
||||
```
|
||||
|
||||
It includes installation, administration, usage, development, contributing guides, and more to help you get started and explore all features effectively.
|
||||
### AI Context APIs - Repository Intelligence
|
||||
|
||||
If you have any suggestions or would like to contribute to it, you can visit the [documentation repository](https://gitea.com/gitea/docs)
|
||||
Purpose-built endpoints that give AI tools the context they need:
|
||||
|
||||
**Repository Summary** (`/api/v2/ai/repo/summary`)
|
||||
```json
|
||||
{
|
||||
"name": "my-project",
|
||||
"primary_language": "Go",
|
||||
"project_type": "application",
|
||||
"build_system": "go modules",
|
||||
"test_framework": "go test",
|
||||
"suggested_entry_points": ["cmd/main.go", "internal/app/"],
|
||||
"config_files": ["go.mod", "Makefile", ".gitea/workflows/"],
|
||||
"language_stats": {"Go": 45000, "YAML": 2000}
|
||||
}
|
||||
```
|
||||
|
||||
**Repository Navigation** (`/api/v2/ai/repo/navigation`)
|
||||
- Directory tree with depth control
|
||||
- Important paths ranked by priority (entry points, tests, docs)
|
||||
- File type distribution
|
||||
|
||||
**Issue Context** (`/api/v2/ai/issue/context`)
|
||||
- Issue details with all comments
|
||||
- Related issues and code references
|
||||
- AI hints: category (bug/feature), complexity estimation, suggested files
|
||||
|
||||
### Runner Capability Discovery
|
||||
|
||||
Runners report their capabilities. AI tools query before generating workflows.
|
||||
|
||||
**Endpoint:** `GET /api/v2/repos/{owner}/{repo}/actions/runners/capabilities`
|
||||
|
||||
```json
|
||||
{
|
||||
"runners": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ubuntu-runner",
|
||||
"status": "online",
|
||||
"labels": ["ubuntu-latest", "docker"],
|
||||
"capabilities": {
|
||||
"os": "linux",
|
||||
"arch": "amd64",
|
||||
"docker": true,
|
||||
"docker_compose": true,
|
||||
"shell": ["bash", "sh"],
|
||||
"tools": {
|
||||
"node": ["18.19.0", "20.10.0"],
|
||||
"go": ["1.21.5", "1.22.0"],
|
||||
"python": ["3.11.6", "3.12.0"]
|
||||
},
|
||||
"features": {
|
||||
"cache": true,
|
||||
"services": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"platform": {
|
||||
"type": "gitea",
|
||||
"version": "1.26.0",
|
||||
"supported_actions": {
|
||||
"actions/checkout": {"versions": ["v3", "v4"]},
|
||||
"actions/setup-node": {"versions": ["v3", "v4"]},
|
||||
"actions/upload-artifact": {"versions": ["v3"], "notes": "v4 not supported"}
|
||||
},
|
||||
"unsupported_features": [
|
||||
"GitHub-hosted runners",
|
||||
"OIDC token authentication"
|
||||
]
|
||||
},
|
||||
"workflow_hints": {
|
||||
"preferred_checkout": "actions/checkout@v4",
|
||||
"artifact_upload_alternative": "Use Gitea API for artifacts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Workflow Validation
|
||||
|
||||
Validate workflows before committing. Catch incompatibilities early.
|
||||
|
||||
**Endpoint:** `POST /api/v2/repos/{owner}/{repo}/actions/workflows/validate`
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"content": "name: Build\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/upload-artifact@v4"
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"valid": false,
|
||||
"warnings": [
|
||||
{
|
||||
"line": 8,
|
||||
"action": "actions/upload-artifact@v4",
|
||||
"severity": "error",
|
||||
"message": "actions/upload-artifact@v4 is not supported",
|
||||
"suggestion": "Use actions/upload-artifact@v3"
|
||||
}
|
||||
],
|
||||
"runner_match": [
|
||||
{
|
||||
"job": "build",
|
||||
"runs_on": ["ubuntu-latest"],
|
||||
"matched_runners": ["ubuntu-runner-1"],
|
||||
"capabilities_met": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Action Compatibility Database
|
||||
|
||||
Built-in knowledge of GitHub Action compatibility:
|
||||
|
||||
| Action | Compatible Versions | Notes |
|
||||
|--------|-------------------|-------|
|
||||
| `actions/checkout` | v2, v3, v4 | Fully compatible |
|
||||
| `actions/setup-node` | v2, v3, v4 | Fully compatible |
|
||||
| `actions/setup-go` | v3, v4, v5 | Fully compatible |
|
||||
| `actions/setup-python` | v4, v5 | Fully compatible |
|
||||
| `actions/cache` | v3, v4 | Fully compatible |
|
||||
| `actions/upload-artifact` | v2, v3 | v4 not supported |
|
||||
| `actions/download-artifact` | v2, v3 | v4 not supported |
|
||||
|
||||
### Release Archive
|
||||
|
||||
Archive old releases without deleting them:
|
||||
|
||||
- Toggle archived status via UI or API
|
||||
- Filter releases by archived state
|
||||
- Archived releases hidden by default, toggle to show
|
||||
- Preserves release history for compliance
|
||||
|
||||
```
|
||||
POST /api/v1/repos/{owner}/{repo}/releases/{id}/archive
|
||||
DELETE /api/v1/repos/{owner}/{repo}/releases/{id}/archive
|
||||
GET /api/v1/repos/{owner}/{repo}/releases?archived=false
|
||||
```
|
||||
|
||||
### Public Landing Pages & Releases for Private Repos
|
||||
|
||||
Private repositories can expose a public landing page and/or public releases. Perfect for:
|
||||
- Commercial software with private source but public downloads
|
||||
- Open-core projects with public documentation
|
||||
- Electron/desktop apps needing public update endpoints
|
||||
|
||||
Configure in `.gitea/landing.yaml`:
|
||||
```yaml
|
||||
enabled: true
|
||||
public_landing: true # Allow unauthenticated access to landing page
|
||||
|
||||
hero:
|
||||
title: "My App"
|
||||
tagline: "The best app ever"
|
||||
|
||||
advanced:
|
||||
public_releases: true # Allow unauthenticated access to releases
|
||||
```
|
||||
|
||||
**API Endpoints (no auth required when enabled):**
|
||||
```
|
||||
GET /api/v2/repos/{owner}/{repo}/pages/config # Landing page config
|
||||
GET /api/v2/repos/{owner}/{repo}/pages/content # Landing page content
|
||||
GET /api/v2/repos/{owner}/{repo}/releases # List releases
|
||||
GET /api/v2/repos/{owner}/{repo}/releases/latest # Latest release
|
||||
```
|
||||
|
||||
### App Update API (Electron/Squirrel Compatible)
|
||||
|
||||
Purpose-built endpoint for desktop app auto-updates. Returns Squirrel-compatible JSON format.
|
||||
|
||||
**Endpoint:** `GET /api/v2/repos/{owner}/{repo}/releases/update`
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `version` | Current app version (semver) | Required |
|
||||
| `platform` | `darwin`, `windows`, `linux` | Runtime OS |
|
||||
| `arch` | `x64`, `arm64` | Runtime arch |
|
||||
| `channel` | `stable`, `beta`, `alpha` | `stable` |
|
||||
|
||||
**Response (200 OK - update available):**
|
||||
```json
|
||||
{
|
||||
"url": "https://git.example.com/owner/repo/releases/download/v1.2.0/App-darwin-arm64.zip",
|
||||
"name": "v1.2.0",
|
||||
"notes": "Release notes in markdown...",
|
||||
"pub_date": "2026-01-10T12:00:00Z",
|
||||
"platform": {
|
||||
"size": 45000000,
|
||||
"releases_url": "https://...", // Windows RELEASES file
|
||||
"nupkg_url": "https://..." // Windows nupkg
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (204 No Content):** No update available
|
||||
|
||||
**Electron Integration:**
|
||||
```typescript
|
||||
// In your Electron app
|
||||
import { autoUpdater } from 'electron'
|
||||
|
||||
const version = app.getVersion()
|
||||
const platform = process.platform
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
|
||||
autoUpdater.setFeedURL({
|
||||
url: `https://git.example.com/api/v2/repos/owner/repo/releases/update?version=${version}&platform=${platform}&arch=${arch}`
|
||||
})
|
||||
|
||||
autoUpdater.checkForUpdates()
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### From Binary
|
||||
|
||||
Download from [Releases](https://git.marketally.com/gitcaddy/gitea/releases):
|
||||
|
||||
```bash
|
||||
# Linux (amd64)
|
||||
curl -L -o gitcaddy https://git.marketally.com/gitcaddy/gitea/releases/latest/download/gitea-linux-amd64
|
||||
chmod +x gitcaddy
|
||||
./gitcaddy web
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://git.marketally.com/gitcaddy/gitea.git
|
||||
cd gitea
|
||||
TAGS="bindata sqlite sqlite_unlock_notify" make build
|
||||
./gitea web
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gitcaddy \
|
||||
-p 3000:3000 \
|
||||
-v ./data:/data \
|
||||
gitcaddy/gitea:latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
GitCaddy uses the same configuration as Gitea. Key settings for AI features:
|
||||
|
||||
```ini
|
||||
[server]
|
||||
ROOT_URL = https://your-instance.com/
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
|
||||
[api]
|
||||
; Enable V2 API (enabled by default)
|
||||
ENABLE_V2_API = true
|
||||
|
||||
; Max files in batch request
|
||||
MAX_BATCH_SIZE = 100
|
||||
|
||||
; Enable AI context endpoints
|
||||
ENABLE_AI_CONTEXT = true
|
||||
```
|
||||
|
||||
## GitCaddy Runner
|
||||
|
||||
For full capability reporting, use the [GitCaddy act_runner](https://git.marketally.com/gitcaddy/act_runner):
|
||||
|
||||
```bash
|
||||
# Download
|
||||
curl -L -o act_runner https://git.marketally.com/gitcaddy/act_runner/releases/latest/download/act_runner-linux-amd64
|
||||
chmod +x act_runner
|
||||
|
||||
# Register
|
||||
./act_runner register \
|
||||
--instance https://your-instance.com \
|
||||
--token YOUR_TOKEN \
|
||||
--name my-runner
|
||||
|
||||
# Run (automatically detects and reports capabilities)
|
||||
./act_runner daemon
|
||||
```
|
||||
|
||||
The runner automatically detects:
|
||||
- OS and architecture
|
||||
- Docker/Podman availability
|
||||
- Installed tools (Node.js, Go, Python, Java, .NET, Rust)
|
||||
- Available shells
|
||||
- Docker Compose support
|
||||
|
||||
## API Documentation
|
||||
|
||||
Interactive API documentation available at:
|
||||
- `/api/v2/docs` - Scalar API explorer
|
||||
- `/api/v2/swagger.json` - OpenAPI specification
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
GitCaddy
|
||||
|
|
||||
+------------------------------+------------------------------+
|
||||
| | |
|
||||
V2 API Layer Actions Engine Web Interface
|
||||
| | |
|
||||
+----+----+ +----+----+ +----+----+
|
||||
| | | | | |
|
||||
Batch Streaming Runners Workflows Repos Releases
|
||||
Files (NDJSON) Capability Validation (Archive)
|
||||
| | Discovery |
|
||||
| | | |
|
||||
+----+----+--------------------+---------+
|
||||
|
|
||||
AI Context APIs
|
||||
|
|
||||
+----+----+----+
|
||||
| | | |
|
||||
Repo Issue Nav Summary
|
||||
```
|
||||
|
||||
## Related Projects
|
||||
|
||||
| Project | Description |
|
||||
|---------|-------------|
|
||||
| [gitcaddy/act_runner](https://git.marketally.com/gitcaddy/act_runner) | Runner with capability detection |
|
||||
| [gitcaddy/actions-proto-go](https://git.marketally.com/gitcaddy/actions-proto-go) | Protocol definitions |
|
||||
|
||||
## Building
|
||||
|
||||
From the root of the source tree, run:
|
||||
Requirements:
|
||||
- Go 1.24+ (see `go.mod`)
|
||||
- Node.js 22.6+ (for frontend)
|
||||
- Make
|
||||
|
||||
TAGS="bindata" make build
|
||||
```bash
|
||||
# Full build
|
||||
TAGS="bindata sqlite sqlite_unlock_notify" make build
|
||||
|
||||
or if SQLite support is required:
|
||||
# Backend only
|
||||
make backend
|
||||
|
||||
TAGS="bindata sqlite sqlite_unlock_notify" make build
|
||||
# Frontend only
|
||||
make frontend
|
||||
|
||||
The `build` target is split into two sub-targets:
|
||||
|
||||
- `make backend` which requires [Go Stable](https://go.dev/dl/), the required version is defined in [go.mod](/go.mod).
|
||||
- `make frontend` which requires [Node.js LTS](https://nodejs.org/en/download/) or greater and [pnpm](https://pnpm.io/installation).
|
||||
|
||||
Internet connectivity is required to download the go and npm modules. When building from the official source tarballs which include pre-built frontend files, the `frontend` target will not be triggered, making it possible to build without Node.js.
|
||||
|
||||
More info: https://docs.gitea.com/installation/install-from-source
|
||||
|
||||
## Using
|
||||
|
||||
After building, a binary file named `gitea` will be generated in the root of the source tree by default. To run it, use:
|
||||
|
||||
./gitea web
|
||||
|
||||
> [!NOTE]
|
||||
> If you're interested in using our APIs, we have experimental support with [documentation](https://docs.gitea.com/api).
|
||||
# Run tests
|
||||
make test
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Expected workflow is: Fork -> Patch -> Push -> Pull Request
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 1. **YOU MUST READ THE [CONTRIBUTORS GUIDE](CONTRIBUTING.md) BEFORE STARTING TO WORK ON A PULL REQUEST.**
|
||||
> 2. If you have found a vulnerability in the project, please write privately to **security@gitea.io**. Thanks!
|
||||
|
||||
## Translating
|
||||
|
||||
[](https://translate.gitea.com)
|
||||
|
||||
Translations are done through [Crowdin](https://translate.gitea.com). If you want to translate to a new language, ask one of the managers in the Crowdin project to add a new language there.
|
||||
|
||||
You can also just create an issue for adding a language or ask on Discord on the #translation channel. If you need context or find some translation issues, you can leave a comment on the string or ask on Discord. For general translation questions there is a section in the docs. Currently a bit empty, but we hope to fill it as questions pop up.
|
||||
|
||||
Get more information from [documentation](https://docs.gitea.com/contributing/localization).
|
||||
|
||||
## Official and Third-Party Projects
|
||||
|
||||
We provide an official [go-sdk](https://gitea.com/gitea/go-sdk), a CLI tool called [tea](https://gitea.com/gitea/tea) and an [action runner](https://gitea.com/gitea/act_runner) for Gitea Action.
|
||||
|
||||
We maintain a list of Gitea-related projects at [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea), where you can discover more third-party projects, including SDKs, plugins, themes, and more.
|
||||
|
||||
## Communication
|
||||
|
||||
[](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
|
||||
|
||||
If you have questions that are not covered by the [documentation](https://docs.gitea.com/), you can get in contact with us on our [Discord server](https://discord.gg/Gitea) or create a post in the [discourse forum](https://forum.gitea.com/).
|
||||
|
||||
## Authors
|
||||
|
||||
- [Maintainers](https://github.com/orgs/go-gitea/people)
|
||||
- [Contributors](https://github.com/go-gitea/gitea/graphs/contributors)
|
||||
- [Translators](options/locale/TRANSLATORS)
|
||||
|
||||
## Backers
|
||||
|
||||
Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/gitea#backer)]
|
||||
|
||||
<a href="https://opencollective.com/gitea#backers" target="_blank"><img src="https://opencollective.com/gitea/backers.svg?width=890"></a>
|
||||
|
||||
## Sponsors
|
||||
|
||||
Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/gitea#sponsor)]
|
||||
|
||||
<a href="https://opencollective.com/gitea/sponsor/0/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/0/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/1/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/1/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/2/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/2/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/3/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/3/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/4/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/4/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/5/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/5/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/6/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/6/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/7/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/7/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/8/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/8/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/9/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/9/avatar.svg"></a>
|
||||
|
||||
## FAQ
|
||||
|
||||
**How do you pronounce Gitea?**
|
||||
|
||||
Gitea is pronounced [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY) as in "gi-tea" with a hard g.
|
||||
|
||||
**Why is this not hosted on a Gitea instance?**
|
||||
|
||||
We're [working on it](https://github.com/go-gitea/gitea/issues/1029).
|
||||
|
||||
**Where can I find the security patches?**
|
||||
|
||||
In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches.
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run tests: `make test`
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
See the [LICENSE](https://github.com/go-gitea/gitea/blob/main/LICENSE) file
|
||||
for the full license text.
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Further information
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Looking for an overview of the interface? Check it out!</summary>
|
||||
## Acknowledgments
|
||||
|
||||
### Login/Register Page
|
||||
GitCaddy is a fork of [Gitea](https://gitea.io), the open-source self-hosted Git service. We thank the Gitea team and all contributors for building the foundation that makes GitCaddy possible.
|
||||
|
||||

|
||||

|
||||
|
||||
### User Dashboard
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### User Profile
|
||||
|
||||

|
||||
|
||||
### Explore
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
### Repository
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
#### Repository Issue
|
||||
|
||||

|
||||

|
||||
|
||||
#### Repository Pull Requests
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
#### Repository Actions
|
||||
|
||||

|
||||

|
||||
|
||||
#### Repository Activity
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### Organization
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
- [Gitea Project](https://gitea.io)
|
||||
- [Gitea Contributors](https://github.com/go-gitea/gitea/graphs/contributors)
|
||||
|
||||
BIN
assets/256x256.png
Normal file
BIN
assets/256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/gitcaddy-icon.png
Normal file
BIN
assets/gitcaddy-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// gitea-cli is a command-line tool for interacting with Gitea instances.
|
||||
|
||||
154
cmd/mcp-server/main.go
Normal file
154
cmd/mcp-server/main.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 MarketAlly. 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...)
|
||||
}
|
||||
}
|
||||
6
go.mod
6
go.mod
@@ -10,7 +10,7 @@ toolchain go1.25.5
|
||||
godebug x509negativeserial=1
|
||||
|
||||
require (
|
||||
code.gitea.io/actions-proto-go v0.4.1
|
||||
code.gitea.io/actions-proto-go v0.5.0
|
||||
code.gitea.io/sdk/gitea v0.22.0
|
||||
codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570
|
||||
connectrpc.com/connect v1.18.1
|
||||
@@ -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
|
||||
@@ -312,6 +313,9 @@ 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.7
|
||||
|
||||
exclude github.com/gofrs/uuid v3.2.0+incompatible
|
||||
|
||||
exclude github.com/gofrs/uuid v4.0.0+incompatible
|
||||
|
||||
6
go.sum
6
go.sum
@@ -16,8 +16,6 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
|
||||
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
|
||||
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
|
||||
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
|
||||
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
|
||||
@@ -31,6 +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.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,6 +78,8 @@ 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/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=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
@@ -62,6 +62,10 @@ type ActionRunner struct {
|
||||
AgentLabels []string `xorm:"TEXT"`
|
||||
// Store if this is a runner that only ever get one single job assigned
|
||||
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"`
|
||||
@@ -394,3 +398,16 @@ func UpdateWrongRepoLevelRunners(ctx context.Context) (int64, error) {
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// GetRunnersOfRepo returns all runners available for a repository
|
||||
// This includes repo-level, owner-level, and global runners
|
||||
func GetRunnersOfRepo(ctx context.Context, repoID int64) ([]*ActionRunner, error) {
|
||||
opts := FindRunnerOptions{
|
||||
RepoID: repoID,
|
||||
WithAvailable: true,
|
||||
}
|
||||
|
||||
var runners []*ActionRunner
|
||||
err := db.GetEngine(ctx).Where(opts.ToConds()).OrderBy(opts.ToOrders()).Find(&runners)
|
||||
return runners, err
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package migrations
|
||||
@@ -403,6 +403,8 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(326, "Add organization pinned repos tables", v1_26.AddOrgPinnedTables),
|
||||
newMigration(327, "Add Gitea Pages tables", v1_26.AddGiteaPagesTables),
|
||||
newMigration(328, "Add wiki index table for search", v1_26.AddWikiIndexTable),
|
||||
newMigration(329, "Add release archive columns", v1_26.AddReleaseArchiveColumns),
|
||||
newMigration(330, "Add runner capabilities column", v1_26.AddRunnerCapabilitiesColumn),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
20
models/migrations/v1_26/v329.go
Normal file
20
models/migrations/v1_26/v329.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddReleaseArchiveColumns adds IsArchived and ArchivedUnix columns to the release table
|
||||
func AddReleaseArchiveColumns(x *xorm.Engine) error {
|
||||
type Release struct {
|
||||
IsArchived bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ArchivedUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
}
|
||||
|
||||
return x.Sync(new(Release))
|
||||
}
|
||||
17
models/migrations/v1_26/v330.go
Normal file
17
models/migrations/v1_26/v330.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddRunnerCapabilitiesColumn adds CapabilitiesJSON column to action_runner table
|
||||
func AddRunnerCapabilitiesColumn(x *xorm.Engine) error {
|
||||
type ActionRunner struct {
|
||||
CapabilitiesJSON string `xorm:"TEXT"`
|
||||
}
|
||||
|
||||
return x.Sync(new(ActionRunner))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package organization
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package organization
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
@@ -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))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
@@ -85,6 +85,8 @@ type Release struct {
|
||||
IsDraft bool `xorm:"NOT NULL DEFAULT false"`
|
||||
IsPrerelease bool `xorm:"NOT NULL DEFAULT false"`
|
||||
IsTag bool `xorm:"NOT NULL DEFAULT false"` // will be true only if the record is a tag and has no related releases
|
||||
IsArchived bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ArchivedUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
Attachments []*Attachment `xorm:"-"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
}
|
||||
@@ -233,14 +235,16 @@ func GetReleaseForRepoByID(ctx context.Context, repoID, id int64) (*Release, err
|
||||
// FindReleasesOptions describes the conditions to Find releases
|
||||
type FindReleasesOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
IncludeDrafts bool
|
||||
IncludeTags bool
|
||||
IsPreRelease optional.Option[bool]
|
||||
IsDraft optional.Option[bool]
|
||||
TagNames []string
|
||||
HasSha1 optional.Option[bool] // useful to find draft releases which are created with existing tags
|
||||
NamePattern optional.Option[string]
|
||||
RepoID int64
|
||||
IncludeDrafts bool
|
||||
IncludeTags bool
|
||||
IncludeArchived bool
|
||||
IsPreRelease optional.Option[bool]
|
||||
IsDraft optional.Option[bool]
|
||||
IsArchived optional.Option[bool]
|
||||
TagNames []string
|
||||
HasSha1 optional.Option[bool] // useful to find draft releases which are created with existing tags
|
||||
NamePattern optional.Option[string]
|
||||
}
|
||||
|
||||
func (opts FindReleasesOptions) ToConds() builder.Cond {
|
||||
@@ -252,6 +256,9 @@ func (opts FindReleasesOptions) ToConds() builder.Cond {
|
||||
if !opts.IncludeTags {
|
||||
cond = cond.And(builder.Eq{"is_tag": false})
|
||||
}
|
||||
if !opts.IncludeArchived {
|
||||
cond = cond.And(builder.Eq{"is_archived": false})
|
||||
}
|
||||
if len(opts.TagNames) > 0 {
|
||||
cond = cond.And(builder.In("tag_name", opts.TagNames))
|
||||
}
|
||||
@@ -261,6 +268,9 @@ func (opts FindReleasesOptions) ToConds() builder.Cond {
|
||||
if opts.IsDraft.Has() {
|
||||
cond = cond.And(builder.Eq{"is_draft": opts.IsDraft.Value()})
|
||||
}
|
||||
if opts.IsArchived.Has() {
|
||||
cond = cond.And(builder.Eq{"is_archived": opts.IsArchived.Value()})
|
||||
}
|
||||
if opts.HasSha1.Has() {
|
||||
if opts.HasSha1.Value() {
|
||||
cond = cond.And(builder.Neq{"sha1": ""})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
@@ -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{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
@@ -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 MarketAlly. 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
|
||||
}
|
||||
137
modules/actions/compatibility.go
Normal file
137
modules/actions/compatibility.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
// CompatibilityStatus represents the support status of an action
|
||||
type CompatibilityStatus string
|
||||
|
||||
const (
|
||||
CompatibilityFull CompatibilityStatus = "full"
|
||||
CompatibilityPartial CompatibilityStatus = "partial"
|
||||
CompatibilityNone CompatibilityStatus = "none"
|
||||
)
|
||||
|
||||
// ActionCompatibility represents compatibility information for a GitHub action
|
||||
type ActionCompatibility struct {
|
||||
// Action name (e.g., "actions/checkout")
|
||||
Name string
|
||||
// Supported versions
|
||||
Versions []string
|
||||
// Compatibility status
|
||||
Status CompatibilityStatus
|
||||
// Notes about compatibility
|
||||
Notes string
|
||||
// Suggested alternative if not fully compatible
|
||||
Alternative string
|
||||
}
|
||||
|
||||
// BuiltinCompatibility contains known action compatibility information for Gitea Actions
|
||||
// This is used by the capability discovery API to help AI tools write correct workflows
|
||||
var BuiltinCompatibility = map[string]*ActionCompatibility{
|
||||
"actions/checkout": {
|
||||
Name: "actions/checkout",
|
||||
Versions: []string{"v2", "v3", "v4"},
|
||||
Status: CompatibilityFull,
|
||||
Notes: "Fully compatible with Gitea Actions",
|
||||
},
|
||||
"actions/setup-node": {
|
||||
Name: "actions/setup-node",
|
||||
Versions: []string{"v2", "v3", "v4"},
|
||||
Status: CompatibilityFull,
|
||||
Notes: "Fully compatible with Gitea Actions",
|
||||
},
|
||||
"actions/setup-go": {
|
||||
Name: "actions/setup-go",
|
||||
Versions: []string{"v3", "v4", "v5"},
|
||||
Status: CompatibilityFull,
|
||||
Notes: "Fully compatible with Gitea Actions",
|
||||
},
|
||||
"actions/setup-python": {
|
||||
Name: "actions/setup-python",
|
||||
Versions: []string{"v4", "v5"},
|
||||
Status: CompatibilityFull,
|
||||
Notes: "Fully compatible with Gitea Actions",
|
||||
},
|
||||
"actions/setup-java": {
|
||||
Name: "actions/setup-java",
|
||||
Versions: []string{"v3", "v4"},
|
||||
Status: CompatibilityFull,
|
||||
Notes: "Fully compatible with Gitea Actions",
|
||||
},
|
||||
"actions/setup-dotnet": {
|
||||
Name: "actions/setup-dotnet",
|
||||
Versions: []string{"v3", "v4"},
|
||||
Status: CompatibilityFull,
|
||||
Notes: "Fully compatible with Gitea Actions",
|
||||
},
|
||||
"actions/upload-artifact": {
|
||||
Name: "actions/upload-artifact",
|
||||
Versions: []string{"v2", "v3"},
|
||||
Status: CompatibilityPartial,
|
||||
Notes: "v4 not supported on GHES-compatible runners. Use v3 or Gitea API for artifact upload.",
|
||||
Alternative: "actions/upload-artifact@v3 or direct Gitea API upload",
|
||||
},
|
||||
"actions/download-artifact": {
|
||||
Name: "actions/download-artifact",
|
||||
Versions: []string{"v2", "v3"},
|
||||
Status: CompatibilityPartial,
|
||||
Notes: "v4 not supported on GHES-compatible runners. Use v3.",
|
||||
Alternative: "actions/download-artifact@v3",
|
||||
},
|
||||
"actions/cache": {
|
||||
Name: "actions/cache",
|
||||
Versions: []string{"v3", "v4"},
|
||||
Status: CompatibilityFull,
|
||||
Notes: "Fully compatible with Gitea Actions",
|
||||
},
|
||||
"actions/github-script": {
|
||||
Name: "actions/github-script",
|
||||
Versions: []string{"v6", "v7"},
|
||||
Status: CompatibilityPartial,
|
||||
Notes: "GitHub API calls may not work. Use for basic scripting only.",
|
||||
},
|
||||
}
|
||||
|
||||
// UnsupportedFeatures lists features that are not supported in Gitea Actions
|
||||
var UnsupportedFeatures = []string{
|
||||
"GitHub-hosted runners",
|
||||
"Environments with protection rules",
|
||||
"OIDC token authentication",
|
||||
"Required workflows",
|
||||
"Deployment branches",
|
||||
"Reusable workflows from other repositories (limited)",
|
||||
"actions/upload-artifact@v4",
|
||||
"actions/download-artifact@v4",
|
||||
}
|
||||
|
||||
// IncompatibleActions maps action@version to error messages
|
||||
var IncompatibleActions = map[string]string{
|
||||
"actions/upload-artifact@v4": "v4 not supported on Gitea/GHES-compatible runners. Use actions/upload-artifact@v3 or direct Gitea API upload.",
|
||||
"actions/download-artifact@v4": "v4 not supported on Gitea/GHES-compatible runners. Use actions/download-artifact@v3.",
|
||||
}
|
||||
|
||||
// GetCompatibility returns compatibility information for an action
|
||||
func GetCompatibility(actionName string) *ActionCompatibility {
|
||||
return BuiltinCompatibility[actionName]
|
||||
}
|
||||
|
||||
// GetIncompatibilityMessage returns an error message if the action@version is incompatible
|
||||
func GetIncompatibilityMessage(actionWithVersion string) string {
|
||||
return IncompatibleActions[actionWithVersion]
|
||||
}
|
||||
|
||||
// IsActionCompatible checks if an action@version is compatible with Gitea Actions
|
||||
func IsActionCompatible(actionWithVersion string) bool {
|
||||
_, incompatible := IncompatibleActions[actionWithVersion]
|
||||
return !incompatible
|
||||
}
|
||||
|
||||
// GetSuggestedAlternative returns a suggested alternative for an incompatible action
|
||||
func GetSuggestedAlternative(actionWithVersion string) string {
|
||||
alternatives := map[string]string{
|
||||
"actions/upload-artifact@v4": "uses: actions/upload-artifact@v3",
|
||||
"actions/download-artifact@v4": "uses: actions/download-artifact@v3",
|
||||
}
|
||||
return alternatives[actionWithVersion]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package circuitbreaker implements the circuit breaker pattern for external service calls.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errors
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errors
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package health provides comprehensive health checking for Gitea services.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package idempotency provides middleware for idempotent POST request handling.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package operations provides tracking for long-running operations
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pages
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
|
||||
// LandingConfig represents the parsed .gitea/landing.yaml configuration
|
||||
type LandingConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Template string `yaml:"template"` // simple, documentation, product, portfolio
|
||||
Enabled bool `yaml:"enabled"`
|
||||
PublicLanding bool `yaml:"public_landing"` // Allow public access even for private repos
|
||||
Template string `yaml:"template"` // simple, documentation, product, portfolio
|
||||
|
||||
// Custom domain (optional)
|
||||
Domain string `yaml:"domain,omitempty"`
|
||||
@@ -182,9 +183,10 @@ type UmamiConfig struct {
|
||||
|
||||
// AdvancedConfig represents advanced settings
|
||||
type AdvancedConfig struct {
|
||||
CustomCSS string `yaml:"custom_css,omitempty"`
|
||||
CustomHead string `yaml:"custom_head,omitempty"`
|
||||
Redirects map[string]string `yaml:"redirects,omitempty"`
|
||||
CustomCSS string `yaml:"custom_css,omitempty"`
|
||||
CustomHead string `yaml:"custom_head,omitempty"`
|
||||
Redirects map[string]string `yaml:"redirects,omitempty"`
|
||||
PublicReleases bool `yaml:"public_releases,omitempty"` // Allow public access to releases even for private repos
|
||||
}
|
||||
|
||||
// ParseLandingConfig parses a landing.yaml file content
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package storage
|
||||
@@ -14,12 +14,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAzureBlobStorageIterator(t *testing.T) {
|
||||
if os.Getenv("CI") == "" {
|
||||
t.Skip("azureBlobStorage not present outside of CI")
|
||||
return
|
||||
}
|
||||
testStorageIterator(t, setting.AzureBlobStorageType, &setting.Storage{
|
||||
// azureBlobTestConfig returns the Azure Blob storage config for tests.
|
||||
// Returns nil if Azurite is not available (skip the test).
|
||||
func azureBlobTestConfig() *setting.Storage {
|
||||
return &setting.Storage{
|
||||
AzureBlobConfig: setting.AzureBlobStorageConfig{
|
||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
|
||||
Endpoint: "http://devstoreaccount1.azurite.local:10000",
|
||||
@@ -28,7 +26,36 @@ func TestAzureBlobStorageIterator(t *testing.T) {
|
||||
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
|
||||
Container: "test",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// skipIfNoAzurite skips the test if Azurite service is not available.
|
||||
// In CI, we always skip since Azurite is not available in our runner environment.
|
||||
func skipIfNoAzurite(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Skip("azureBlobStorage requires Azurite service which is not available in CI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureBlobStorageIterator(t *testing.T) {
|
||||
skipIfNoAzurite(t)
|
||||
|
||||
cfg := azureBlobTestConfig()
|
||||
// Try to create storage to verify Azurite is available
|
||||
s, err := NewStorage(setting.AzureBlobStorageType, cfg)
|
||||
if err != nil {
|
||||
t.Skipf("azureBlobStorage not available: %v", err)
|
||||
}
|
||||
// Clean up the test storage
|
||||
_ = 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.AzureBlobStorageType, cfg)
|
||||
}
|
||||
|
||||
func TestAzureBlobStoragePath(t *testing.T) {
|
||||
@@ -58,22 +85,12 @@ func TestAzureBlobStoragePath(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_azureBlobObject(t *testing.T) {
|
||||
if os.Getenv("CI") == "" {
|
||||
t.Skip("azureBlobStorage not present outside of CI")
|
||||
return
|
||||
}
|
||||
skipIfNoAzurite(t)
|
||||
|
||||
s, err := NewStorage(setting.AzureBlobStorageType, &setting.Storage{
|
||||
AzureBlobConfig: setting.AzureBlobStorageConfig{
|
||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
|
||||
Endpoint: "http://devstoreaccount1.azurite.local:10000",
|
||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#well-known-storage-account-and-key
|
||||
AccountName: "devstoreaccount1",
|
||||
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
|
||||
Container: "test",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
s, err := NewStorage(setting.AzureBlobStorageType, azureBlobTestConfig())
|
||||
if err != nil {
|
||||
t.Skipf("azureBlobStorage not available: %v", err)
|
||||
}
|
||||
|
||||
data := "Q2xTckt6Y1hDOWh0"
|
||||
_, err = s.Save("test.txt", strings.NewReader(data), int64(len(data)))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package storage
|
||||
@@ -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",
|
||||
|
||||
122
modules/structs/actions_capabilities.go
Normal file
122
modules/structs/actions_capabilities.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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"`
|
||||
Shell []string `json:"shell,omitempty"`
|
||||
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
|
||||
type CapabilityFeatures struct {
|
||||
ArtifactsV4 bool `json:"artifacts_v4"`
|
||||
Cache bool `json:"cache"`
|
||||
Services bool `json:"services"`
|
||||
CompositeActions bool `json:"composite_actions"`
|
||||
}
|
||||
|
||||
// ActionSupport represents version support for an action
|
||||
type ActionSupport struct {
|
||||
Versions []string `json:"versions"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// PlatformInfo represents Gitea platform capabilities
|
||||
type PlatformInfo struct {
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
ActionsVersion string `json:"actions_version,omitempty"`
|
||||
DefaultActionsURL string `json:"default_actions_url"`
|
||||
SupportedActions map[string]ActionSupport `json:"supported_actions,omitempty"`
|
||||
UnsupportedFeatures []string `json:"unsupported_features,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowHints provides hints for AI workflow generation
|
||||
type WorkflowHints struct {
|
||||
PreferredCheckout string `json:"preferred_checkout,omitempty"`
|
||||
ArtifactUploadAlternative string `json:"artifact_upload_alternative,omitempty"`
|
||||
SecretAccess string `json:"secret_access,omitempty"`
|
||||
}
|
||||
|
||||
// RunnerWithCapabilities represents a runner with its capabilities for API response
|
||||
type RunnerWithCapabilities struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Labels []string `json:"labels"`
|
||||
Capabilities *RunnerCapability `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// ActionsCapabilitiesResponse is the response for the capabilities endpoint
|
||||
type ActionsCapabilitiesResponse struct {
|
||||
Runners []*RunnerWithCapabilities `json:"runners"`
|
||||
Platform *PlatformInfo `json:"platform"`
|
||||
WorkflowHints *WorkflowHints `json:"workflow_hints,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowValidationRequest is the request for workflow validation
|
||||
type WorkflowValidationRequest struct {
|
||||
Content string `json:"content" binding:"Required"`
|
||||
}
|
||||
|
||||
// WorkflowValidationWarning represents a validation warning
|
||||
type WorkflowValidationWarning struct {
|
||||
Line int `json:"line,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Suggestion string `json:"suggestion,omitempty"`
|
||||
}
|
||||
|
||||
// RunnerMatch represents job-to-runner matching result
|
||||
type RunnerMatch struct {
|
||||
Job string `json:"job"`
|
||||
RunsOn []string `json:"runs_on"`
|
||||
MatchedRunners []string `json:"matched_runners,omitempty"`
|
||||
CapabilitiesMet bool `json:"capabilities_met"`
|
||||
}
|
||||
|
||||
// WorkflowValidationResponse is the response for workflow validation
|
||||
type WorkflowValidationResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Warnings []*WorkflowValidationWarning `json:"warnings,omitempty"`
|
||||
RunnerMatch []*RunnerMatch `json:"runner_match,omitempty"`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs // import "code.gitea.io/gitea/modules/structs"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2016 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
@@ -41,6 +41,10 @@ type Release struct {
|
||||
Publisher *User `json:"author"`
|
||||
// The files attached to the release
|
||||
Attachments []*Attachment `json:"assets"`
|
||||
// Whether the release is archived
|
||||
IsArchived bool `json:"archived"`
|
||||
// swagger:strfmt date-time
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateReleaseOption options when creating a release
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package templates
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package templates
|
||||
@@ -35,6 +35,7 @@ func (su *StringUtils) ToString(v any) string {
|
||||
func (su *StringUtils) HasPrefix(s, prefix string) bool {
|
||||
return strings.HasPrefix(s, prefix)
|
||||
}
|
||||
func (su *StringUtils) HasSuffix(s, suffix string) bool { return strings.HasSuffix(s, suffix) }
|
||||
|
||||
func (su *StringUtils) Contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
@@ -61,6 +62,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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package webhook
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
@@ -115,9 +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
|
||||
if err := actions_model.UpdateRunner(ctx, runner, "agent_labels", "version"); err != nil {
|
||||
runner.CapabilitiesJSON = req.Msg.CapabilitiesJson
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -140,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)
|
||||
@@ -166,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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2016 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package v1 Gitea API
|
||||
@@ -1327,6 +1327,9 @@ func Routes() *web.Router {
|
||||
m.Combo("").Get(repo.GetRelease).
|
||||
Patch(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.EditReleaseOption{}), repo.EditRelease).
|
||||
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteRelease)
|
||||
m.Combo("/archive").
|
||||
Post(reqToken(), reqRepoWriter(unit.TypeReleases), repo.ArchiveRelease).
|
||||
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.UnarchiveRelease)
|
||||
m.Group("/assets", func() {
|
||||
m.Combo("").Get(repo.ListReleaseAttachments).
|
||||
Post(reqToken(), reqRepoWriter(unit.TypeReleases), repo.CreateReleaseAttachment)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
@@ -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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2016 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
@@ -136,6 +136,10 @@ func ListReleases(ctx *context.APIContext) {
|
||||
// in: query
|
||||
// description: filter (exclude / include) pre-releases
|
||||
// type: boolean
|
||||
// - name: archived
|
||||
// in: query
|
||||
// description: filter archived releases (true=only archived, false=exclude archived, omit=all)
|
||||
// type: boolean
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
@@ -151,13 +155,18 @@ func ListReleases(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
// By default, exclude archived releases unless explicitly requested
|
||||
includeArchived := ctx.FormOptionalBool("archived").Has()
|
||||
|
||||
opts := repo_model.FindReleasesOptions{
|
||||
ListOptions: listOptions,
|
||||
IncludeDrafts: ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite,
|
||||
IncludeTags: false,
|
||||
IsDraft: ctx.FormOptionalBool("draft"),
|
||||
IsPreRelease: ctx.FormOptionalBool("pre-release"),
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
ListOptions: listOptions,
|
||||
IncludeDrafts: ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite,
|
||||
IncludeTags: false,
|
||||
IncludeArchived: includeArchived,
|
||||
IsDraft: ctx.FormOptionalBool("draft"),
|
||||
IsPreRelease: ctx.FormOptionalBool("pre-release"),
|
||||
IsArchived: ctx.FormOptionalBool("archived"),
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
}
|
||||
|
||||
releases, err := db.Find[repo_model.Release](ctx, opts)
|
||||
@@ -419,3 +428,109 @@ func DeleteRelease(ctx *context.APIContext) {
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ArchiveRelease archives a release
|
||||
func ArchiveRelease(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/releases/{id}/archive repository repoArchiveRelease
|
||||
// ---
|
||||
// summary: Archive a release
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the release to archive
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Release"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
id := ctx.PathParamInt64("id")
|
||||
release, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id)
|
||||
if err != nil && !repo_model.IsErrReleaseNotExist(err) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if err != nil && repo_model.IsErrReleaseNotExist(err) || release.IsTag {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := release_service.ArchiveRelease(ctx, release); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := release.LoadAttributes(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
|
||||
}
|
||||
|
||||
// UnarchiveRelease unarchives a release
|
||||
func UnarchiveRelease(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/releases/{id}/archive repository repoUnarchiveRelease
|
||||
// ---
|
||||
// summary: Unarchive a release
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the release to unarchive
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Release"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
id := ctx.PathParamInt64("id")
|
||||
release, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id)
|
||||
if err != nil && !repo_model.IsErrReleaseNotExist(err) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if err != nil && repo_model.IsErrReleaseNotExist(err) || release.IsTag {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := release_service.UnarchiveRelease(ctx, release); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := release.LoadAttributes(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
191
routers/api/v2/actions.go
Normal file
191
routers/api/v2/actions.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/modules/actions"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// getSupportedActions converts the compatibility module data to API format
|
||||
func getSupportedActions() map[string]api.ActionSupport {
|
||||
result := make(map[string]api.ActionSupport)
|
||||
for name, compat := range actions.BuiltinCompatibility {
|
||||
result[name] = api.ActionSupport{
|
||||
Versions: compat.Versions,
|
||||
Notes: compat.Notes,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetActionsCapabilities returns structured capability information for AI consumption
|
||||
// @Summary Get runner capabilities for AI workflow generation
|
||||
// @Description Returns detailed runner capabilities, platform info, and action compatibility
|
||||
// @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 {object} api.ActionsCapabilitiesResponse
|
||||
// @Router /repos/{owner}/{repo}/actions/runners/capabilities [get]
|
||||
func GetActionsCapabilities(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
// Get runners available for this repository
|
||||
runners, err := actions_model.GetRunnersOfRepo(ctx, repo.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := &api.ActionsCapabilitiesResponse{
|
||||
Runners: make([]*api.RunnerWithCapabilities, 0, len(runners)),
|
||||
Platform: &api.PlatformInfo{
|
||||
Type: "gitea",
|
||||
Version: setting.AppVer,
|
||||
DefaultActionsURL: setting.Actions.DefaultActionsURL.URL(),
|
||||
SupportedActions: getSupportedActions(),
|
||||
UnsupportedFeatures: actions.UnsupportedFeatures,
|
||||
},
|
||||
WorkflowHints: &api.WorkflowHints{
|
||||
PreferredCheckout: "actions/checkout@v4",
|
||||
ArtifactUploadAlternative: "Use Gitea API: curl -X POST $GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/{id}/assets",
|
||||
SecretAccess: "Use ${{ secrets.NAME }} syntax",
|
||||
},
|
||||
}
|
||||
|
||||
// Process each runner
|
||||
for _, runner := range runners {
|
||||
status := "offline"
|
||||
if runner.LastOnline.AsTime().Add(actions_model.RunnerOfflineTime).After(time.Now()) {
|
||||
status = "online"
|
||||
}
|
||||
|
||||
runnerResp := &api.RunnerWithCapabilities{
|
||||
ID: runner.ID,
|
||||
Name: runner.Name,
|
||||
Status: status,
|
||||
Labels: runner.AgentLabels,
|
||||
}
|
||||
|
||||
// Parse capabilities JSON if available
|
||||
if runner.CapabilitiesJSON != "" {
|
||||
var runnerCaps api.RunnerCapability
|
||||
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &runnerCaps); err == nil {
|
||||
runnerResp.Capabilities = &runnerCaps
|
||||
}
|
||||
}
|
||||
|
||||
// If no capabilities, infer from labels
|
||||
if runnerResp.Capabilities == nil {
|
||||
runnerResp.Capabilities = inferCapabilitiesFromLabels(runner.AgentLabels)
|
||||
}
|
||||
|
||||
response.Runners = append(response.Runners, runnerResp)
|
||||
}
|
||||
|
||||
ctx.JSON(200, response)
|
||||
}
|
||||
|
||||
// inferCapabilitiesFromLabels attempts to infer capabilities from runner labels
|
||||
func inferCapabilitiesFromLabels(labels []string) *api.RunnerCapability {
|
||||
caps := &api.RunnerCapability{
|
||||
Limitations: []string{
|
||||
"Capabilities inferred from labels - may not be accurate",
|
||||
"actions/upload-artifact@v4 not supported (use v3 or direct API upload)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, label := range labels {
|
||||
switch label {
|
||||
case "ubuntu-latest", "ubuntu-22.04", "ubuntu-20.04":
|
||||
caps.OS = "linux"
|
||||
caps.Shell = []string{"bash", "sh"}
|
||||
case "windows-latest", "windows-2022", "windows-2019":
|
||||
caps.OS = "windows"
|
||||
caps.Shell = []string{"pwsh", "powershell", "cmd"}
|
||||
case "macos-latest", "macos-13", "macos-12":
|
||||
caps.OS = "darwin"
|
||||
caps.Shell = []string{"bash", "sh", "zsh"}
|
||||
case "linux":
|
||||
caps.OS = "linux"
|
||||
case "x64", "amd64":
|
||||
caps.Arch = "amd64"
|
||||
case "arm64", "aarch64":
|
||||
caps.Arch = "arm64"
|
||||
case "docker":
|
||||
caps.Docker = true
|
||||
caps.ContainerRuntime = "docker"
|
||||
}
|
||||
}
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
// ValidateWorkflow validates a workflow YAML and returns compatibility warnings
|
||||
// @Summary Validate a workflow for compatibility
|
||||
// @Description Parses workflow YAML and returns warnings about unsupported features
|
||||
// @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 body body api.WorkflowValidationRequest true "Workflow content"
|
||||
// @Success 200 {object} api.WorkflowValidationResponse
|
||||
// @Router /repos/{owner}/{repo}/actions/workflows/validate [post]
|
||||
func ValidateWorkflow(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.WorkflowValidationRequest)
|
||||
|
||||
response := &api.WorkflowValidationResponse{
|
||||
Valid: true,
|
||||
Warnings: make([]*api.WorkflowValidationWarning, 0),
|
||||
}
|
||||
|
||||
// Check for known incompatible actions using the compatibility module
|
||||
for action, message := range actions.IncompatibleActions {
|
||||
if containsAction(form.Content, action) {
|
||||
response.Warnings = append(response.Warnings, &api.WorkflowValidationWarning{
|
||||
Action: action,
|
||||
Severity: "error",
|
||||
Message: message,
|
||||
Suggestion: actions.GetSuggestedAlternative(action),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(response.Warnings) > 0 {
|
||||
response.Valid = false
|
||||
}
|
||||
|
||||
ctx.JSON(200, response)
|
||||
}
|
||||
|
||||
// containsAction checks if workflow content contains a specific action
|
||||
func containsAction(content, action string) bool {
|
||||
// Simple string search - could be enhanced with YAML parsing
|
||||
return len(content) > 0 && len(action) > 0 &&
|
||||
(contains(content, "uses: "+action) || contains(content, "uses: \""+action+"\""))
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
|
||||
}
|
||||
|
||||
func containsSubstr(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package v2 Gitea API v2
|
||||
@@ -22,8 +22,12 @@ package v2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
apierrors "code.gitea.io/gitea/modules/errors"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/idempotency"
|
||||
@@ -82,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)
|
||||
@@ -132,6 +139,33 @@ func Routes() *web.Router {
|
||||
m.Delete("/pages/{pageName}", DeleteWikiPageV2)
|
||||
}, reqToken())
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
// Releases v2 API - Enhanced releases with app update support
|
||||
// Supports public access for private repos with public_releases enabled
|
||||
m.Group("/repos/{owner}/{repo}/releases", func() {
|
||||
// App update endpoint - Electron/Squirrel compatible
|
||||
// Returns 200 with update info or 204 if no update available
|
||||
m.Get("/update", repoAssignmentWithPublicAccess(), CheckAppUpdate)
|
||||
|
||||
// List and get releases
|
||||
m.Get("", repoAssignmentWithPublicAccess(), ListReleasesV2)
|
||||
m.Get("/latest", repoAssignmentWithPublicAccess(), GetLatestReleaseV2)
|
||||
m.Get("/{tag}", repoAssignmentWithPublicAccess(), GetReleaseV2)
|
||||
})
|
||||
|
||||
// Public landing page API - for private repos with public_landing enabled
|
||||
m.Group("/repos/{owner}/{repo}/pages", func() {
|
||||
m.Get("/config", repoAssignmentWithPublicAccess(), GetPagesConfig)
|
||||
m.Get("/content", repoAssignmentWithPublicAccess(), GetPagesContent)
|
||||
})
|
||||
})
|
||||
|
||||
return m
|
||||
@@ -188,3 +222,59 @@ func reqToken() func(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repoAssignment loads the repository from path parameters
|
||||
func repoAssignment() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
ownerName := ctx.PathParam("owner")
|
||||
repoName := ctx.PathParam("repo")
|
||||
|
||||
var (
|
||||
owner *user_model.User
|
||||
err error
|
||||
)
|
||||
|
||||
// Check if the user is the same as the repository owner
|
||||
if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, ownerName) {
|
||||
owner = ctx.Doer
|
||||
} else {
|
||||
owner, err = user_model.GetUserByName(ctx, ownerName)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.Repo.Owner = owner
|
||||
ctx.ContextUser = owner
|
||||
|
||||
// Get repository
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetRepositoryByName", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
repo.Owner = owner
|
||||
ctx.Repo.Repository = repo
|
||||
|
||||
// Get permissions
|
||||
ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
ctx.APIErrorNotFound("HasAnyUnitAccessOrPublicAccess")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
160
routers/api/v2/pages_api.go
Normal file
160
routers/api/v2/pages_api.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
pages_module "code.gitea.io/gitea/modules/pages"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
pages_service "code.gitea.io/gitea/services/pages"
|
||||
)
|
||||
|
||||
// PagesConfigResponse represents the pages configuration for a repository
|
||||
type PagesConfigResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicLanding bool `json:"public_landing"`
|
||||
Template string `json:"template"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Branding pages_module.BrandingConfig `json:"branding"`
|
||||
Hero pages_module.HeroConfig `json:"hero"`
|
||||
SEO pages_module.SEOConfig `json:"seo"`
|
||||
Footer pages_module.FooterConfig `json:"footer"`
|
||||
}
|
||||
|
||||
// PagesContentResponse represents the rendered content for a landing page
|
||||
type PagesContentResponse struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Readme string `json:"readme,omitempty"`
|
||||
}
|
||||
|
||||
// GetPagesConfig returns the pages configuration for a repository
|
||||
// GET /api/v2/repos/{owner}/{repo}/pages/config
|
||||
func GetPagesConfig(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
if repo == nil {
|
||||
ctx.APIErrorNotFound("Repository not found")
|
||||
return
|
||||
}
|
||||
|
||||
config, err := pages_service.GetPagesConfig(ctx, repo)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound("Pages not configured")
|
||||
return
|
||||
}
|
||||
|
||||
response := &PagesConfigResponse{
|
||||
Enabled: config.Enabled,
|
||||
PublicLanding: config.PublicLanding,
|
||||
Template: config.Template,
|
||||
Domain: config.Domain,
|
||||
Branding: config.Branding,
|
||||
Hero: config.Hero,
|
||||
SEO: config.SEO,
|
||||
Footer: config.Footer,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetPagesContent returns the rendered content for a repository's landing page
|
||||
// GET /api/v2/repos/{owner}/{repo}/pages/content
|
||||
func GetPagesContent(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
if repo == nil {
|
||||
ctx.APIErrorNotFound("Repository not found")
|
||||
return
|
||||
}
|
||||
|
||||
config, err := pages_service.GetPagesConfig(ctx, repo)
|
||||
if err != nil || !config.Enabled {
|
||||
ctx.APIErrorNotFound("Pages not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Load README content
|
||||
readme := loadReadmeContent(ctx, repo)
|
||||
|
||||
// Build title
|
||||
title := config.SEO.Title
|
||||
if title == "" {
|
||||
title = config.Hero.Title
|
||||
}
|
||||
if title == "" {
|
||||
title = repo.Name
|
||||
}
|
||||
|
||||
// Build description
|
||||
description := config.SEO.Description
|
||||
if description == "" {
|
||||
description = config.Hero.Tagline
|
||||
}
|
||||
if description == "" {
|
||||
description = repo.Description
|
||||
}
|
||||
|
||||
response := &PagesContentResponse{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Readme: readme,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// loadReadmeContent loads the README content from the repository
|
||||
func loadReadmeContent(ctx *context.APIContext, repo *repo_model.Repository) string {
|
||||
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
branch := repo.DefaultBranch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(branch)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try common README paths
|
||||
readmePaths := []string{
|
||||
"README.md",
|
||||
"readme.md",
|
||||
"Readme.md",
|
||||
"README.markdown",
|
||||
"README.txt",
|
||||
"README",
|
||||
}
|
||||
|
||||
for _, path := range readmePaths {
|
||||
entry, err := commit.GetTreeEntryByPath(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
reader, err := entry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
content := make([]byte, entry.Blob().Size())
|
||||
_, err = reader.Read(content)
|
||||
reader.Close()
|
||||
|
||||
if err != nil && err.Error() != "EOF" {
|
||||
continue
|
||||
}
|
||||
|
||||
return string(content)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
553
routers/api/v2/releases.go
Normal file
553
routers/api/v2/releases.go
Normal file
@@ -0,0 +1,553 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
apierrors "code.gitea.io/gitea/modules/errors"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
pages_service "code.gitea.io/gitea/services/pages"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
)
|
||||
|
||||
// AppUpdateResponse represents the response for an app update check
|
||||
// Compatible with Electron autoUpdater (Squirrel format)
|
||||
type AppUpdateResponse struct {
|
||||
// URL to download the update
|
||||
URL string `json:"url"`
|
||||
// Version name (semver)
|
||||
Name string `json:"name"`
|
||||
// Release notes (markdown)
|
||||
Notes string `json:"notes"`
|
||||
// Publication date (RFC3339)
|
||||
PubDate string `json:"pub_date"`
|
||||
// Whether this is a mandatory/priority update
|
||||
Mandatory bool `json:"mandatory,omitempty"`
|
||||
// Additional platform-specific info
|
||||
Platform *PlatformInfo `json:"platform,omitempty"`
|
||||
}
|
||||
|
||||
// PlatformInfo contains platform-specific update information
|
||||
type PlatformInfo struct {
|
||||
// For Windows: URL to RELEASES file
|
||||
ReleasesURL string `json:"releases_url,omitempty"`
|
||||
// For Windows: URL to nupkg file
|
||||
NupkgURL string `json:"nupkg_url,omitempty"`
|
||||
// Signature/checksum for verification
|
||||
Signature string `json:"signature,omitempty"`
|
||||
// File size in bytes
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
// CheckAppUpdate checks if an update is available for an app
|
||||
// This endpoint is designed for Electron apps using autoUpdater
|
||||
// GET /api/v2/repos/{owner}/{repo}/releases/update?version=1.0.0&platform=darwin&arch=arm64
|
||||
func CheckAppUpdate(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
if repo == nil {
|
||||
ctx.APIErrorNotFound("Repository not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
currentVersion := ctx.FormString("version")
|
||||
platform := ctx.FormString("platform")
|
||||
arch := ctx.FormString("arch")
|
||||
channel := ctx.FormString("channel")
|
||||
|
||||
// Default to current runtime if not specified
|
||||
if platform == "" {
|
||||
platform = runtime.GOOS
|
||||
}
|
||||
if arch == "" {
|
||||
arch = runtime.GOARCH
|
||||
if arch == "amd64" {
|
||||
arch = "x64"
|
||||
}
|
||||
}
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
|
||||
// Parse current version
|
||||
current, err := semver.NewVersion(strings.TrimPrefix(currentVersion, "v"))
|
||||
if err != nil {
|
||||
ctx.APIErrorWithCodeAndMessage(apierrors.ValInvalidInput, "Invalid version format: "+currentVersion)
|
||||
return
|
||||
}
|
||||
|
||||
// Build find options
|
||||
opts := repo_model.FindReleasesOptions{
|
||||
ListOptions: db.ListOptions{PageSize: 50},
|
||||
RepoID: repo.ID,
|
||||
IncludeDrafts: false,
|
||||
IncludeTags: false,
|
||||
}
|
||||
if channel == "stable" {
|
||||
opts.IsPreRelease = optional.Some(false)
|
||||
}
|
||||
|
||||
// Get releases
|
||||
releases, err := db.Find[repo_model.Release](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the latest release newer than current version
|
||||
var latestRelease *repo_model.Release
|
||||
var latestVersion *semver.Version
|
||||
|
||||
for _, release := range releases {
|
||||
if release.IsDraft {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip prereleases unless on beta/alpha channel
|
||||
if release.IsPrerelease && channel == "stable" {
|
||||
continue
|
||||
}
|
||||
|
||||
tagVersion := strings.TrimPrefix(release.TagName, "v")
|
||||
ver, err := semver.NewVersion(tagVersion)
|
||||
if err != nil {
|
||||
continue // Skip invalid versions
|
||||
}
|
||||
|
||||
// Check if this version is newer than current
|
||||
if ver.GreaterThan(current) {
|
||||
if latestVersion == nil || ver.GreaterThan(latestVersion) {
|
||||
latestVersion = ver
|
||||
latestRelease = release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No update available
|
||||
if latestRelease == nil {
|
||||
// Return 204 No Content for no update (Squirrel convention)
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Load release attachments
|
||||
if err := repo_model.GetReleaseAttachments(ctx, latestRelease); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the appropriate asset for this platform/arch
|
||||
downloadURL, platformInfo := findUpdateAsset(latestRelease, platform, arch)
|
||||
if downloadURL == "" {
|
||||
// No compatible asset found
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
response := &AppUpdateResponse{
|
||||
URL: downloadURL,
|
||||
Name: latestRelease.TagName,
|
||||
Notes: latestRelease.Note,
|
||||
PubDate: latestRelease.CreatedUnix.AsTime().Format("2006-01-02T15:04:05Z07:00"),
|
||||
Platform: platformInfo,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// findUpdateAsset finds the appropriate download asset for the given platform and architecture
|
||||
func findUpdateAsset(release *repo_model.Release, platform, arch string) (string, *PlatformInfo) {
|
||||
if len(release.Attachments) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var platformInfo *PlatformInfo
|
||||
|
||||
// Platform-specific asset patterns
|
||||
patterns := getAssetPatterns(platform, arch)
|
||||
|
||||
for _, pattern := range patterns {
|
||||
for _, asset := range release.Attachments {
|
||||
name := strings.ToLower(asset.Name)
|
||||
if matchesPattern(name, pattern) {
|
||||
// Build direct download URL
|
||||
directURL := fmt.Sprintf("%s%s/%s/releases/download/%s/%s",
|
||||
setting.AppURL,
|
||||
release.Repo.OwnerName,
|
||||
release.Repo.Name,
|
||||
release.TagName,
|
||||
asset.Name,
|
||||
)
|
||||
|
||||
platformInfo = &PlatformInfo{
|
||||
Size: asset.Size,
|
||||
}
|
||||
|
||||
// For Windows, also look for RELEASES file
|
||||
if platform == "windows" {
|
||||
for _, a := range release.Attachments {
|
||||
if strings.EqualFold(a.Name, "RELEASES") {
|
||||
platformInfo.ReleasesURL = fmt.Sprintf("%s%s/%s/releases/download/%s/%s",
|
||||
setting.AppURL,
|
||||
release.Repo.OwnerName,
|
||||
release.Repo.Name,
|
||||
release.TagName,
|
||||
a.Name,
|
||||
)
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(a.Name), ".nupkg") {
|
||||
platformInfo.NupkgURL = fmt.Sprintf("%s%s/%s/releases/download/%s/%s",
|
||||
setting.AppURL,
|
||||
release.Repo.OwnerName,
|
||||
release.Repo.Name,
|
||||
release.TagName,
|
||||
a.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return directURL, platformInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getAssetPatterns returns file patterns to match for the given platform/arch
|
||||
func getAssetPatterns(platform, arch string) []string {
|
||||
switch platform {
|
||||
case "darwin", "macos":
|
||||
if arch == "arm64" {
|
||||
return []string{
|
||||
"arm64.zip",
|
||||
"darwin-arm64.zip",
|
||||
"macos-arm64.zip",
|
||||
"osx-arm64.zip",
|
||||
"universal.zip",
|
||||
".zip", // Fallback
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"x64.zip",
|
||||
"darwin-x64.zip",
|
||||
"macos-x64.zip",
|
||||
"osx-x64.zip",
|
||||
"intel.zip",
|
||||
"universal.zip",
|
||||
".zip", // Fallback
|
||||
}
|
||||
case "windows", "win32":
|
||||
if arch == "arm64" {
|
||||
return []string{
|
||||
"arm64.exe",
|
||||
"win-arm64.exe",
|
||||
"windows-arm64.exe",
|
||||
"setup-arm64.exe",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"x64.exe",
|
||||
"win-x64.exe",
|
||||
"windows-x64.exe",
|
||||
"setup-x64.exe",
|
||||
"setup.exe", // Fallback
|
||||
".exe",
|
||||
}
|
||||
case "linux":
|
||||
if arch == "arm64" {
|
||||
return []string{
|
||||
"arm64.appimage",
|
||||
"linux-arm64.appimage",
|
||||
"aarch64.appimage",
|
||||
"arm64.deb",
|
||||
"arm64.rpm",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"x86_64.appimage",
|
||||
"linux-x64.appimage",
|
||||
"amd64.appimage",
|
||||
"amd64.deb",
|
||||
"x86_64.rpm",
|
||||
".appimage",
|
||||
".deb",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchesPattern checks if a filename matches a pattern (case-insensitive suffix)
|
||||
func matchesPattern(name, pattern string) bool {
|
||||
return strings.HasSuffix(name, pattern)
|
||||
}
|
||||
|
||||
// ListReleasesV2 lists releases with enhanced filtering
|
||||
// GET /api/v2/repos/{owner}/{repo}/releases
|
||||
func ListReleasesV2(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
if repo == nil {
|
||||
ctx.APIErrorNotFound("Repository not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
page := ctx.FormInt("page")
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
limit := ctx.FormInt("limit")
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
includePrereleases := ctx.FormBool("prereleases")
|
||||
includeDrafts := ctx.FormBool("drafts") && ctx.Repo.Permission.IsAdmin()
|
||||
|
||||
opts := repo_model.FindReleasesOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: limit,
|
||||
},
|
||||
RepoID: repo.ID,
|
||||
IncludeDrafts: includeDrafts,
|
||||
IncludeTags: false,
|
||||
}
|
||||
|
||||
if !includePrereleases {
|
||||
opts.IsPreRelease = optional.Some(false)
|
||||
}
|
||||
|
||||
releases, err := db.Find[repo_model.Release](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Load attachments for all releases
|
||||
if err := repo_model.GetReleaseAttachments(ctx, releases...); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to API format
|
||||
apiReleases := make([]*api.Release, 0, len(releases))
|
||||
for _, release := range releases {
|
||||
apiReleases = append(apiReleases, convertToAPIRelease(repo, release))
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, apiReleases)
|
||||
}
|
||||
|
||||
// GetReleaseV2 gets a specific release by tag or ID
|
||||
// GET /api/v2/repos/{owner}/{repo}/releases/{tag}
|
||||
func GetReleaseV2(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
if repo == nil {
|
||||
ctx.APIErrorNotFound("Repository not found")
|
||||
return
|
||||
}
|
||||
|
||||
tag := ctx.PathParam("tag")
|
||||
|
||||
var release *repo_model.Release
|
||||
var err error
|
||||
|
||||
// Try to parse as ID first
|
||||
if id := ctx.PathParamInt64("tag"); id > 0 {
|
||||
release, err = repo_model.GetReleaseByID(ctx, id)
|
||||
} else {
|
||||
// Try as tag name
|
||||
release, err = repo_model.GetRelease(ctx, repo.ID, tag)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if repo_model.IsErrReleaseNotExist(err) {
|
||||
ctx.APIErrorNotFound("Release not found")
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo_model.GetReleaseAttachments(ctx, release); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convertToAPIRelease(repo, release))
|
||||
}
|
||||
|
||||
// GetLatestReleaseV2 gets the latest release
|
||||
// GET /api/v2/repos/{owner}/{repo}/releases/latest
|
||||
func GetLatestReleaseV2(ctx *context.APIContext) {
|
||||
repo := ctx.Repo.Repository
|
||||
if repo == nil {
|
||||
ctx.APIErrorNotFound("Repository not found")
|
||||
return
|
||||
}
|
||||
|
||||
channel := ctx.FormString("channel")
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
|
||||
opts := repo_model.FindReleasesOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
},
|
||||
RepoID: repo.ID,
|
||||
IncludeDrafts: false,
|
||||
IncludeTags: false,
|
||||
}
|
||||
|
||||
if channel == "stable" {
|
||||
opts.IsPreRelease = optional.Some(false)
|
||||
}
|
||||
|
||||
releases, err := db.Find[repo_model.Release](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(releases) == 0 {
|
||||
ctx.APIErrorNotFound("No releases found")
|
||||
return
|
||||
}
|
||||
|
||||
release := releases[0]
|
||||
if err := repo_model.GetReleaseAttachments(ctx, release); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convertToAPIRelease(repo, release))
|
||||
}
|
||||
|
||||
// convertToAPIRelease converts a repo_model.Release to api.Release
|
||||
func convertToAPIRelease(repo *repo_model.Repository, release *repo_model.Release) *api.Release {
|
||||
assets := make([]*api.Attachment, 0, len(release.Attachments))
|
||||
for _, attachment := range release.Attachments {
|
||||
assets = append(assets, &api.Attachment{
|
||||
ID: attachment.ID,
|
||||
Name: attachment.Name,
|
||||
Size: attachment.Size,
|
||||
DownloadCount: attachment.DownloadCount,
|
||||
Created: attachment.CreatedUnix.AsTime(),
|
||||
UUID: attachment.UUID,
|
||||
DownloadURL: fmt.Sprintf("%s%s/%s/releases/download/%s/%s",
|
||||
setting.AppURL,
|
||||
repo.OwnerName,
|
||||
repo.Name,
|
||||
release.TagName,
|
||||
attachment.Name,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return &api.Release{
|
||||
ID: release.ID,
|
||||
TagName: release.TagName,
|
||||
Target: release.Target,
|
||||
Title: release.Title,
|
||||
Note: release.Note,
|
||||
URL: release.HTMLURL(),
|
||||
HTMLURL: release.HTMLURL(),
|
||||
TarURL: release.TarURL(),
|
||||
ZipURL: release.ZipURL(),
|
||||
IsDraft: release.IsDraft,
|
||||
IsPrerelease: release.IsPrerelease,
|
||||
CreatedAt: release.CreatedUnix.AsTime(),
|
||||
PublishedAt: release.CreatedUnix.AsTime(),
|
||||
Attachments: assets,
|
||||
}
|
||||
}
|
||||
|
||||
// repoAssignmentWithPublicAccess is a variant of repoAssignment that allows
|
||||
// public access for repos with public_landing or public_releases enabled
|
||||
func repoAssignmentWithPublicAccess() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
ownerName := ctx.PathParam("owner")
|
||||
repoName := ctx.PathParam("repo")
|
||||
|
||||
// Get owner
|
||||
var owner *user_model.User
|
||||
var err error
|
||||
|
||||
if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, ownerName) {
|
||||
owner = ctx.Doer
|
||||
} else {
|
||||
owner, err = user_model.GetUserByName(ctx, ownerName)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.Repo.Owner = owner
|
||||
ctx.ContextUser = owner
|
||||
|
||||
// Get repository
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetRepositoryByName", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
repo.Owner = owner
|
||||
ctx.Repo.Repository = repo
|
||||
|
||||
// Check if repo is public
|
||||
if !repo.IsPrivate {
|
||||
// Get permissions for public repo
|
||||
ctx.Repo.Permission, _ = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
return // Public repo, allow access
|
||||
}
|
||||
|
||||
// For private repos, check if public landing/releases is enabled
|
||||
if pages_service.HasPublicLanding(ctx, repo) || pages_service.HasPublicReleases(ctx, repo) {
|
||||
// Allow read-only access for public landing/releases
|
||||
ctx.Repo.Permission, _ = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, require authentication
|
||||
if !ctx.IsSigned {
|
||||
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
||||
return
|
||||
}
|
||||
|
||||
// Check permission
|
||||
ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
ctx.APIErrorNotFound("HasAnyUnitAccessOrPublicAccess")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
134
routers/api/v2/runners.go
Normal file
134
routers/api/v2/runners.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 MarketAlly. 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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2016 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package routers
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pages
|
||||
@@ -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 MarketAlly. 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)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
@@ -168,12 +168,22 @@ func Releases(ctx *context.Context) {
|
||||
writeAccess := ctx.Repo.CanWrite(unit.TypeReleases)
|
||||
ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived
|
||||
|
||||
releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{
|
||||
// Handle archived filter
|
||||
showArchived := ctx.FormString("archived") == "true"
|
||||
ctx.Data["ShowArchived"] = showArchived
|
||||
|
||||
findOpts := &repo_model.FindReleasesOptions{
|
||||
ListOptions: listOptions,
|
||||
// only show draft releases for users who can write, read-only users shouldn't see draft releases.
|
||||
IncludeDrafts: writeAccess,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
})
|
||||
IncludeDrafts: writeAccess,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
IncludeArchived: showArchived,
|
||||
}
|
||||
if !showArchived {
|
||||
findOpts.IsArchived = optional.Some(false)
|
||||
}
|
||||
|
||||
releases, err := getReleaseInfos(ctx, findOpts)
|
||||
if err != nil {
|
||||
ctx.ServerError("getReleaseInfos", err)
|
||||
return
|
||||
@@ -701,3 +711,47 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) {
|
||||
|
||||
redirect()
|
||||
}
|
||||
|
||||
// ArchiveReleasePost archives a release
|
||||
func ArchiveReleasePost(ctx *context.Context) {
|
||||
releaseID := ctx.PathParamInt64("id")
|
||||
rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, releaseID)
|
||||
if err != nil {
|
||||
if repo_model.IsErrReleaseNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetReleaseForRepoByID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := release_service.ArchiveRelease(ctx, rel); err != nil {
|
||||
ctx.ServerError("ArchiveRelease", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(string(ctx.Tr("repo.release.archive")) + ": " + rel.Title)
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
|
||||
}
|
||||
|
||||
// UnarchiveReleasePost unarchives a release
|
||||
func UnarchiveReleasePost(ctx *context.Context) {
|
||||
releaseID := ctx.PathParamInt64("id")
|
||||
rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, releaseID)
|
||||
if err != nil {
|
||||
if repo_model.IsErrReleaseNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetReleaseForRepoByID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := release_service.UnarchiveRelease(ctx, rel); err != nil {
|
||||
ctx.ServerError("UnarchiveRelease", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(string(ctx.Tr("repo.release.unarchive")) + ": " + rel.Title)
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/releases?archived=true")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"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"
|
||||
@@ -199,6 +206,14 @@ func RunnersEdit(ctx *context.Context) {
|
||||
|
||||
ctx.Data["Runner"] = runner
|
||||
|
||||
// Parse runner capabilities if available
|
||||
if runner.CapabilitiesJSON != "" {
|
||||
var caps structs.RunnerCapability
|
||||
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps); err == nil {
|
||||
ctx.Data["RunnerCapabilities"] = &caps
|
||||
}
|
||||
}
|
||||
|
||||
opts := actions_model.FindTaskOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
@@ -285,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)
|
||||
@@ -358,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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
@@ -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":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
@@ -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}))
|
||||
|
||||
@@ -1416,6 +1462,8 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Post("/delete", repo.DeleteRelease)
|
||||
m.Post("/attachments", repo.UploadReleaseAttachment)
|
||||
m.Post("/attachments/remove", repo.DeleteAttachment)
|
||||
m.Post("/{id}/archive", repo.ArchiveReleasePost)
|
||||
m.Post("/{id}/unarchive", repo.UnarchiveReleasePost)
|
||||
}, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter)
|
||||
m.Group("/releases", func() {
|
||||
m.Get("/edit/*", repo.EditRelease)
|
||||
@@ -1668,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package gitea provides a Go SDK for the Gitea API.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitea
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attachment
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convert
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// ToAPIRelease convert a repo_model.Release to api.Release
|
||||
func ToAPIRelease(ctx context.Context, repo *repo_model.Repository, r *repo_model.Release) *api.Release {
|
||||
return &api.Release{
|
||||
rel := &api.Release{
|
||||
ID: r.ID,
|
||||
TagName: r.TagName,
|
||||
Target: r.Target,
|
||||
@@ -29,5 +29,11 @@ func ToAPIRelease(ctx context.Context, repo *repo_model.Repository, r *repo_mode
|
||||
PublishedAt: r.CreatedUnix.AsTime(),
|
||||
Publisher: ToUser(ctx, r.Publisher, nil),
|
||||
Attachments: ToAPIAttachments(repo, r.Attachments),
|
||||
IsArchived: r.IsArchived,
|
||||
}
|
||||
if r.IsArchived && r.ArchivedUnix > 0 {
|
||||
archivedAt := r.ArchivedUnix.AsTime()
|
||||
rel.ArchivedAt = &archivedAt
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cron
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forms
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pages
|
||||
@@ -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 {
|
||||
@@ -252,3 +282,23 @@ func GetRepoByPagesDomain(ctx context.Context, domainName string) (*repo_model.R
|
||||
|
||||
return repo_model.GetRepositoryByID(ctx, domain.RepoID)
|
||||
}
|
||||
|
||||
// HasPublicLanding checks if a repository has public landing enabled
|
||||
// This allows private repos to have a public-facing landing page
|
||||
func HasPublicLanding(ctx context.Context, repo *repo_model.Repository) bool {
|
||||
config, err := GetPagesConfig(ctx, repo)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return config.Enabled && config.PublicLanding
|
||||
}
|
||||
|
||||
// HasPublicReleases checks if a repository has public releases enabled
|
||||
// This allows private repos to have publicly accessible releases
|
||||
func HasPublicReleases(ctx context.Context, repo *repo_model.Repository) bool {
|
||||
config, err := GetPagesConfig(ctx, repo)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return config.Enabled && config.Advanced.PublicReleases
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package release
|
||||
@@ -422,6 +422,30 @@ func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *re
|
||||
return nil
|
||||
}
|
||||
|
||||
// ArchiveRelease archives a release by setting IsArchived to true
|
||||
func ArchiveRelease(ctx context.Context, rel *repo_model.Release) error {
|
||||
if rel.IsArchived {
|
||||
return nil // Already archived
|
||||
}
|
||||
|
||||
rel.IsArchived = true
|
||||
rel.ArchivedUnix = timeutil.TimeStampNow()
|
||||
|
||||
return repo_model.UpdateRelease(ctx, rel)
|
||||
}
|
||||
|
||||
// UnarchiveRelease unarchives a release by setting IsArchived to false
|
||||
func UnarchiveRelease(ctx context.Context, rel *repo_model.Release) error {
|
||||
if !rel.IsArchived {
|
||||
return nil // Not archived
|
||||
}
|
||||
|
||||
rel.IsArchived = false
|
||||
rel.ArchivedUnix = 0
|
||||
|
||||
return repo_model.UpdateRelease(ctx, rel)
|
||||
}
|
||||
|
||||
// Init start release service
|
||||
func Init() error {
|
||||
return initTagSyncQueue(graceful.GetManager().ShutdownContext())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2024 The Gitea Authors and MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user