Some checks are pending
Build and Release / Lint and Test (push) Waiting to run
Build and Release / Build Binaries (amd64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, linux) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, windows) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, linux) (push) Blocked by required conditions
- Add runner capability discovery API (v2) for AI tools to query before writing workflows - Add release archive feature with filter toggle UI - Add GitHub Actions compatibility layer with action aliasing - Store runner capabilities JSON from act_runner Declare calls - Add migrations for release archive and runner capabilities fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
257 lines
7.0 KiB
Go
257 lines
7.0 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// Package v2 Gitea API v2
|
|
//
|
|
// This is the v2 API with improved error handling, batch operations,
|
|
// and AI-friendly endpoints. It uses structured error codes for
|
|
// machine-readable error handling.
|
|
//
|
|
// Schemes: https, http
|
|
// License: MIT http://opensource.org/licenses/MIT
|
|
//
|
|
// Consumes:
|
|
// - application/json
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
// - application/x-ndjson
|
|
//
|
|
// swagger:meta
|
|
package v2
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
access_model "code.gitea.io/gitea/models/perm/access"
|
|
auth_model "code.gitea.io/gitea/models/auth"
|
|
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"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
api "code.gitea.io/gitea/modules/structs"
|
|
"code.gitea.io/gitea/modules/util"
|
|
"code.gitea.io/gitea/modules/web"
|
|
"code.gitea.io/gitea/modules/web/middleware"
|
|
"code.gitea.io/gitea/routers/common"
|
|
"code.gitea.io/gitea/services/auth"
|
|
"code.gitea.io/gitea/services/context"
|
|
|
|
"github.com/go-chi/cors"
|
|
)
|
|
|
|
// Routes registers all v2 API routes to web application.
|
|
func Routes() *web.Router {
|
|
m := web.NewRouter()
|
|
|
|
m.Use(middleware.RequestID())
|
|
m.Use(middleware.RateLimitInfo())
|
|
m.Use(securityHeaders())
|
|
|
|
// Idempotency middleware for POST/PUT/PATCH requests
|
|
idempotencyMiddleware := idempotency.NewMiddleware(idempotency.GetDefaultStore())
|
|
m.Use(idempotencyMiddleware.Handler)
|
|
|
|
if setting.CORSConfig.Enabled {
|
|
m.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: setting.CORSConfig.AllowDomain,
|
|
AllowedMethods: setting.CORSConfig.Methods,
|
|
AllowCredentials: setting.CORSConfig.AllowCredentials,
|
|
AllowedHeaders: append([]string{"Authorization", "X-Gitea-OTP"}, setting.CORSConfig.Headers...),
|
|
MaxAge: int(setting.CORSConfig.MaxAge.Seconds()),
|
|
}))
|
|
}
|
|
|
|
m.Use(context.APIContexter())
|
|
|
|
// Get user from session if logged in
|
|
m.Use(apiAuth(buildAuthGroup()))
|
|
|
|
m.Group("", func() {
|
|
// Public endpoints (no auth required)
|
|
m.Get("/version", Version)
|
|
|
|
// API Documentation (Scalar)
|
|
m.Get("/docs", DocsScalar)
|
|
m.Get("/swagger.json", SwaggerJSON)
|
|
|
|
// Health check endpoints
|
|
m.Group("/health", func() {
|
|
m.Get("", HealthCheck)
|
|
m.Get("/live", LivenessCheck)
|
|
m.Get("/ready", ReadinessCheck)
|
|
m.Get("/component/{component}", ComponentHealthCheck)
|
|
})
|
|
|
|
// Operation progress endpoints (SSE)
|
|
m.Group("/operations", func() {
|
|
m.Get("/{id}/progress", OperationProgress)
|
|
m.Get("/{id}", GetOperation)
|
|
m.Delete("/{id}", CancelOperation)
|
|
})
|
|
|
|
// Authenticated endpoints
|
|
m.Group("", func() {
|
|
// User info
|
|
m.Get("/user", GetAuthenticatedUser)
|
|
|
|
// Batch operations - efficient bulk requests
|
|
m.Group("/batch", func() {
|
|
m.Post("/files", BatchGetFiles)
|
|
m.Post("/repos", BatchGetRepos)
|
|
})
|
|
|
|
// Streaming endpoints - NDJSON responses
|
|
m.Group("/stream", func() {
|
|
m.Post("/files", StreamFiles)
|
|
m.Post("/commits", StreamCommits)
|
|
m.Post("/issues", StreamIssues)
|
|
})
|
|
|
|
// AI context endpoints - rich context for AI tools
|
|
m.Group("/ai", func() {
|
|
m.Post("/repo/summary", GetAIRepoSummary)
|
|
m.Post("/repo/navigation", GetAINavigation)
|
|
m.Post("/issue/context", GetAIIssueContext)
|
|
})
|
|
}, reqToken())
|
|
|
|
// Wiki v2 API - repository wiki endpoints
|
|
m.Group("/repos/{owner}/{repo}/wiki", func() {
|
|
// Public read endpoints (access checked in handler)
|
|
m.Get("/pages", ListWikiPagesV2)
|
|
m.Get("/pages/{pageName}", GetWikiPageV2)
|
|
m.Get("/pages/{pageName}/revisions", GetWikiPageRevisionsV2)
|
|
m.Get("/search", SearchWikiV2)
|
|
m.Get("/graph", GetWikiGraphV2)
|
|
m.Get("/stats", GetWikiStatsV2)
|
|
|
|
// Write endpoints require authentication
|
|
m.Group("", func() {
|
|
m.Post("/pages", web.Bind(api.CreateWikiPageV2Option{}), CreateWikiPageV2)
|
|
m.Put("/pages/{pageName}", web.Bind(api.UpdateWikiPageV2Option{}), UpdateWikiPageV2)
|
|
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.Post("/workflows/validate", repoAssignment(), reqToken(), web.Bind(api.WorkflowValidationRequest{}), ValidateWorkflow)
|
|
})
|
|
})
|
|
|
|
return m
|
|
}
|
|
|
|
func securityHeaders() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
// CORS preflight
|
|
if req.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
next.ServeHTTP(w, req)
|
|
})
|
|
}
|
|
}
|
|
|
|
func buildAuthGroup() *auth.Group {
|
|
group := auth.NewGroup(
|
|
&auth.OAuth2{},
|
|
&auth.HTTPSign{},
|
|
&auth.Basic{},
|
|
)
|
|
if setting.Service.EnableReverseProxyAuthAPI {
|
|
group.Add(&auth.ReverseProxy{})
|
|
}
|
|
if setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext()) {
|
|
group.Add(&auth.SSPI{})
|
|
}
|
|
return group
|
|
}
|
|
|
|
func apiAuth(authMethod auth.Method) func(*context.APIContext) {
|
|
return func(ctx *context.APIContext) {
|
|
ar, err := common.AuthShared(ctx.Base, nil, authMethod)
|
|
if err != nil {
|
|
msg, ok := auth.ErrAsUserAuthMessage(err)
|
|
msg = util.Iif(ok, msg, "invalid username, password or token")
|
|
ctx.APIErrorWithCodeAndMessage(apierrors.AuthInvalidCredentials, msg)
|
|
return
|
|
}
|
|
ctx.Doer = ar.Doer
|
|
ctx.IsSigned = ar.Doer != nil
|
|
ctx.IsBasicAuth = ar.IsBasicAuth
|
|
}
|
|
}
|
|
|
|
// reqToken requires authentication
|
|
func reqToken() func(ctx *context.APIContext) {
|
|
return func(ctx *context.APIContext) {
|
|
if !ctx.IsSigned {
|
|
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|