Merge branch 'go-gitea:main' into main

This commit is contained in:
badhezi 2025-04-21 22:30:05 +03:00 committed by GitHub
commit 104eecc710
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 945 additions and 466 deletions

View File

@ -2439,6 +2439,8 @@ LEVEL = Info
;DEFAULT_GIT_TREES_PER_PAGE = 1000 ;DEFAULT_GIT_TREES_PER_PAGE = 1000
;; Default max size of a blob returned by the blobs API (default is 10MiB) ;; Default max size of a blob returned by the blobs API (default is 10MiB)
;DEFAULT_MAX_BLOB_SIZE = 10485760 ;DEFAULT_MAX_BLOB_SIZE = 10485760
;; Default max combined size of all blobs returned by the files API (default is 100MiB)
;DEFAULT_MAX_RESPONSE_SIZE = 104857600
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -12,8 +12,14 @@ import (
// IssueLockOptions defines options for locking and/or unlocking an issue/PR // IssueLockOptions defines options for locking and/or unlocking an issue/PR
type IssueLockOptions struct { type IssueLockOptions struct {
Doer *user_model.User Doer *user_model.User
Issue *Issue Issue *Issue
// Reason is the doer-provided comment message for the locked issue
// GitHub doesn't support changing the "reasons" by config file, so GitHub has pre-defined "reason" enum values.
// Gitea is not like GitHub, it allows site admin to define customized "reasons" in the config file.
// So the API caller might not know what kind of "reasons" are valid, and the customized reasons are not translatable.
// To make things clear and simple: doer have the chance to use any reason they like, we do not do validation.
Reason string Reason string
} }

View File

@ -85,17 +85,3 @@ func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error)
} }
return strings.TrimSpace(stdout.String()), nil return strings.TrimSpace(stdout.String()), nil
} }
// GetRefType gets the type of the ref based on the string
func (repo *Repository) GetRefType(ref string) ObjectType {
if repo.IsTagExist(ref) {
return ObjectTag
} else if repo.IsBranchExist(ref) {
return ObjectBranch
} else if repo.IsCommitExist(ref) {
return ObjectCommit
} else if _, err := repo.GetBlob(ref); err == nil {
return ObjectBlob
}
return ObjectType("invalid")
}

View File

@ -18,6 +18,7 @@ var API = struct {
DefaultPagingNum int DefaultPagingNum int
DefaultGitTreesPerPage int DefaultGitTreesPerPage int
DefaultMaxBlobSize int64 DefaultMaxBlobSize int64
DefaultMaxResponseSize int64
}{ }{
EnableSwagger: true, EnableSwagger: true,
SwaggerURL: "", SwaggerURL: "",
@ -25,6 +26,7 @@ var API = struct {
DefaultPagingNum: 30, DefaultPagingNum: 30,
DefaultGitTreesPerPage: 1000, DefaultGitTreesPerPage: 1000,
DefaultMaxBlobSize: 10485760, DefaultMaxBlobSize: 10485760,
DefaultMaxResponseSize: 104857600,
} }
func loadAPIFrom(rootCfg ConfigProvider) { func loadAPIFrom(rootCfg ConfigProvider) {

View File

@ -5,9 +5,9 @@ package structs
// GitBlobResponse represents a git blob // GitBlobResponse represents a git blob
type GitBlobResponse struct { type GitBlobResponse struct {
Content string `json:"content"` Content *string `json:"content"`
Encoding string `json:"encoding"` Encoding *string `json:"encoding"`
URL string `json:"url"` URL string `json:"url"`
SHA string `json:"sha"` SHA string `json:"sha"`
Size int64 `json:"size"` Size int64 `json:"size"`
} }

View File

@ -266,3 +266,8 @@ type IssueMeta struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Name string `json:"repo"` Name string `json:"repo"`
} }
// LockIssueOption options to lock an issue
type LockIssueOption struct {
Reason string `json:"lock_reason"`
}

View File

@ -176,3 +176,8 @@ type FileDeleteResponse struct {
Commit *FileCommitResponse `json:"commit"` Commit *FileCommitResponse `json:"commit"`
Verification *PayloadCommitVerification `json:"verification"` Verification *PayloadCommitVerification `json:"verification"`
} }
// GetFilesOptions options for retrieving metadate and content of multiple files
type GetFilesOptions struct {
Files []string `json:"files" binding:"Required"`
}

View File

@ -26,6 +26,7 @@ type GeneralAPISettings struct {
DefaultPagingNum int `json:"default_paging_num"` DefaultPagingNum int `json:"default_paging_num"`
DefaultGitTreesPerPage int `json:"default_git_trees_per_page"` DefaultGitTreesPerPage int `json:"default_git_trees_per_page"`
DefaultMaxBlobSize int64 `json:"default_max_blob_size"` DefaultMaxBlobSize int64 `json:"default_max_blob_size"`
DefaultMaxResponseSize int64 `json:"default_max_response_size"`
} }
// GeneralAttachmentSettings contains global Attachment settings exposed by API // GeneralAttachmentSettings contains global Attachment settings exposed by API

View File

@ -1681,7 +1681,6 @@ issues.pin_comment = "pinned this %s"
issues.unpin_comment = "unpinned this %s" issues.unpin_comment = "unpinned this %s"
issues.lock = Lock conversation issues.lock = Lock conversation
issues.unlock = Unlock conversation issues.unlock = Unlock conversation
issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.
issues.lock_duplicate = An issue cannot be locked twice. issues.lock_duplicate = An issue cannot be locked twice.
issues.unlock_error = Cannot unlock an issue that is not locked. issues.unlock_error = Cannot unlock an issue that is not locked.
issues.lock_with_reason = "locked as <strong>%s</strong> and limited conversation to collaborators %s" issues.lock_with_reason = "locked as <strong>%s</strong> and limited conversation to collaborators %s"

View File

@ -1389,14 +1389,17 @@ func Routes() *web.Router {
m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, repo.ApplyDiffPatch) m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, repo.ApplyDiffPatch)
m.Group("/contents", func() { m.Group("/contents", func() {
m.Get("", repo.GetContentsList) m.Get("", repo.GetContentsList)
m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.ChangeFiles)
m.Get("/*", repo.GetContents) m.Get("/*", repo.GetContents)
m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.ChangeFiles)
m.Group("/*", func() { m.Group("/*", func() {
m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.CreateFile) m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.CreateFile)
m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.UpdateFile) m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.UpdateFile)
m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile) m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile)
}, reqToken()) }, reqToken())
}, reqRepoReader(unit.TypeCode)) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()).
Get(repo.GetFileContentsGet).
Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // POST method requires "write" permission, so we also support "GET" method above
m.Get("/signing-key.gpg", misc.SigningKey) m.Get("/signing-key.gpg", misc.SigningKey)
m.Group("/topics", func() { m.Group("/topics", func() {
m.Combo("").Get(repo.ListTopics). m.Combo("").Get(repo.ListTopics).
@ -1530,6 +1533,11 @@ func Routes() *web.Router {
Delete(reqToken(), reqAdmin(), repo.UnpinIssue) Delete(reqToken(), reqAdmin(), repo.UnpinIssue)
m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin) m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin)
}) })
m.Group("/lock", func() {
m.Combo("").
Put(bind(api.LockIssueOption{}), repo.LockIssue).
Delete(repo.UnlockIssue)
}, reqToken(), reqAdmin())
}) })
}, mustEnableIssuesOrPulls) }, mustEnableIssuesOrPulls)
m.Group("/labels", func() { m.Group("/labels", func() {

View File

@ -16,16 +16,18 @@ import (
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo" 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/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
api "code.gitea.io/gitea/modules/structs" 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"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
@ -375,7 +377,7 @@ func GetEditorconfig(ctx *context.APIContext) {
// required: true // required: true
// - name: ref // - name: ref
// in: query // in: query
// description: "The name of the commit/branch/tag. Default the repositorys default branch (usually master)" // description: "The name of the commit/branch/tag. Default to the repositorys default branch."
// type: string // type: string
// required: false // required: false
// responses: // responses:
@ -410,11 +412,6 @@ func canWriteFiles(ctx *context.APIContext, branch string) bool {
!ctx.Repo.Repository.IsArchived !ctx.Repo.Repository.IsArchived
} }
// canReadFiles returns true if repository is readable and user has proper access level.
func canReadFiles(r *context.Repository) bool {
return r.Permission.CanRead(unit.TypeCode)
}
func base64Reader(s string) (io.ReadSeeker, error) { func base64Reader(s string) (io.ReadSeeker, error) {
b, err := base64.StdEncoding.DecodeString(s) b, err := base64.StdEncoding.DecodeString(s)
if err != nil { if err != nil {
@ -894,6 +891,17 @@ func DeleteFile(ctx *context.APIContext) {
} }
} }
func resolveRefCommit(ctx *context.APIContext, ref string, minCommitIDLen ...int) *utils.RefCommit {
ref = util.IfZero(ref, ctx.Repo.Repository.DefaultBranch)
refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ref, minCommitIDLen...)
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else if err != nil {
ctx.APIErrorInternal(err)
}
return refCommit
}
// GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir // GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir
func GetContents(ctx *context.APIContext) { func GetContents(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetContents // swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetContents
@ -919,7 +927,7 @@ func GetContents(ctx *context.APIContext) {
// required: true // required: true
// - name: ref // - name: ref
// in: query // in: query
// description: "The name of the commit/branch/tag. Default the repositorys default branch (usually master)" // description: "The name of the commit/branch/tag. Default to the repositorys default branch."
// type: string // type: string
// required: false // required: false
// responses: // responses:
@ -928,18 +936,13 @@ func GetContents(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !canReadFiles(ctx.Repo) { treePath := ctx.PathParam("*")
ctx.APIErrorInternal(repo_model.ErrUserDoesNotHaveAccessToRepo{ refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref"))
UserID: ctx.Doer.ID, if ctx.Written() {
RepoName: ctx.Repo.Repository.LowerName,
})
return return
} }
treePath := ctx.PathParam("*") if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath); err != nil {
ref := ctx.FormTrim("ref")
if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, treePath, ref); err != nil {
if git.IsErrNotExist(err) { if git.IsErrNotExist(err) {
ctx.APIErrorNotFound("GetContentsOrList", err) ctx.APIErrorNotFound("GetContentsOrList", err)
return return
@ -970,7 +973,7 @@ func GetContentsList(ctx *context.APIContext) {
// required: true // required: true
// - name: ref // - name: ref
// in: query // in: query
// description: "The name of the commit/branch/tag. Default the repositorys default branch (usually master)" // description: "The name of the commit/branch/tag. Default to the repositorys default branch."
// type: string // type: string
// required: false // required: false
// responses: // responses:
@ -982,3 +985,102 @@ func GetContentsList(ctx *context.APIContext) {
// same as GetContents(), this function is here because swagger fails if path is empty in GetContents() interface // same as GetContents(), this function is here because swagger fails if path is empty in GetContents() interface
GetContents(ctx) GetContents(ctx)
} }
func GetFileContentsGet(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/file-contents repository repoGetFileContents
// ---
// summary: Get the metadata and contents of requested files
// description: See the POST method. This GET method supports to use JSON encoded request body in query parameter.
// 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: ref
// in: query
// description: "The name of the commit/branch/tag. Default to the repositorys default branch."
// type: string
// required: false
// - name: body
// in: query
// description: "The JSON encoded body (see the POST request): {\"files\": [\"filename1\", \"filename2\"]}"
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ContentsListResponse"
// "404":
// "$ref": "#/responses/notFound"
// POST method requires "write" permission, so we also support this "GET" method
handleGetFileContents(ctx)
}
func GetFileContentsPost(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/file-contents repository repoGetFileContentsPost
// ---
// summary: Get the metadata and contents of requested files
// description: Uses automatic pagination based on default page size and
// max response size and returns the maximum allowed number of files.
// Files which could not be retrieved are null. Files which are too large
// are being returned with `encoding == null`, `content == null` and `size > 0`,
// they can be requested separately by using the `download_url`.
// 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: ref
// in: query
// description: "The name of the commit/branch/tag. Default to the repositorys default branch."
// type: string
// required: false
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/GetFilesOptions"
// responses:
// "200":
// "$ref": "#/responses/ContentsListResponse"
// "404":
// "$ref": "#/responses/notFound"
// This is actually a "read" request, but we need to accept a "files" list, then POST method seems easy to use.
// But the permission system requires that the caller must have "write" permission to use POST method.
// At the moment there is no other way to get around the permission check, so there is a "GET" workaround method above.
handleGetFileContents(ctx)
}
func handleGetFileContents(ctx *context.APIContext) {
opts, ok := web.GetForm(ctx).(*api.GetFilesOptions)
if !ok {
err := json.Unmarshal(util.UnsafeStringToBytes(ctx.FormString("body")), &opts)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid body parameter")
return
}
}
refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref"))
if ctx.Written() {
return
}
filesResponse := files_service.GetContentsListFromTreePaths(ctx, ctx.Repo.Repository, refCommit, opts.Files)
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(filesResponse))
}

View File

@ -0,0 +1,152 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"net/http"
issues_model "code.gitea.io/gitea/models/issues"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
)
// LockIssue lock an issue
func LockIssue(ctx *context.APIContext) {
// swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/lock issue issueLockIssue
// ---
// summary: Lock an issue
// 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: index
// in: path
// description: index of the issue
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/LockIssueOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
reason := web.GetForm(ctx).(*api.LockIssueOption).Reason
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
ctx.APIError(http.StatusForbidden, errors.New("no permission to lock this issue"))
return
}
if !issue.IsLocked {
opt := &issues_model.IssueLockOptions{
Doer: ctx.ContextUser,
Issue: issue,
Reason: reason,
}
issue.Repo = ctx.Repo.Repository
err = issues_model.LockIssue(ctx, opt)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
ctx.Status(http.StatusNoContent)
}
// UnlockIssue unlock an issue
func UnlockIssue(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/lock issue issueUnlockIssue
// ---
// summary: Unlock an issue
// 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: index
// in: path
// description: index of the issue
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
ctx.APIError(http.StatusForbidden, errors.New("no permission to unlock this issue"))
return
}
if issue.IsLocked {
opt := &issues_model.IssueLockOptions{
Doer: ctx.ContextUser,
Issue: issue,
}
issue.Repo = ctx.Repo.Repository
err = issues_model.UnlockIssue(ctx, opt)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
ctx.Status(http.StatusNoContent)
}

View File

@ -177,20 +177,14 @@ func GetCommitStatusesByRef(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
filter := utils.ResolveRefOrSha(ctx, ctx.PathParam("ref")) refCommit := resolveRefCommit(ctx, ctx.PathParam("ref"), 7)
if ctx.Written() { if ctx.Written() {
return return
} }
getCommitStatuses(ctx, refCommit.CommitID)
getCommitStatuses(ctx, filter) // By default filter is maybe the raw SHA
} }
func getCommitStatuses(ctx *context.APIContext, sha string) { func getCommitStatuses(ctx *context.APIContext, commitID string) {
if len(sha) == 0 {
ctx.APIError(http.StatusBadRequest, nil)
return
}
sha = utils.MustConvertToSHA1(ctx.Base, ctx.Repo, sha)
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
listOptions := utils.GetListOptions(ctx) listOptions := utils.GetListOptions(ctx)
@ -198,12 +192,12 @@ func getCommitStatuses(ctx *context.APIContext, sha string) {
statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](ctx, &git_model.CommitStatusOptions{ statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](ctx, &git_model.CommitStatusOptions{
ListOptions: listOptions, ListOptions: listOptions,
RepoID: repo.ID, RepoID: repo.ID,
SHA: sha, SHA: commitID,
SortType: ctx.FormTrim("sort"), SortType: ctx.FormTrim("sort"),
State: ctx.FormTrim("state"), State: ctx.FormTrim("state"),
}) })
if err != nil { if err != nil {
ctx.APIErrorInternal(fmt.Errorf("GetCommitStatuses[%s, %s, %d]: %w", repo.FullName(), sha, ctx.FormInt("page"), err)) ctx.APIErrorInternal(fmt.Errorf("GetCommitStatuses[%s, %s, %d]: %w", repo.FullName(), commitID, ctx.FormInt("page"), err))
return return
} }
@ -257,16 +251,16 @@ func GetCombinedCommitStatusByRef(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
sha := utils.ResolveRefOrSha(ctx, ctx.PathParam("ref")) refCommit := resolveRefCommit(ctx, ctx.PathParam("ref"), 7)
if ctx.Written() { if ctx.Written() {
return return
} }
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
statuses, count, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, utils.GetListOptions(ctx)) statuses, count, err := git_model.GetLatestCommitStatus(ctx, repo.ID, refCommit.Commit.ID.String(), utils.GetListOptions(ctx))
if err != nil { if err != nil {
ctx.APIErrorInternal(fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), sha, err)) ctx.APIErrorInternal(fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), refCommit.CommitID, err))
return return
} }

View File

@ -43,6 +43,7 @@ func GetGeneralAPISettings(ctx *context.APIContext) {
DefaultPagingNum: setting.API.DefaultPagingNum, DefaultPagingNum: setting.API.DefaultPagingNum,
DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage, DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage,
DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize, DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize,
DefaultMaxResponseSize: setting.API.DefaultMaxResponseSize,
}) })
} }

View File

@ -118,6 +118,9 @@ type swaggerParameterBodies struct {
// in:body // in:body
EditAttachmentOptions api.EditAttachmentOptions EditAttachmentOptions api.EditAttachmentOptions
// in:body
GetFilesOptions api.GetFilesOptions
// in:body // in:body
ChangeFilesOptions api.ChangeFilesOptions ChangeFilesOptions api.ChangeFilesOptions
@ -216,4 +219,7 @@ type swaggerParameterBodies struct {
// in:body // in:body
UpdateVariableOption api.UpdateVariableOption UpdateVariableOption api.UpdateVariableOption
// in:body
LockIssueOption api.LockIssueOption
} }

View File

@ -4,48 +4,48 @@
package utils package utils
import ( import (
gocontext "context"
"errors" "errors"
"fmt"
"net/http"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
) )
// ResolveRefOrSha resolve ref to sha if exist type RefCommit struct {
func ResolveRefOrSha(ctx *context.APIContext, ref string) string { InputRef string
if len(ref) == 0 { RefName git.RefName
ctx.APIError(http.StatusBadRequest, nil) Commit *git.Commit
return "" CommitID string
}
// ResolveRefCommit resolve ref to a commit if exist
func ResolveRefCommit(ctx reqctx.RequestContext, repo *repo_model.Repository, inputRef string, minCommitIDLen ...int) (_ *RefCommit, err error) {
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
return nil, err
} }
refCommit := RefCommit{InputRef: inputRef}
sha := ref if gitrepo.IsBranchExist(ctx, repo, inputRef) {
// Search branches and tags refCommit.RefName = git.RefNameFromBranch(inputRef)
for _, refType := range []string{"heads", "tags"} { } else if gitrepo.IsTagExist(ctx, repo, inputRef) {
refSHA, lastMethodName, err := searchRefCommitByType(ctx, refType, ref) refCommit.RefName = git.RefNameFromTag(inputRef)
if err != nil { } else if git.IsStringLikelyCommitID(git.ObjectFormatFromName(repo.ObjectFormatName), inputRef, minCommitIDLen...) {
ctx.APIErrorInternal(fmt.Errorf("%s: %w", lastMethodName, err)) refCommit.RefName = git.RefNameFromCommit(inputRef)
return ""
}
if refSHA != "" {
sha = refSHA
break
}
} }
if refCommit.RefName == "" {
sha = MustConvertToSHA1(ctx, ctx.Repo, sha) return nil, git.ErrNotExist{ID: inputRef}
if ctx.Repo.GitRepo != nil {
err := ctx.Repo.GitRepo.AddLastCommitCache(ctx.Repo.Repository.GetCommitsCountCacheKey(ref, ref != sha), ctx.Repo.Repository.FullName(), sha)
if err != nil {
log.Error("Unable to get commits count for %s in %s. Error: %v", sha, ctx.Repo.Repository.FullName(), err)
}
} }
if refCommit.Commit, err = gitRepo.GetCommit(refCommit.RefName.String()); err != nil {
return nil, err
}
refCommit.CommitID = refCommit.Commit.ID.String()
return &refCommit, nil
}
return sha func NewRefCommit(refName git.RefName, commit *git.Commit) *RefCommit {
return &RefCommit{InputRef: refName.ShortName(), RefName: refName, Commit: commit, CommitID: commit.ID.String()}
} }
// GetGitRefs return git references based on filter // GetGitRefs return git references based on filter
@ -59,42 +59,3 @@ func GetGitRefs(ctx *context.APIContext, filter string) ([]*git.Reference, strin
refs, err := ctx.Repo.GitRepo.GetRefsFiltered(filter) refs, err := ctx.Repo.GitRepo.GetRefsFiltered(filter)
return refs, "GetRefsFiltered", err return refs, "GetRefsFiltered", err
} }
func searchRefCommitByType(ctx *context.APIContext, refType, filter string) (string, string, error) {
refs, lastMethodName, err := GetGitRefs(ctx, refType+"/"+filter) // Search by type
if err != nil {
return "", lastMethodName, err
}
if len(refs) > 0 {
return refs[0].Object.String(), "", nil // Return found SHA
}
return "", "", nil
}
// ConvertToObjectID returns a full-length SHA1 from a potential ID string
func ConvertToObjectID(ctx gocontext.Context, repo *context.Repository, commitID string) (git.ObjectID, error) {
objectFormat := repo.GetObjectFormat()
if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) {
sha, err := git.NewIDFromString(commitID)
if err == nil {
return sha, nil
}
}
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo.Repository)
if err != nil {
return objectFormat.EmptyObjectID(), fmt.Errorf("RepositoryFromContextOrOpen: %w", err)
}
defer closer.Close()
return gitRepo.ConvertToGitID(commitID)
}
// MustConvertToSHA1 returns a full-length SHA1 string from a potential ID string, or returns origin input if it can't convert to SHA1
func MustConvertToSHA1(ctx gocontext.Context, repo *context.Repository, commitID string) string {
sha, err := ConvertToObjectID(ctx, repo, commitID)
if err != nil {
return commitID
}
return sha.String()
}

View File

@ -24,11 +24,6 @@ func LockIssue(ctx *context.Context) {
return return
} }
if !form.HasValidReason() {
ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason"))
return
}
if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{ if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{
Doer: ctx.Doer, Doer: ctx.Doer,
Issue: issue, Issue: issue,

View File

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@ -245,7 +246,7 @@ func APIContexter() func(http.Handler) http.Handler {
// String will replace message, errors will be added to a slice // String will replace message, errors will be added to a slice
func (ctx *APIContext) APIErrorNotFound(objs ...any) { func (ctx *APIContext) APIErrorNotFound(objs ...any) {
message := ctx.Locale.TrString("error.not_found") message := ctx.Locale.TrString("error.not_found")
var errors []string var errs []string
for _, obj := range objs { for _, obj := range objs {
// Ignore nil // Ignore nil
if obj == nil { if obj == nil {
@ -253,7 +254,7 @@ func (ctx *APIContext) APIErrorNotFound(objs ...any) {
} }
if err, ok := obj.(error); ok { if err, ok := obj.(error); ok {
errors = append(errors, err.Error()) errs = append(errs, err.Error())
} else { } else {
message = obj.(string) message = obj.(string)
} }
@ -262,7 +263,7 @@ func (ctx *APIContext) APIErrorNotFound(objs ...any) {
ctx.JSON(http.StatusNotFound, map[string]any{ ctx.JSON(http.StatusNotFound, map[string]any{
"message": message, "message": message,
"url": setting.API.SwaggerURL, "url": setting.API.SwaggerURL,
"errors": errors, "errors": errs,
}) })
} }
@ -298,39 +299,27 @@ func RepoRefForAPI(next http.Handler) http.Handler {
} }
if ctx.Repo.GitRepo == nil { if ctx.Repo.GitRepo == nil {
ctx.APIErrorInternal(errors.New("no open git repo")) panic("no GitRepo, forgot to call the middleware?") // it is a programming error
return
} }
refName, _, _ := getRefNameLegacy(ctx.Base, ctx.Repo, ctx.PathParam("*"), ctx.FormTrim("ref")) refName, refType, _ := getRefNameLegacy(ctx.Base, ctx.Repo, ctx.PathParam("*"), ctx.FormTrim("ref"))
var err error var err error
switch refType {
if gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, refName) { case git.RefTypeBranch:
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName) ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName)
if err != nil { case git.RefTypeTag:
ctx.APIErrorInternal(err)
return
}
ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
} else if gitrepo.IsTagExist(ctx, ctx.Repo.Repository, refName) {
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetTagCommit(refName) ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetTagCommit(refName)
if err != nil { case git.RefTypeCommit:
ctx.APIErrorInternal(err)
return
}
ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
} else if len(refName) == ctx.Repo.GetObjectFormat().FullLength() {
ctx.Repo.CommitID = refName
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetCommit(refName) ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetCommit(refName)
if err != nil { }
ctx.APIErrorNotFound("GetCommit", err) if ctx.Repo.Commit == nil || errors.Is(err, util.ErrNotExist) {
return
}
} else {
ctx.APIErrorNotFound(fmt.Errorf("not exist: '%s'", ctx.PathParam("*"))) ctx.APIErrorNotFound(fmt.Errorf("not exist: '%s'", ctx.PathParam("*")))
return return
} else if err != nil {
ctx.APIErrorInternal(err)
return
} }
ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
next.ServeHTTP(w, req) next.ServeHTTP(w, req)
}) })
} }

View File

@ -170,10 +170,19 @@ func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) {
// LoadGitRepo load a git repo into a test context. Requires that ctx.Repo has // LoadGitRepo load a git repo into a test context. Requires that ctx.Repo has
// already been populated. // already been populated.
func LoadGitRepo(t *testing.T, ctx *context.Context) { func LoadGitRepo(t *testing.T, ctx gocontext.Context) {
assert.NoError(t, ctx.Repo.Repository.LoadOwner(ctx)) var repo *context.Repository
switch ctx := any(ctx).(type) {
case *context.Context:
repo = ctx.Repo
case *context.APIContext:
repo = ctx.Repo
default:
assert.FailNow(t, "context is not *context.Context or *context.APIContext")
}
assert.NoError(t, repo.Repository.LoadOwner(ctx))
var err error var err error
ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository) repo.GitRepo, err = gitrepo.OpenRepository(ctx, repo.Repository)
assert.NoError(t, err) assert.NoError(t, err)
} }

View File

@ -10,7 +10,6 @@ import (
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project" project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
@ -473,22 +472,6 @@ func (i *IssueLockForm) Validate(req *http.Request, errs binding.Errors) binding
return middleware.Validate(errs, ctx.Data, i, ctx.Locale) return middleware.Validate(errs, ctx.Data, i, ctx.Locale)
} }
// HasValidReason checks to make sure that the reason submitted in
// the form matches any of the values in the config
func (i IssueLockForm) HasValidReason() bool {
if strings.TrimSpace(i.Reason) == "" {
return true
}
for _, v := range setting.Repository.Issue.LockReasons {
if v == i.Reason {
return true
}
}
return false
}
// CreateProjectForm form for creating a project // CreateProjectForm form for creating a project
type CreateProjectForm struct { type CreateProjectForm struct {
Title string `binding:"Required;MaxSize(100)"` Title string `binding:"Required;MaxSize(100)"`

View File

@ -6,8 +6,6 @@ package forms
import ( import (
"testing" "testing"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -39,26 +37,3 @@ func TestSubmitReviewForm_IsEmpty(t *testing.T) {
assert.Equal(t, v.expected, v.form.HasEmptyContent()) assert.Equal(t, v.expected, v.form.HasEmptyContent())
} }
} }
func TestIssueLock_HasValidReason(t *testing.T) {
// Init settings
_ = setting.Repository
cases := []struct {
form IssueLockForm
expected bool
}{
{IssueLockForm{""}, true}, // an empty reason is accepted
{IssueLockForm{"Off-topic"}, true},
{IssueLockForm{"Too heated"}, true},
{IssueLockForm{"Spam"}, true},
{IssueLockForm{"Resolved"}, true},
{IssueLockForm{"ZZZZ"}, false},
{IssueLockForm{"I want to lock this issue"}, false},
}
for _, v := range cases {
assert.Equal(t, v.expected, v.form.HasValidReason())
}
}

View File

@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"path" "path"
"strings"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
@ -16,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/v1/utils"
) )
// ContentType repo content type // ContentType repo content type
@ -23,14 +23,10 @@ type ContentType string
// The string representations of different content types // The string representations of different content types
const ( const (
// ContentTypeRegular regular content type (file) ContentTypeRegular ContentType = "file" // regular content type (file)
ContentTypeRegular ContentType = "file" ContentTypeDir ContentType = "dir" // dir content type (dir)
// ContentTypeDir dir content type (dir) ContentTypeLink ContentType = "symlink" // link content type (symlink)
ContentTypeDir ContentType = "dir" ContentTypeSubmodule ContentType = "submodule" // submodule content type (submodule)
// ContentLink link content type (symlink)
ContentTypeLink ContentType = "symlink"
// ContentTag submodule content type (submodule)
ContentTypeSubmodule ContentType = "submodule"
) )
// String gets the string of ContentType // String gets the string of ContentType
@ -38,16 +34,12 @@ func (ct *ContentType) String() string {
return string(*ct) return string(*ct)
} }
// GetContentsOrList gets the meta data of a file's contents (*ContentsResponse) if treePath not a tree // GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree
// directory, otherwise a listing of file contents ([]*ContentsResponse). Ref can be a branch, commit or tag // directory, otherwise a listing of file contents ([]*ContentsResponse). Ref can be a branch, commit or tag
func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, treePath, ref string) (any, error) { func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treePath string) (any, error) {
if repo.IsEmpty { if repo.IsEmpty {
return make([]any, 0), nil return make([]any, 0), nil
} }
if ref == "" {
ref = repo.DefaultBranch
}
origRef := ref
// Check that the path given in opts.treePath is valid (not a git path) // Check that the path given in opts.treePath is valid (not a git path)
cleanTreePath := CleanUploadFileName(treePath) cleanTreePath := CleanUploadFileName(treePath)
@ -58,17 +50,8 @@ func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, treePat
} }
treePath = cleanTreePath treePath = cleanTreePath
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
defer closer.Close()
// Get the commit object for the ref // Get the commit object for the ref
commit, err := gitRepo.GetCommit(ref) commit := refCommit.Commit
if err != nil {
return nil, err
}
entry, err := commit.GetTreeEntryByPath(treePath) entry, err := commit.GetTreeEntryByPath(treePath)
if err != nil { if err != nil {
@ -76,7 +59,7 @@ func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, treePat
} }
if entry.Type() != "tree" { if entry.Type() != "tree" {
return GetContents(ctx, repo, treePath, origRef, false) return GetContents(ctx, repo, refCommit, treePath, false)
} }
// We are in a directory, so we return a list of FileContentResponse objects // We are in a directory, so we return a list of FileContentResponse objects
@ -92,7 +75,7 @@ func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, treePat
} }
for _, e := range entries { for _, e := range entries {
subTreePath := path.Join(treePath, e.Name()) subTreePath := path.Join(treePath, e.Name())
fileContentResponse, err := GetContents(ctx, repo, subTreePath, origRef, true) fileContentResponse, err := GetContents(ctx, repo, refCommit, subTreePath, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -117,13 +100,8 @@ func GetObjectTypeFromTreeEntry(entry *git.TreeEntry) ContentType {
} }
} }
// GetContents gets the meta data on a file's contents. Ref can be a branch, commit or tag // GetContents gets the metadata on a file's contents. Ref can be a branch, commit or tag
func GetContents(ctx context.Context, repo *repo_model.Repository, treePath, ref string, forList bool) (*api.ContentsResponse, error) { func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treePath string, forList bool) (*api.ContentsResponse, error) {
if ref == "" {
ref = repo.DefaultBranch
}
origRef := ref
// Check that the path given in opts.treePath is valid (not a git path) // Check that the path given in opts.treePath is valid (not a git path)
cleanTreePath := CleanUploadFileName(treePath) cleanTreePath := CleanUploadFileName(treePath)
if cleanTreePath == "" && treePath != "" { if cleanTreePath == "" && treePath != "" {
@ -139,33 +117,24 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, treePath, ref
} }
defer closer.Close() defer closer.Close()
// Get the commit object for the ref commit := refCommit.Commit
commit, err := gitRepo.GetCommit(ref)
if err != nil {
return nil, err
}
commitID := commit.ID.String()
if len(ref) >= 4 && strings.HasPrefix(commitID, ref) {
ref = commit.ID.String()
}
entry, err := commit.GetTreeEntryByPath(treePath) entry, err := commit.GetTreeEntryByPath(treePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
refType := gitRepo.GetRefType(ref) refType := refCommit.RefName.RefType()
if refType == "invalid" { if refType != git.RefTypeBranch && refType != git.RefTypeTag && refType != git.RefTypeCommit {
return nil, fmt.Errorf("no commit found for the ref [ref: %s]", ref) return nil, fmt.Errorf("no commit found for the ref [ref: %s]", refCommit.RefName)
} }
selfURL, err := url.Parse(repo.APIURL() + "/contents/" + util.PathEscapeSegments(treePath) + "?ref=" + url.QueryEscape(origRef)) selfURL, err := url.Parse(repo.APIURL() + "/contents/" + util.PathEscapeSegments(treePath) + "?ref=" + url.QueryEscape(refCommit.InputRef))
if err != nil { if err != nil {
return nil, err return nil, err
} }
selfURLString := selfURL.String() selfURLString := selfURL.String()
err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(ref, refType != git.ObjectCommit), repo.FullName(), commitID) err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -196,15 +165,18 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, treePath, ref
if lastCommit.Author != nil { if lastCommit.Author != nil {
contentsResponse.LastAuthorDate = lastCommit.Author.When contentsResponse.LastAuthorDate = lastCommit.Author.When
} }
// Now populate the rest of the ContentsResponse based on entry type // Now populate the rest of the ContentsResponse based on entry type
if entry.IsRegular() || entry.IsExecutable() { if entry.IsRegular() || entry.IsExecutable() {
contentsResponse.Type = string(ContentTypeRegular) contentsResponse.Type = string(ContentTypeRegular)
if blobResponse, err := GetBlobBySHA(ctx, repo, gitRepo, entry.ID.String()); err != nil { // if it is listing the repo root dir, don't waste system resources on reading content
return nil, err if !forList {
} else if !forList { blobResponse, err := GetBlobBySHA(ctx, repo, gitRepo, entry.ID.String())
// We don't show the content if we are getting a list of FileContentResponses if err != nil {
contentsResponse.Encoding = &blobResponse.Encoding return nil, err
contentsResponse.Content = &blobResponse.Content }
contentsResponse.Encoding = blobResponse.Encoding
contentsResponse.Content = blobResponse.Content
} }
} else if entry.IsDir() { } else if entry.IsDir() {
contentsResponse.Type = string(ContentTypeDir) contentsResponse.Type = string(ContentTypeDir)
@ -228,7 +200,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, treePath, ref
} }
// Handle links // Handle links
if entry.IsRegular() || entry.IsLink() || entry.IsExecutable() { if entry.IsRegular() || entry.IsLink() || entry.IsExecutable() {
downloadURL, err := url.Parse(repo.HTMLURL() + "/raw/" + url.PathEscape(string(refType)) + "/" + util.PathEscapeSegments(ref) + "/" + util.PathEscapeSegments(treePath)) downloadURL, err := url.Parse(repo.HTMLURL() + "/raw/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(treePath))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -236,7 +208,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, treePath, ref
contentsResponse.DownloadURL = &downloadURLString contentsResponse.DownloadURL = &downloadURLString
} }
if !entry.IsSubModule() { if !entry.IsSubModule() {
htmlURL, err := url.Parse(repo.HTMLURL() + "/src/" + url.PathEscape(string(refType)) + "/" + util.PathEscapeSegments(ref) + "/" + util.PathEscapeSegments(treePath)) htmlURL, err := url.Parse(repo.HTMLURL() + "/src/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(treePath))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -262,18 +234,17 @@ func GetBlobBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git
if err != nil { if err != nil {
return nil, err return nil, err
} }
content := "" ret := &api.GitBlobResponse{
SHA: gitBlob.ID.String(),
URL: repo.APIURL() + "/git/blobs/" + url.PathEscape(gitBlob.ID.String()),
Size: gitBlob.Size(),
}
if gitBlob.Size() <= setting.API.DefaultMaxBlobSize { if gitBlob.Size() <= setting.API.DefaultMaxBlobSize {
content, err = gitBlob.GetBlobContentBase64() content, err := gitBlob.GetBlobContentBase64()
if err != nil { if err != nil {
return nil, err return nil, err
} }
ret.Encoding, ret.Content = util.ToPointer("base64"), &content
} }
return &api.GitBlobResponse{ return ret, nil
SHA: gitBlob.ID.String(),
URL: repo.APIURL() + "/git/blobs/" + url.PathEscape(gitBlob.ID.String()),
Size: gitBlob.Size(),
Encoding: "base64",
Content: content,
}, nil
} }

View File

@ -10,11 +10,14 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
_ "code.gitea.io/gitea/models/actions" _ "code.gitea.io/gitea/models/actions"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@ -64,18 +67,13 @@ func TestGetContents(t *testing.T) {
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
treePath := "README.md" treePath := "README.md"
ref := ctx.Repo.Repository.DefaultBranch refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch)
require.NoError(t, err)
expectedContentsResponse := getExpectedReadmeContentsResponse() expectedContentsResponse := getExpectedReadmeContentsResponse()
t.Run("Get README.md contents with GetContents(ctx, )", func(t *testing.T) { t.Run("Get README.md contents with GetContents(ctx, )", func(t *testing.T) {
fileContentResponse, err := GetContents(ctx, ctx.Repo.Repository, treePath, ref, false) fileContentResponse, err := GetContents(ctx, ctx.Repo.Repository, refCommit, treePath, false)
assert.Equal(t, expectedContentsResponse, fileContentResponse)
assert.NoError(t, err)
})
t.Run("Get README.md contents with ref as empty string (should then use the repo's default branch) with GetContents(ctx, )", func(t *testing.T) {
fileContentResponse, err := GetContents(ctx, ctx.Repo.Repository, treePath, "", false)
assert.Equal(t, expectedContentsResponse, fileContentResponse) assert.Equal(t, expectedContentsResponse, fileContentResponse)
assert.NoError(t, err) assert.NoError(t, err)
}) })
@ -92,7 +90,8 @@ func TestGetContentsOrListForDir(t *testing.T) {
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
treePath := "" // root dir treePath := "" // root dir
ref := ctx.Repo.Repository.DefaultBranch refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch)
require.NoError(t, err)
readmeContentsResponse := getExpectedReadmeContentsResponse() readmeContentsResponse := getExpectedReadmeContentsResponse()
// because will be in a list, doesn't have encoding and content // because will be in a list, doesn't have encoding and content
@ -104,13 +103,7 @@ func TestGetContentsOrListForDir(t *testing.T) {
} }
t.Run("Get root dir contents with GetContentsOrList(ctx, )", func(t *testing.T) { t.Run("Get root dir contents with GetContentsOrList(ctx, )", func(t *testing.T) {
fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, treePath, ref) fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath)
assert.EqualValues(t, expectedContentsListResponse, fileContentResponse)
assert.NoError(t, err)
})
t.Run("Get root dir contents with ref as empty string (should then use the repo's default branch) with GetContentsOrList(ctx, )", func(t *testing.T) {
fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, treePath, "")
assert.EqualValues(t, expectedContentsListResponse, fileContentResponse) assert.EqualValues(t, expectedContentsListResponse, fileContentResponse)
assert.NoError(t, err) assert.NoError(t, err)
}) })
@ -127,18 +120,13 @@ func TestGetContentsOrListForFile(t *testing.T) {
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
treePath := "README.md" treePath := "README.md"
ref := ctx.Repo.Repository.DefaultBranch refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch)
require.NoError(t, err)
expectedContentsResponse := getExpectedReadmeContentsResponse() expectedContentsResponse := getExpectedReadmeContentsResponse()
t.Run("Get README.md contents with GetContentsOrList(ctx, )", func(t *testing.T) { t.Run("Get README.md contents with GetContentsOrList(ctx, )", func(t *testing.T) {
fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, treePath, ref) fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath)
assert.EqualValues(t, expectedContentsResponse, fileContentResponse)
assert.NoError(t, err)
})
t.Run("Get README.md contents with ref as empty string (should then use the repo's default branch) with GetContentsOrList(ctx, )", func(t *testing.T) {
fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, treePath, "")
assert.EqualValues(t, expectedContentsResponse, fileContentResponse) assert.EqualValues(t, expectedContentsResponse, fileContentResponse)
assert.NoError(t, err) assert.NoError(t, err)
}) })
@ -155,24 +143,16 @@ func TestGetContentsErrors(t *testing.T) {
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
treePath := "README.md" refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch)
ref := repo.DefaultBranch require.NoError(t, err)
t.Run("bad treePath", func(t *testing.T) { t.Run("bad treePath", func(t *testing.T) {
badTreePath := "bad/tree.md" badTreePath := "bad/tree.md"
fileContentResponse, err := GetContents(ctx, repo, badTreePath, ref, false) fileContentResponse, err := GetContents(ctx, repo, refCommit, badTreePath, false)
assert.Error(t, err) assert.Error(t, err)
assert.EqualError(t, err, "object does not exist [id: , rel_path: bad]") assert.EqualError(t, err, "object does not exist [id: , rel_path: bad]")
assert.Nil(t, fileContentResponse) assert.Nil(t, fileContentResponse)
}) })
t.Run("bad ref", func(t *testing.T) {
badRef := "bad_ref"
fileContentResponse, err := GetContents(ctx, repo, treePath, badRef, false)
assert.Error(t, err)
assert.EqualError(t, err, "object does not exist [id: "+badRef+", rel_path: ]")
assert.Nil(t, fileContentResponse)
})
} }
func TestGetContentsOrListErrors(t *testing.T) { func TestGetContentsOrListErrors(t *testing.T) {
@ -186,42 +166,16 @@ func TestGetContentsOrListErrors(t *testing.T) {
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
treePath := "README.md" refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch)
ref := repo.DefaultBranch require.NoError(t, err)
t.Run("bad treePath", func(t *testing.T) { t.Run("bad treePath", func(t *testing.T) {
badTreePath := "bad/tree.md" badTreePath := "bad/tree.md"
fileContentResponse, err := GetContentsOrList(ctx, repo, badTreePath, ref) fileContentResponse, err := GetContentsOrList(ctx, repo, refCommit, badTreePath)
assert.Error(t, err) assert.Error(t, err)
assert.EqualError(t, err, "object does not exist [id: , rel_path: bad]") assert.EqualError(t, err, "object does not exist [id: , rel_path: bad]")
assert.Nil(t, fileContentResponse) assert.Nil(t, fileContentResponse)
}) })
t.Run("bad ref", func(t *testing.T) {
badRef := "bad_ref"
fileContentResponse, err := GetContentsOrList(ctx, repo, treePath, badRef)
assert.Error(t, err)
assert.EqualError(t, err, "object does not exist [id: "+badRef+", rel_path: ]")
assert.Nil(t, fileContentResponse)
})
}
func TestGetContentsOrListOfEmptyRepos(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user30/empty")
ctx.SetPathParam("id", "52")
contexttest.LoadRepo(t, ctx, 52)
contexttest.LoadUser(t, ctx, 30)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
t.Run("empty repo", func(t *testing.T) {
contents, err := GetContentsOrList(ctx, repo, "", "")
assert.NoError(t, err)
assert.Empty(t, contents)
})
} }
func TestGetBlobBySHA(t *testing.T) { func TestGetBlobBySHA(t *testing.T) {
@ -244,8 +198,8 @@ func TestGetBlobBySHA(t *testing.T) {
gbr, err := GetBlobBySHA(ctx, ctx.Repo.Repository, gitRepo, ctx.PathParam("sha")) gbr, err := GetBlobBySHA(ctx, ctx.Repo.Repository, gitRepo, ctx.PathParam("sha"))
expectedGBR := &api.GitBlobResponse{ expectedGBR := &api.GitBlobResponse{
Content: "dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK", Content: util.ToPointer("dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK"),
Encoding: "base64", Encoding: util.ToPointer("base64"),
URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/65f1bf27bc3bf70f64657658635e66094edbcb4d", URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/65f1bf27bc3bf70f64657658635e66094edbcb4d",
SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
Size: 180, Size: 180,

View File

@ -13,18 +13,35 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/v1/utils"
) )
func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branch string, treeNames []string) (*api.FilesResponse, error) { func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) {
files := []*api.ContentsResponse{} var size int64
for _, file := range treeNames { for _, treePath := range treePaths {
fileContents, _ := GetContents(ctx, repo, file, branch, false) // ok if fails, then will be nil fileContents, _ := GetContents(ctx, repo, refCommit, treePath, false) // ok if fails, then will be nil
if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" {
// if content isn't empty (e.g. due to the single blob being too large), add file size to response size
size += int64(len(*fileContents.Content))
}
if size > setting.API.DefaultMaxResponseSize {
break // stop if max response size would be exceeded
}
files = append(files, fileContents) files = append(files, fileContents)
if len(files) == setting.API.DefaultPagingNum {
break // stop if paging num reached
}
} }
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil return files
verification := GetPayloadCommitVerification(ctx, commit) }
func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treeNames []string) (*api.FilesResponse, error) {
files := GetContentsListFromTreePaths(ctx, repo, refCommit, treeNames)
fileCommitResponse, _ := GetFileCommitResponse(repo, refCommit.Commit) // ok if fails, then will be nil
verification := GetPayloadCommitVerification(ctx, refCommit.Commit)
filesResponse := &api.FilesResponse{ filesResponse := &api.FilesResponse{
Files: files, Files: files,
Commit: fileCommitResponse, Commit: fileCommitResponse,
@ -33,19 +50,6 @@ func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository
return filesResponse, nil return filesResponse, nil
} }
// GetFileResponseFromCommit Constructs a FileResponse from a Commit object
func GetFileResponseFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branch, treeName string) (*api.FileResponse, error) {
fileContents, _ := GetContents(ctx, repo, treeName, branch, false) // ok if fails, then will be nil
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil
verification := GetPayloadCommitVerification(ctx, commit)
fileResponse := &api.FileResponse{
Content: fileContents,
Commit: fileCommitResponse,
Verification: verification,
}
return fileResponse, nil
}
// constructs a FileResponse with the file at the index from FilesResponse // constructs a FileResponse with the file at the index from FilesResponse
func GetFileResponseFromFilesResponse(filesResponse *api.FilesResponse, index int) *api.FileResponse { func GetFileResponseFromFilesResponse(filesResponse *api.FilesResponse, index int) *api.FileResponse {
content := &api.ContentsResponse{} content := &api.ContentsResponse{}

View File

@ -5,13 +5,6 @@ package files
import ( import (
"testing" "testing"
"time"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -31,93 +24,3 @@ func TestCleanUploadFileName(t *testing.T) {
assert.Equal(t, expectedCleanName, cleanName) assert.Equal(t, expectedCleanName, cleanName)
}) })
} }
func getExpectedFileResponse() *api.FileResponse {
treePath := "README.md"
sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f"
encoding := "base64"
content := "IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x"
selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master"
htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + treePath
gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha
downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + treePath
return &api.FileResponse{
Content: &api.ContentsResponse{
Name: treePath,
Path: treePath,
SHA: sha,
LastCommitSHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
Type: "file",
Size: 30,
Encoding: &encoding,
Content: &content,
URL: &selfURL,
HTMLURL: &htmlURL,
GitURL: &gitURL,
DownloadURL: &downloadURL,
Links: &api.FileLinksResponse{
Self: &selfURL,
GitURL: &gitURL,
HTMLURL: &htmlURL,
},
},
Commit: &api.FileCommitResponse{
CommitMeta: api.CommitMeta{
URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d",
SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
},
HTMLURL: "https://try.gitea.io/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d",
Author: &api.CommitUser{
Identity: api.Identity{
Name: "user1",
Email: "address1@example.com",
},
Date: "2017-03-19T20:47:59Z",
},
Committer: &api.CommitUser{
Identity: api.Identity{
Name: "Ethan Koenig",
Email: "ethantkoenig@gmail.com",
},
Date: "2017-03-19T20:47:59Z",
},
Parents: []*api.CommitMeta{},
Message: "Initial commit\n",
Tree: &api.CommitMeta{
URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/trees/2a2f1d4670728a2e10049e345bd7a276468beab6",
SHA: "2a2f1d4670728a2e10049e345bd7a276468beab6",
},
},
Verification: &api.PayloadCommitVerification{
Verified: false,
Reason: "gpg.error.not_signed_commit",
Signature: "",
Payload: "",
},
}
}
func TestGetFileResponseFromCommit(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetPathParam("id", "1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
branch := repo.DefaultBranch
treePath := "README.md"
gitRepo, _ := gitrepo.OpenRepository(ctx, repo)
defer gitRepo.Close()
commit, _ := gitRepo.GetBranchCommit(branch)
expectedFileResponse := getExpectedFileResponse()
fileResponse, err := GetFileResponseFromCommit(ctx, repo, commit, branch, treePath)
assert.NoError(t, err)
assert.Equal(t, expectedFileResponse, fileResponse)
}

View File

@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/v1/utils"
asymkey_service "code.gitea.io/gitea/services/asymkey" asymkey_service "code.gitea.io/gitea/services/asymkey"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
) )
@ -296,7 +297,9 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
return nil, err return nil, err
} }
filesResponse, err := GetFilesResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePaths) // FIXME: this call seems not right, why it needs to read the file content again
// FIXME: why it uses the NewBranch as "ref", it should use the commit ID because the response is only for this commit
filesResponse, err := GetFilesResponseFromCommit(ctx, repo, utils.NewRefCommit(git.RefNameFromBranch(opts.NewBranch), commit), treePaths)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -6863,7 +6863,7 @@
}, },
{ {
"type": "string", "type": "string",
"description": "The name of the commit/branch/tag. Default the repositorys default branch (usually master)", "description": "The name of the commit/branch/tag. Default to the repositorys default branch.",
"name": "ref", "name": "ref",
"in": "query" "in": "query"
} }
@ -6966,7 +6966,7 @@
}, },
{ {
"type": "string", "type": "string",
"description": "The name of the commit/branch/tag. Default the repositorys default branch (usually master)", "description": "The name of the commit/branch/tag. Default to the repositorys default branch.",
"name": "ref", "name": "ref",
"in": "query" "in": "query"
} }
@ -7248,7 +7248,7 @@
}, },
{ {
"type": "string", "type": "string",
"description": "The name of the commit/branch/tag. Default the repositorys default branch (usually master)", "description": "The name of the commit/branch/tag. Default to the repositorys default branch.",
"name": "ref", "name": "ref",
"in": "query" "in": "query"
} }
@ -7263,6 +7263,105 @@
} }
} }
}, },
"/repos/{owner}/{repo}/file-contents": {
"get": {
"description": "See the POST method. This GET method supports to use JSON encoded request body in query parameter.",
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get the metadata and contents of requested files",
"operationId": "repoGetFileContents",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "string",
"description": "The name of the commit/branch/tag. Default to the repositorys default branch.",
"name": "ref",
"in": "query"
},
{
"type": "string",
"description": "The JSON encoded body (see the POST request): {\"files\": [\"filename1\", \"filename2\"]}",
"name": "body",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ContentsListResponse"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"post": {
"description": "Uses automatic pagination based on default page size and max response size and returns the maximum allowed number of files. Files which could not be retrieved are null. Files which are too large are being returned with `encoding == null`, `content == null` and `size \u003e 0`, they can be requested separately by using the `download_url`.",
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get the metadata and contents of requested files",
"operationId": "repoGetFileContentsPost",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "string",
"description": "The name of the commit/branch/tag. Default to the repositorys default branch.",
"name": "ref",
"in": "query"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/GetFilesOptions"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/ContentsListResponse"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/forks": { "/repos/{owner}/{repo}/forks": {
"get": { "get": {
"produces": [ "produces": [
@ -10484,6 +10583,111 @@
} }
} }
}, },
"/repos/{owner}/{repo}/issues/{index}/lock": {
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"issue"
],
"summary": "Lock an issue",
"operationId": "issueLockIssue",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "index of the issue",
"name": "index",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/LockIssueOption"
}
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"delete": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"issue"
],
"summary": "Unlock an issue",
"operationId": "issueUnlockIssue",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "index of the issue",
"name": "index",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/issues/{index}/pin": { "/repos/{owner}/{repo}/issues/{index}/pin": {
"post": { "post": {
"tags": [ "tags": [
@ -23518,6 +23722,11 @@
"format": "int64", "format": "int64",
"x-go-name": "DefaultMaxBlobSize" "x-go-name": "DefaultMaxBlobSize"
}, },
"default_max_response_size": {
"type": "integer",
"format": "int64",
"x-go-name": "DefaultMaxResponseSize"
},
"default_paging_num": { "default_paging_num": {
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
@ -23684,6 +23893,20 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"GetFilesOptions": {
"description": "GetFilesOptions options for retrieving metadate and content of multiple files",
"type": "object",
"properties": {
"files": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Files"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"GitBlobResponse": { "GitBlobResponse": {
"description": "GitBlobResponse represents a git blob", "description": "GitBlobResponse represents a git blob",
"type": "object", "type": "object",
@ -24338,6 +24561,17 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"LockIssueOption": {
"description": "LockIssueOption options to lock an issue",
"type": "object",
"properties": {
"lock_reason": {
"type": "string",
"x-go-name": "Reason"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"MarkdownOption": { "MarkdownOption": {
"description": "MarkdownOption markdown options", "description": "MarkdownOption markdown options",
"type": "object", "type": "object",
@ -28247,7 +28481,7 @@
"parameterBodies": { "parameterBodies": {
"description": "parameterBodies", "description": "parameterBodies",
"schema": { "schema": {
"$ref": "#/definitions/UpdateVariableOption" "$ref": "#/definitions/LockIssueOption"
} }
}, },
"redirect": { "redirect": {

View File

@ -0,0 +1,74 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPILockIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Lock", func(t *testing.T) {
issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
assert.False(t, issueBefore.IsLocked)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index)
session := loginUser(t, owner.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
// check lock issue
req := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
assert.True(t, issueAfter.IsLocked)
// check with other user
user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34})
session34 := loginUser(t, user34.Name)
token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll)
req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token34)
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("Unlock", func(t *testing.T) {
issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index)
session := loginUser(t, owner.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
lockReq := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token)
MakeRequest(t, lockReq, http.StatusNoContent)
// check unlock issue
req := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
assert.False(t, issueAfter.IsLocked)
// check with other user
user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34})
session34 := loginUser(t, user34.Name)
token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll)
req = NewRequest(t, "DELETE", urlStr).AddTokenAuth(token34)
MakeRequest(t, req, http.StatusForbidden)
})
}

View File

@ -0,0 +1,157 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIGetRequestedFiles(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
// Get user2's token
session := loginUser(t, user2.Name)
token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
// Get user4's token
session = loginUser(t, user4.Name)
token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1)
assert.NoError(t, err)
defer gitRepo.Close()
lastCommit, _ := gitRepo.GetCommitByPath("README.md")
requestFiles := func(t *testing.T, url string, files []string, expectedStatusCode ...int) (ret []*api.ContentsResponse) {
req := NewRequestWithJSON(t, "POST", url, &api.GetFilesOptions{Files: files})
resp := MakeRequest(t, req, util.OptionalArg(expectedStatusCode, http.StatusOK))
if resp.Code != http.StatusOK {
return nil
}
DecodeJSON(t, resp, &ret)
return ret
}
t.Run("User2Get", func(t *testing.T) {
reqBodyOpt := &api.GetFilesOptions{Files: []string{"README.md"}}
reqBodyParam, _ := json.Marshal(reqBodyOpt)
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/file-contents?body="+url.QueryEscape(string(reqBodyParam)))
resp := MakeRequest(t, req, http.StatusOK)
var ret []*api.ContentsResponse
DecodeJSON(t, resp, &ret)
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents(repo1.DefaultBranch, "branch", lastCommit.ID.String())}
assert.Equal(t, expected, ret)
})
t.Run("User2NoRef", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents(repo1.DefaultBranch, "branch", lastCommit.ID.String())}
assert.Equal(t, expected, ret)
})
t.Run("User2RefBranch", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=master", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents(repo1.DefaultBranch, "branch", lastCommit.ID.String())}
assert.Equal(t, expected, ret)
})
t.Run("User2RefTag", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=v1.1", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("v1.1", "tag", lastCommit.ID.String())}
assert.Equal(t, expected, ret)
})
t.Run("User2RefCommit", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=65f1bf27bc3bf70f64657658635e66094edbcb4d", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("65f1bf27bc3bf70f64657658635e66094edbcb4d", "commit", lastCommit.ID.String())}
assert.Equal(t, expected, ret)
})
t.Run("User2RefNotExist", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=not-exist", []string{"README.md"}, http.StatusNotFound)
assert.Empty(t, ret)
})
t.Run("PermissionCheck", func(t *testing.T) {
filesOptions := &api.GetFilesOptions{Files: []string{"README.md"}}
// Test accessing private ref with user token that does not have access - should fail
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/file-contents", user2.Name, repo16.Name), &filesOptions).AddTokenAuth(token4)
MakeRequest(t, req, http.StatusNotFound)
// Test access private ref of owner of token
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/file-contents", user2.Name, repo16.Name), &filesOptions).AddTokenAuth(token2)
MakeRequest(t, req, http.StatusOK)
// Test access of org org3 private repo file by owner user2
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/file-contents", org3.Name, repo3.Name), &filesOptions).AddTokenAuth(token2)
MakeRequest(t, req, http.StatusOK)
})
t.Run("ResponseList", func(t *testing.T) {
defer test.MockVariableValue(&setting.API.DefaultPagingNum)()
defer test.MockVariableValue(&setting.API.DefaultMaxBlobSize)()
defer test.MockVariableValue(&setting.API.DefaultMaxResponseSize)()
type expected struct {
Name string
HasContent bool
}
assertResponse := func(t *testing.T, expected []*expected, ret []*api.ContentsResponse) {
require.Len(t, ret, len(expected))
for i, e := range expected {
if e == nil {
assert.Nil(t, ret[i], "item %d", i)
continue
}
assert.Equal(t, e.Name, ret[i].Name, "item %d name", i)
if e.HasContent {
require.NotNil(t, ret[i].Content, "item %d content", i)
assert.NotEmpty(t, *ret[i].Content, "item %d content", i)
} else {
assert.Nil(t, ret[i].Content, "item %d content", i)
}
}
}
// repo1 "DefaultBranch" has 2 files: LICENSE (1064 bytes), README.md (30 bytes)
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=DefaultBranch", []string{"no-such.txt", "LICENSE", "README.md"})
assertResponse(t, []*expected{nil, {"LICENSE", true}, {"README.md", true}}, ret)
// the returned file list is limited by the DefaultPagingNum
setting.API.DefaultPagingNum = 2
ret = requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=DefaultBranch", []string{"no-such.txt", "LICENSE", "README.md"})
assertResponse(t, []*expected{nil, {"LICENSE", true}}, ret)
setting.API.DefaultPagingNum = 100
// if a file exceeds the DefaultMaxBlobSize, the content is not returned
setting.API.DefaultMaxBlobSize = 200
ret = requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=DefaultBranch", []string{"no-such.txt", "LICENSE", "README.md"})
assertResponse(t, []*expected{nil, {"LICENSE", false}, {"README.md", true}}, ret)
setting.API.DefaultMaxBlobSize = 20000
// if the total response size would exceed the DefaultMaxResponseSize, then the list stops
setting.API.DefaultMaxResponseSize = ret[1].Size*4/3 + 10
ret = requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=DefaultBranch", []string{"no-such.txt", "LICENSE", "README.md"})
assertResponse(t, []*expected{nil, {"LICENSE", true}}, ret)
setting.API.DefaultMaxBlobSize = 20000
})
}

View File

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -26,28 +27,24 @@ import (
func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) *api.ContentsResponse { func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) *api.ContentsResponse {
treePath := "README.md" treePath := "README.md"
sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f"
encoding := "base64"
content := "IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x"
selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + ref selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + ref
htmlURL := setting.AppURL + "user2/repo1/src/" + refType + "/" + ref + "/" + treePath htmlURL := setting.AppURL + "user2/repo1/src/" + refType + "/" + ref + "/" + treePath
gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f"
downloadURL := setting.AppURL + "user2/repo1/raw/" + refType + "/" + ref + "/" + treePath
return &api.ContentsResponse{ return &api.ContentsResponse{
Name: treePath, Name: treePath,
Path: treePath, Path: treePath,
SHA: sha, SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
LastCommitSHA: lastCommitSHA, LastCommitSHA: lastCommitSHA,
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
Type: "file", Type: "file",
Size: 30, Size: 30,
Encoding: &encoding, Encoding: util.ToPointer("base64"),
Content: &content, Content: util.ToPointer("IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x"),
URL: &selfURL, URL: &selfURL,
HTMLURL: &htmlURL, HTMLURL: &htmlURL,
GitURL: &gitURL, GitURL: &gitURL,
DownloadURL: &downloadURL, DownloadURL: util.ToPointer(setting.AppURL + "user2/repo1/raw/" + refType + "/" + ref + "/" + treePath),
Links: &api.FileLinksResponse{ Links: &api.FileLinksResponse{
Self: &selfURL, Self: &selfURL,
GitURL: &gitURL, GitURL: &gitURL,

View File

@ -41,7 +41,7 @@ func TestAPIReposGitBlobs(t *testing.T) {
DecodeJSON(t, resp, &gitBlobResponse) DecodeJSON(t, resp, &gitBlobResponse)
assert.NotNil(t, gitBlobResponse) assert.NotNil(t, gitBlobResponse)
expectedContent := "dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK" expectedContent := "dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK"
assert.Equal(t, expectedContent, gitBlobResponse.Content) assert.Equal(t, expectedContent, *gitBlobResponse.Content)
// Tests a private repo with no token so will fail // Tests a private repo with no token so will fail
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo16.Name, repo16ReadmeSHA) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo16.Name, repo16ReadmeSHA)

View File

@ -35,6 +35,7 @@ func TestAPIExposedSettings(t *testing.T) {
DefaultPagingNum: setting.API.DefaultPagingNum, DefaultPagingNum: setting.API.DefaultPagingNum,
DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage, DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage,
DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize, DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize,
DefaultMaxResponseSize: setting.API.DefaultMaxResponseSize,
}, apiSettings) }, apiSettings)
repo := new(api.GeneralRepoSettings) repo := new(api.GeneralRepoSettings)