mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
442 lines
12 KiB
Go
442 lines
12 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
auth_model "github.com/hanzoai/git/models/auth"
|
|
"github.com/hanzoai/git/models/db"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
"github.com/hanzoai/git/models/unit"
|
|
"github.com/hanzoai/git/modules/git"
|
|
api "github.com/hanzoai/git/modules/structs"
|
|
"github.com/hanzoai/git/modules/web"
|
|
"github.com/hanzoai/git/routers/api/v1/utils"
|
|
"github.com/hanzoai/git/services/context"
|
|
"github.com/hanzoai/git/services/convert"
|
|
release_service "github.com/hanzoai/git/services/release"
|
|
)
|
|
|
|
func canAccessReleaseDraft(ctx *context.APIContext) bool {
|
|
if !ctx.IsSigned || !ctx.Repo.Permission.CanWrite(unit.TypeReleases) {
|
|
return false
|
|
}
|
|
if ctx.Data["IsApiToken"] != true {
|
|
// not API token request, the request is from a user session with write access
|
|
return true
|
|
}
|
|
// the request is from an access token with scope
|
|
scope := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
|
|
requiredScopes := auth_model.GetRequiredScopes(auth_model.Write, auth_model.AccessTokenScopeCategoryRepository)
|
|
allow, _ := scope.HasScope(requiredScopes...) // err (invalid token) can be safely ignored
|
|
return allow
|
|
}
|
|
|
|
// GetRelease get a single release of a repository
|
|
func GetRelease(ctx *context.APIContext) {
|
|
// swagger:operation GET /repos/{owner}/{repo}/releases/{id} repository repoGetRelease
|
|
// ---
|
|
// summary: Get 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 get
|
|
// 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 release.IsDraft && !canAccessReleaseDraft(ctx) { // only the users with write access can see draft releases
|
|
ctx.APIErrorNotFound()
|
|
return
|
|
}
|
|
|
|
if err := release.LoadAttributes(ctx); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
|
|
}
|
|
|
|
// GetLatestRelease gets the most recent non-prerelease, non-draft release of a repository, sorted by created_at
|
|
func GetLatestRelease(ctx *context.APIContext) {
|
|
// swagger:operation GET /repos/{owner}/{repo}/releases/latest repository repoGetLatestRelease
|
|
// ---
|
|
// summary: Gets the most recent non-prerelease, non-draft release of a repository, sorted by created_at
|
|
// 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
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/Release"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID)
|
|
if err != nil && !repo_model.IsErrReleaseNotExist(err) {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
if err != nil && repo_model.IsErrReleaseNotExist(err) ||
|
|
release.IsTag || release.RepoID != ctx.Repo.Repository.ID {
|
|
ctx.APIErrorNotFound()
|
|
return
|
|
}
|
|
|
|
if err := release.LoadAttributes(ctx); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
|
|
}
|
|
|
|
// ListReleases list a repository's releases
|
|
func ListReleases(ctx *context.APIContext) {
|
|
// swagger:operation GET /repos/{owner}/{repo}/releases repository repoListReleases
|
|
// ---
|
|
// summary: List a repo's releases
|
|
// 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: draft
|
|
// in: query
|
|
// description: filter (exclude / include) drafts, if you don't have repo write access none will show
|
|
// type: boolean
|
|
// - name: pre-release
|
|
// in: query
|
|
// description: filter (exclude / include) pre-releases
|
|
// type: boolean
|
|
// - name: page
|
|
// in: query
|
|
// description: page number of results to return (1-based)
|
|
// type: integer
|
|
// - name: limit
|
|
// in: query
|
|
// description: page size of results
|
|
// type: integer
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/ReleaseList"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
listOptions := utils.GetListOptions(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
opts := repo_model.FindReleasesOptions{
|
|
ListOptions: listOptions,
|
|
IncludeDrafts: canAccessReleaseDraft(ctx),
|
|
IncludeTags: false,
|
|
IsDraft: ctx.FormOptionalBool("draft"),
|
|
IsPreRelease: ctx.FormOptionalBool("pre-release"),
|
|
RepoID: ctx.Repo.Repository.ID,
|
|
}
|
|
|
|
releases, err := db.Find[repo_model.Release](ctx, opts)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
rels := make([]*api.Release, len(releases))
|
|
for i, release := range releases {
|
|
if err := release.LoadAttributes(ctx); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
rels[i] = convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)
|
|
}
|
|
|
|
filteredCount, err := db.Count[repo_model.Release](ctx, opts)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.SetLinkHeader(filteredCount, listOptions.PageSize)
|
|
ctx.SetTotalCountHeader(filteredCount)
|
|
ctx.JSON(http.StatusOK, rels)
|
|
}
|
|
|
|
// CreateRelease create a release
|
|
func CreateRelease(ctx *context.APIContext) {
|
|
// swagger:operation POST /repos/{owner}/{repo}/releases repository repoCreateRelease
|
|
// ---
|
|
// summary: Create a release
|
|
// consumes:
|
|
// - application/json
|
|
// 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: body
|
|
// in: body
|
|
// schema:
|
|
// "$ref": "#/definitions/CreateReleaseOption"
|
|
// responses:
|
|
// "201":
|
|
// "$ref": "#/responses/Release"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "409":
|
|
// "$ref": "#/responses/error"
|
|
// "422":
|
|
// "$ref": "#/responses/validationError"
|
|
|
|
form := web.GetForm(ctx).(*api.CreateReleaseOption)
|
|
if ctx.Repo.Repository.IsEmpty {
|
|
ctx.APIError(http.StatusUnprocessableEntity, "repo is empty")
|
|
return
|
|
}
|
|
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName)
|
|
if err != nil {
|
|
if !repo_model.IsErrReleaseNotExist(err) {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
// If target is not provided use default branch
|
|
if len(form.Target) == 0 {
|
|
form.Target = ctx.Repo.Repository.DefaultBranch
|
|
}
|
|
rel = &repo_model.Release{
|
|
RepoID: ctx.Repo.Repository.ID,
|
|
PublisherID: ctx.Doer.ID,
|
|
Publisher: ctx.Doer,
|
|
TagName: form.TagName,
|
|
Target: form.Target,
|
|
Title: form.Title,
|
|
Note: form.Note,
|
|
IsDraft: form.IsDraft,
|
|
IsPrerelease: form.IsPrerelease,
|
|
IsTag: false,
|
|
Repo: ctx.Repo.Repository,
|
|
}
|
|
// GitHub doesn't have "tag_message", GitLab has: https://docs.gitlab.com/api/releases/#create-a-release
|
|
// It doesn't need to be the same as the "release note"
|
|
if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, nil, form.TagMessage); err != nil {
|
|
if repo_model.IsErrReleaseAlreadyExist(err) {
|
|
ctx.APIError(http.StatusConflict, err.Error())
|
|
} else if release_service.IsErrProtectedTagName(err) {
|
|
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
|
} else if git.IsErrNotExist(err) {
|
|
ctx.APIError(http.StatusNotFound, "target not found")
|
|
} else {
|
|
ctx.APIErrorInternal(err)
|
|
}
|
|
return
|
|
}
|
|
} else {
|
|
if !rel.IsTag {
|
|
ctx.APIError(http.StatusConflict, "Release is has no Tag")
|
|
return
|
|
}
|
|
|
|
rel.Title = form.Title
|
|
rel.Note = form.Note
|
|
rel.IsDraft = form.IsDraft
|
|
rel.IsPrerelease = form.IsPrerelease
|
|
rel.PublisherID = ctx.Doer.ID
|
|
rel.IsTag = false
|
|
rel.Repo = ctx.Repo.Repository
|
|
rel.Publisher = ctx.Doer
|
|
rel.Target = form.Target
|
|
|
|
if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
}
|
|
ctx.JSON(http.StatusCreated, convert.ToAPIRelease(ctx, ctx.Repo.Repository, rel))
|
|
}
|
|
|
|
// EditRelease edit a release
|
|
func EditRelease(ctx *context.APIContext) {
|
|
// swagger:operation PATCH /repos/{owner}/{repo}/releases/{id} repository repoEditRelease
|
|
// ---
|
|
// summary: Update a release
|
|
// consumes:
|
|
// - application/json
|
|
// 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 edit
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: body
|
|
// in: body
|
|
// schema:
|
|
// "$ref": "#/definitions/EditReleaseOption"
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/Release"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
|
|
form := web.GetForm(ctx).(*api.EditReleaseOption)
|
|
id := ctx.PathParamInt64("id")
|
|
rel, 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) || rel.IsTag {
|
|
ctx.APIErrorNotFound()
|
|
return
|
|
}
|
|
|
|
if len(form.TagName) > 0 {
|
|
rel.TagName = form.TagName
|
|
}
|
|
if len(form.Target) > 0 {
|
|
rel.Target = form.Target
|
|
}
|
|
if len(form.Title) > 0 {
|
|
rel.Title = form.Title
|
|
}
|
|
if len(form.Note) > 0 {
|
|
rel.Note = form.Note
|
|
}
|
|
if form.IsDraft != nil {
|
|
rel.IsDraft = *form.IsDraft
|
|
}
|
|
if form.IsPrerelease != nil {
|
|
rel.IsPrerelease = *form.IsPrerelease
|
|
}
|
|
if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
// reload data from database
|
|
rel, err = repo_model.GetReleaseByID(ctx, id)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
if err := rel.LoadAttributes(ctx); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, rel))
|
|
}
|
|
|
|
// DeleteRelease delete a release from a repository
|
|
func DeleteRelease(ctx *context.APIContext) {
|
|
// swagger:operation DELETE /repos/{owner}/{repo}/releases/{id} repository repoDeleteRelease
|
|
// ---
|
|
// summary: Delete a release
|
|
// 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 delete
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "204":
|
|
// "$ref": "#/responses/empty"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "422":
|
|
// "$ref": "#/responses/validationError"
|
|
|
|
id := ctx.PathParamInt64("id")
|
|
rel, 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) || rel.IsTag {
|
|
ctx.APIErrorNotFound()
|
|
return
|
|
}
|
|
if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, false); err != nil {
|
|
if release_service.IsErrProtectedTagName(err) {
|
|
ctx.APIError(http.StatusUnprocessableEntity, "user not allowed to delete protected tag")
|
|
return
|
|
}
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
ctx.Status(http.StatusNoContent)
|
|
}
|