0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-07 13:35:40 +02:00

Merge branch 'main' into feat/issue-column-picker

This commit is contained in:
wxiaoguang 2026-03-30 21:55:28 +08:00
commit 36810b8900
40 changed files with 584 additions and 230 deletions

6
flake.lock generated
View File

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1773821835,
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"lastModified": 1774386573,
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
"type": "github"
},
"original": {

View File

@ -33,9 +33,9 @@
inherit (pkgs) lib;
# only bump toolchain versions here
go = pkgs.go_1_25;
go = pkgs.go_1_26;
nodejs = pkgs.nodejs_24;
python3 = pkgs.python312;
python3 = pkgs.python314;
pnpm = pkgs.pnpm_10;
# Platform-specific dependencies

View File

@ -22,16 +22,16 @@ import (
var catFileBatchDebugWaitClose atomic.Int64
type catFileBatchCommunicator struct {
cancel context.CancelFunc
closeFunc func(err error)
reqWriter io.Writer
respReader *bufio.Reader
debugGitCmd *gitcmd.Command
}
func (b *catFileBatchCommunicator) Close() {
if b.cancel != nil {
b.cancel()
b.cancel = nil
if b.closeFunc != nil {
b.closeFunc(nil)
b.closeFunc = nil
}
}
@ -47,10 +47,19 @@ func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Co
}
stdPipeClose()
}
closeFunc := func(err error) {
ctxCancel(err)
pipeClose()
}
return newCatFileBatchWithCloseFunc(ctx, repoPath, cmdCatFile, stdinWriter, stdoutReader, closeFunc)
}
ret = &catFileBatchCommunicator{
func newCatFileBatchWithCloseFunc(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command,
stdinWriter gitcmd.PipeWriter, stdoutReader gitcmd.PipeReader, closeFunc func(err error),
) *catFileBatchCommunicator {
ret := &catFileBatchCommunicator{
debugGitCmd: cmdCatFile,
cancel: func() { ctxCancel(nil) },
closeFunc: closeFunc,
reqWriter: stdinWriter,
respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations
}
@ -60,8 +69,7 @@ func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Co
log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err)
// ideally here it should return the error, but it would require refactoring all callers
// so just return a dummy communicator that does nothing, almost the same behavior as before, not bad
ctxCancel(err)
pipeClose()
closeFunc(err)
return ret
}
@ -70,8 +78,7 @@ func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Co
if err != nil && !errors.Is(err, context.Canceled) {
log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err)
}
ctxCancel(err)
pipeClose()
closeFunc(err)
}()
return ret

View File

@ -4,31 +4,36 @@
package public
import (
"mime"
"strings"
"sync"
)
// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType
var wellKnownMimeTypesLower = map[string]string{
".avif": "image/avif",
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".htm": "text/html; charset=utf-8",
".html": "text/html; charset=utf-8",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json",
".mjs": "text/javascript; charset=utf-8",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".webp": "image/webp",
".xml": "text/xml; charset=utf-8",
// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`,
// see the comment of DetectWellKnownMimeType
var wellKnownMimeTypesLower = sync.OnceValue(func() map[string]string {
return map[string]string{
".avif": "image/avif",
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".htm": "text/html; charset=utf-8",
".html": "text/html; charset=utf-8",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json",
".mjs": "text/javascript; charset=utf-8",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".webp": "image/webp",
".xml": "text/xml; charset=utf-8",
// well, there are some types missing from the builtin list
".txt": "text/plain; charset=utf-8",
}
// well, there are some types missing from the builtin list
".txt": "text/plain; charset=utf-8",
}
})
// DetectWellKnownMimeType will return the mime-type for a well-known file ext name
// The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension
@ -38,5 +43,8 @@ var wellKnownMimeTypesLower = map[string]string{
// DetectWellKnownMimeType makes the Content-Type for well-known files stable.
func DetectWellKnownMimeType(ext string) string {
ext = strings.ToLower(ext)
return wellKnownMimeTypesLower[ext]
if s, ok := wellKnownMimeTypesLower()[ext]; ok {
return s
}
return mime.TypeByExtension(ext)
}

View File

@ -12,7 +12,7 @@ type Activity struct {
UserID int64 `json:"user_id"` // Receiver user
// the type of action
//
// enum: create_repo,rename_repo,star_repo,watch_repo,commit_repo,create_issue,create_pull_request,transfer_repo,push_tag,comment_issue,merge_pull_request,close_issue,reopen_issue,close_pull_request,reopen_pull_request,delete_tag,delete_branch,mirror_sync_push,mirror_sync_create,mirror_sync_delete,approve_pull_request,reject_pull_request,comment_pull,publish_release,pull_review_dismissed,pull_request_ready_for_review,auto_merge_pull_request
// enum: ["create_repo","rename_repo","star_repo","watch_repo","commit_repo","create_issue","create_pull_request","transfer_repo","push_tag","comment_issue","merge_pull_request","close_issue","reopen_issue","close_pull_request","reopen_pull_request","delete_tag","delete_branch","mirror_sync_push","mirror_sync_create","mirror_sync_delete","approve_pull_request","reject_pull_request","comment_pull","publish_release","pull_review_dismissed","pull_request_ready_for_review","auto_merge_pull_request"]
OpType string `json:"op_type"`
// The ID of the user who performed the action
ActUserID int64 `json:"act_user_id"`

View File

@ -51,7 +51,7 @@ type CreateHookOptionConfig map[string]string
// CreateHookOption options when create a hook
type CreateHookOption struct {
// required: true
// enum: dingtalk,discord,gitea,gogs,msteams,slack,telegram,feishu,wechatwork,packagist
// enum: ["dingtalk","discord","gitea","gogs","msteams","slack","telegram","feishu","wechatwork","packagist"]
// The type of the webhook to create
Type string `json:"type" binding:"Required"`
// required: true

View File

@ -14,6 +14,8 @@ import (
)
// StateType issue state type
//
// swagger:enum StateType
type StateType string
const (
@ -21,10 +23,11 @@ const (
StateOpen StateType = "open"
// StateClosed pr is closed
StateClosed StateType = "closed"
// StateAll is all
StateAll StateType = "all"
)
// StateAll is a query parameter filter value, not a valid object state.
const StateAll = "all"
// PullRequestMeta PR info if an issue is a PR
type PullRequestMeta struct {
HasMerged bool `json:"merged"`
@ -58,15 +61,11 @@ type Issue struct {
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
// deprecated
Assignee *User `json:"assignee"`
Assignees []*User `json:"assignees"`
// Whether the issue is open or closed
//
// type: string
// enum: open,closed
State StateType `json:"state"`
IsLocked bool `json:"is_locked"`
Comments int `json:"comments"`
Assignee *User `json:"assignee"`
Assignees []*User `json:"assignees"`
State StateType `json:"state"`
IsLocked bool `json:"is_locked"`
Comments int `json:"comments"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
@ -81,7 +80,8 @@ type Issue struct {
PullRequest *PullRequestMeta `json:"pull_request"`
Repo *RepositoryMeta `json:"repository"`
PinOrder int `json:"pin_order"`
PinOrder int `json:"pin_order"`
ContentVersion int `json:"content_version"`
}
// CreateIssueOption options to create one issue
@ -115,6 +115,7 @@ type EditIssueOption struct {
// swagger:strfmt date-time
Deadline *time.Time `json:"due_date"`
RemoveDeadline *bool `json:"unset_due_date"`
ContentVersion *int `json:"content_version"`
}
// EditDeadlineOption options for creating a deadline
@ -132,6 +133,8 @@ type IssueDeadline struct {
}
// IssueFormFieldType defines issue form field type, can be "markdown", "textarea", "input", "dropdown" or "checkboxes"
//
// swagger:enum IssueFormFieldType
type IssueFormFieldType string
const (
@ -168,7 +171,8 @@ func (iff IssueFormField) VisibleInContent() bool {
}
// IssueFormFieldVisible defines issue form field visible
// swagger:model
//
// swagger:enum IssueFormFieldVisible
type IssueFormFieldVisible string
const (

View File

@ -40,7 +40,7 @@ type CreateMilestoneOption struct {
// swagger:strfmt date-time
// Deadline is the due date for the milestone
Deadline *time.Time `json:"due_on"`
// enum: open,closed
// enum: ["open","closed"]
// State indicates the initial state of the milestone
State string `json:"state"`
}
@ -52,6 +52,7 @@ type EditMilestoneOption struct {
// Description provides updated details about the milestone
Description *string `json:"description"`
// State indicates the updated state of the milestone
// enum: ["open","closed"]
State *string `json:"state"`
// Deadline is the updated due date for the milestone
Deadline *time.Time `json:"due_on"`

View File

@ -40,7 +40,7 @@ type NotificationSubject struct {
// Type indicates the type of the notification subject
Type NotifySubjectType `json:"type" binding:"In(Issue,Pull,Commit,Repository)"`
// State indicates the current state of the notification subject
State StateType `json:"state"`
State NotifySubjectStateType `json:"state"`
}
// NotificationCount number of unread notifications
@ -49,7 +49,22 @@ type NotificationCount struct {
New int64 `json:"new"`
}
// NotifySubjectStateType represents the state of a notification subject
// swagger:enum NotifySubjectStateType
type NotifySubjectStateType string
const (
// NotifySubjectStateOpen is an open subject
NotifySubjectStateOpen NotifySubjectStateType = "open"
// NotifySubjectStateClosed is a closed subject
NotifySubjectStateClosed NotifySubjectStateType = "closed"
// NotifySubjectStateMerged is a merged pull request
NotifySubjectStateMerged NotifySubjectStateType = "merged"
)
// NotifySubjectType represent type of notification subject
//
// swagger:enum NotifySubjectType
type NotifySubjectType string
const (

View File

@ -60,7 +60,7 @@ type CreateOrgOption struct {
// The location of the organization
Location string `json:"location" binding:"MaxSize(50)"`
// possible values are `public` (default), `limited` or `private`
// enum: public,limited,private
// enum: ["public","limited","private"]
Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
@ -79,7 +79,7 @@ type EditOrgOption struct {
// The location of the organization
Location *string `json:"location" binding:"MaxSize(50)"`
// possible values are `public`, `limited` or `private`
// enum: public,limited,private
// enum: ["public","limited","private"]
Visibility *string `json:"visibility" binding:"In(,public,limited,private)"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access"`

View File

@ -16,7 +16,7 @@ type Team struct {
Organization *Organization `json:"organization"`
// Whether the team has access to all repositories in the organization
IncludesAllRepositories bool `json:"includes_all_repositories"`
// enum: none,read,write,admin,owner
// enum: ["none","read","write","admin","owner"]
Permission string `json:"permission"`
// example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
// Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
@ -35,7 +35,7 @@ type CreateTeamOption struct {
Description string `json:"description" binding:"MaxSize(255)"`
// Whether the team has access to all repositories in the organization
IncludesAllRepositories bool `json:"includes_all_repositories"`
// enum: read,write,admin
// enum: ["read","write","admin"]
Permission string `json:"permission"`
// example: ["repo.actions","repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.ext_wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
// Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.
@ -54,7 +54,7 @@ type EditTeamOption struct {
Description *string `json:"description" binding:"MaxSize(255)"`
// Whether the team has access to all repositories in the organization
IncludesAllRepositories *bool `json:"includes_all_repositories"`
// enum: read,write,admin
// enum: ["read","write","admin"]
Permission string `json:"permission"`
// example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"]
// Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions.

View File

@ -90,7 +90,8 @@ type PullRequest struct {
Closed *time.Time `json:"closed_at"`
// The pin order for the pull request
PinOrder int `json:"pin_order"`
PinOrder int `json:"pin_order"`
ContentVersion int `json:"content_version"`
}
// PRBranchInfo information about a branch
@ -168,6 +169,7 @@ type EditPullRequestOption struct {
RemoveDeadline *bool `json:"unset_due_date"`
// Whether to allow maintainer edits
AllowMaintainerEdit *bool `json:"allow_maintainer_edit"`
ContentVersion *int `json:"content_version"`
}
// ChangedFile store information about files affected by the pull request

View File

@ -8,6 +8,8 @@ import (
)
// ReviewStateType review state type
//
// swagger:enum ReviewStateType
type ReviewStateType string
const (
@ -21,10 +23,11 @@ const (
ReviewStateRequestChanges ReviewStateType = "REQUEST_CHANGES"
// ReviewStateRequestReview review is requested from user
ReviewStateRequestReview ReviewStateType = "REQUEST_REVIEW"
// ReviewStateUnknown state of pr is unknown
ReviewStateUnknown ReviewStateType = ""
)
// ReviewStateUnknown is an internal sentinel for unknown review state, not a valid API value.
const ReviewStateUnknown = ""
// PullReview represents a pull request review
type PullReview struct {
ID int64 `json:"id"`

View File

@ -114,7 +114,7 @@ type Repository struct {
Internal bool `json:"internal"`
MirrorInterval string `json:"mirror_interval"`
// ObjectFormatName of the underlying git repository
// enum: sha1,sha256
// enum: ["sha1","sha256"]
ObjectFormatName string `json:"object_format_name"`
// swagger:strfmt date-time
MirrorUpdated time.Time `json:"mirror_updated"`
@ -150,10 +150,10 @@ type CreateRepoOption struct {
// DefaultBranch of the repository (used when initializes and in template)
DefaultBranch string `json:"default_branch" binding:"GitRefName;MaxSize(100)"`
// TrustModel of the repository
// enum: default,collaborator,committer,collaboratorcommitter
// enum: ["default","collaborator","committer","collaboratorcommitter"]
TrustModel string `json:"trust_model"`
// ObjectFormatName of the underlying git repository, empty string for default (sha1)
// enum: sha1,sha256
// enum: ["sha1","sha256"]
ObjectFormatName string `json:"object_format_name" binding:"MaxSize(6)"`
}
@ -378,7 +378,7 @@ type MigrateRepoOptions struct {
// required: true
RepoName string `json:"repo_name" binding:"Required;AlphaDashDot;MaxSize(100)"`
// enum: git,github,gitea,gitlab,gogs,onedev,gitbucket,codebase,codecommit
// enum: ["git","github","gitea","gitlab","gogs","onedev","gitbucket","codebase","codecommit"]
Service string `json:"service"`
AuthUsername string `json:"auth_username"`
AuthPassword string `json:"auth_password"`

View File

@ -5,7 +5,7 @@ package structs
// AddCollaboratorOption options when adding a user as a collaborator of a repository
type AddCollaboratorOption struct {
// enum: read,write,admin
// enum: ["read","write","admin"]
// Permission level to grant the collaborator
Permission *string `json:"permission"`
}

View File

@ -72,7 +72,7 @@ type ChangeFileOperation struct {
// indicates what to do with the file: "create" for creating a new file, "update" for updating an existing file,
// "upload" for creating or updating a file, "rename" for renaming a file, and "delete" for deleting an existing file.
// required: true
// enum: create,update,upload,rename,delete
// enum: ["create","update","upload","rename","delete"]
Operation string `json:"operation" binding:"Required"`
// path to the existing or new file
// required: true

View File

@ -40,7 +40,7 @@ func ListRunners(ctx *context.APIContext) {
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
// "$ref": "#/responses/RunnerList"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -63,7 +63,7 @@ func GetRunner(ctx *context.APIContext) {
// required: true
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -115,7 +115,7 @@ func UpdateRunner(ctx *context.APIContext) {
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":

View File

@ -11,11 +11,9 @@
//
// Consumes:
// - application/json
// - text/plain
//
// Produces:
// - application/json
// - text/html
//
// Security:
// - BasicAuth :

View File

@ -492,7 +492,7 @@ func (Action) ListRunners(ctx *context.APIContext) {
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
// "$ref": "#/responses/RunnerList"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -520,7 +520,7 @@ func (Action) GetRunner(ctx *context.APIContext) {
// required: true
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -582,7 +582,7 @@ func (Action) UpdateRunner(ctx *context.APIContext) {
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":

View File

@ -561,7 +561,7 @@ func (Action) ListRunners(ctx *context.APIContext) {
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
// "$ref": "#/responses/RunnerList"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -594,7 +594,7 @@ func (Action) GetRunner(ctx *context.APIContext) {
// required: true
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -666,7 +666,7 @@ func (Action) UpdateRunner(ctx *context.APIContext) {
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -1192,7 +1192,7 @@ func GetWorkflowRun(ctx *context.APIContext) {
// - name: run
// in: path
// description: id of the run
// type: string
// type: integer
// required: true
// responses:
// "200":

View File

@ -726,6 +726,9 @@ func EditIssue(ctx *context.APIContext) {
// swagger:operation PATCH /repos/{owner}/{repo}/issues/{index} issue issueEditIssue
// ---
// summary: Edit an issue. If using deadline only the date will be taken into account, and time of day ignored.
// description: |
// Pass `content_version` to enable optimistic locking on body edits.
// If the version doesn't match the current value, the request fails with 409 Conflict.
// consumes:
// - application/json
// produces:
@ -785,6 +788,15 @@ func EditIssue(ctx *context.APIContext) {
return
}
// Fail fast: if content_version is provided and already stale, reject
// before any mutations. The DB-level check in ChangeContent still
// handles concurrent requests.
// TODO: wrap all mutations in a transaction to fully prevent partial writes.
if form.ContentVersion != nil && *form.ContentVersion != issue.ContentVersion {
ctx.APIError(http.StatusConflict, issues_model.ErrIssueAlreadyChanged)
return
}
if len(form.Title) > 0 {
err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title)
if err != nil {
@ -793,10 +805,14 @@ func EditIssue(ctx *context.APIContext) {
}
}
if form.Body != nil {
err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, issue.ContentVersion)
contentVersion := issue.ContentVersion
if form.ContentVersion != nil {
contentVersion = *form.ContentVersion
}
err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, contentVersion)
if err != nil {
if errors.Is(err, issues_model.ErrIssueAlreadyChanged) {
ctx.APIError(http.StatusBadRequest, err)
ctx.APIError(http.StatusConflict, err)
return
}

View File

@ -175,7 +175,7 @@ func DeleteIssueCommentReaction(ctx *context.APIContext) {
// schema:
// "$ref": "#/definitions/EditReactionOption"
// responses:
// "200":
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
@ -248,8 +248,7 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp
ctx.APIErrorInternal(err)
return
}
// ToDo respond 204
ctx.Status(http.StatusOK)
ctx.Status(http.StatusNoContent)
}
}
@ -408,7 +407,7 @@ func DeleteIssueReaction(ctx *context.APIContext) {
// schema:
// "$ref": "#/definitions/EditReactionOption"
// responses:
// "200":
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
@ -464,7 +463,6 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i
ctx.APIErrorInternal(err)
return
}
// ToDo respond 204
ctx.Status(http.StatusOK)
ctx.Status(http.StatusNoContent)
}
}

View File

@ -657,6 +657,15 @@ func EditPullRequest(ctx *context.APIContext) {
return
}
// Fail fast: if content_version is provided and already stale, reject
// before any mutations. The DB-level check in ChangeContent still
// handles concurrent requests.
// TODO: wrap all mutations in a transaction to fully prevent partial writes.
if form.ContentVersion != nil && *form.ContentVersion != issue.ContentVersion {
ctx.APIError(http.StatusConflict, issues_model.ErrIssueAlreadyChanged)
return
}
if len(form.Title) > 0 {
err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title)
if err != nil {
@ -665,10 +674,14 @@ func EditPullRequest(ctx *context.APIContext) {
}
}
if form.Body != nil {
err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, issue.ContentVersion)
contentVersion := issue.ContentVersion
if form.ContentVersion != nil {
contentVersion = *form.ContentVersion
}
err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, contentVersion)
if err != nil {
if errors.Is(err, issues_model.ErrIssueAlreadyChanged) {
ctx.APIError(http.StatusBadRequest, err)
ctx.APIError(http.StatusConflict, err)
return
}
@ -898,6 +911,8 @@ func MergePullRequest(ctx *context.APIContext) {
// responses:
// "200":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "405":

View File

@ -40,7 +40,7 @@ func ListRunners(ctx *context.APIContext) {
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
// "$ref": "#/responses/RunnerList"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -63,7 +63,7 @@ func GetRunner(ctx *context.APIContext) {
// required: true
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":
@ -115,7 +115,7 @@ func UpdateRunner(ctx *context.APIContext) {
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "$ref": "#/responses/Runner"
// "400":
// "$ref": "#/responses/error"
// "404":

View File

@ -103,6 +103,11 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
CreateCommitStatusForRunJobs(ctx, job.Run, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, actionTask)
// job.Run is loaded inside the transaction before UpdateRunJob sets run.Started,
// so Started is zero only on the very first pick-up of that run.
if job.Run.Started.IsZero() {
NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
}
return task, true, nil
}

View File

@ -62,7 +62,8 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss
Updated: issue.UpdatedUnix.AsTime(),
PinOrder: util.Iif(issue.PinOrder == -1, 0, issue.PinOrder), // -1 means loaded with no pin order
TimeEstimate: issue.TimeEstimate,
TimeEstimate: issue.TimeEstimate,
ContentVersion: issue.ContentVersion,
}
if issue.Repo != nil {

View File

@ -47,7 +47,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
result.Subject.Title = n.Issue.Title
result.Subject.URL = n.Issue.APIURL(ctx)
result.Subject.HTMLURL = n.Issue.HTMLURL(ctx)
result.Subject.State = n.Issue.State()
result.Subject.State = api.NotifySubjectStateType(n.Issue.State())
comment, err := n.Issue.GetLastComment(ctx)
if err == nil && comment != nil {
result.Subject.LatestCommentURL = comment.APIURL(ctx)
@ -60,7 +60,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
result.Subject.Title = n.Issue.Title
result.Subject.URL = n.Issue.APIURL(ctx)
result.Subject.HTMLURL = n.Issue.HTMLURL(ctx)
result.Subject.State = n.Issue.State()
result.Subject.State = api.NotifySubjectStateType(n.Issue.State())
comment, err := n.Issue.GetLastComment(ctx)
if err == nil && comment != nil {
result.Subject.LatestCommentURL = comment.APIURL(ctx)
@ -70,7 +70,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
if err := n.Issue.LoadPullRequest(ctx); err == nil &&
n.Issue.PullRequest != nil &&
n.Issue.PullRequest.HasMerged {
result.Subject.State = "merged"
result.Subject.State = api.NotifySubjectStateMerged
}
}
case activities_model.NotificationSourceCommit:

View File

@ -7,12 +7,15 @@ import (
"testing"
activities_model "code.gitea.io/gitea/models/activities"
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/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToNotificationThreadIncludesRepoForAccessibleUser(t *testing.T) {
@ -36,6 +39,78 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
assert.Nil(t, thread.Repository)
}
func TestToNotificationThread(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("issue notification", func(t *testing.T) {
// Notification 1: source=issue, issue_id=1, status=unread
n := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 1})
require.NoError(t, n.LoadAttributes(t.Context()))
thread := ToNotificationThread(t.Context(), n)
assert.Equal(t, int64(1), thread.ID)
assert.True(t, thread.Unread)
assert.False(t, thread.Pinned)
require.NotNil(t, thread.Subject)
assert.Equal(t, api.NotifySubjectIssue, thread.Subject.Type)
assert.Equal(t, api.NotifySubjectStateOpen, thread.Subject.State)
})
t.Run("pinned notification", func(t *testing.T) {
// Notification 3: status=pinned
n := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 3})
require.NoError(t, n.LoadAttributes(t.Context()))
thread := ToNotificationThread(t.Context(), n)
assert.False(t, thread.Unread)
assert.True(t, thread.Pinned)
})
t.Run("merged pull request returns merged state", func(t *testing.T) {
// Issue 2 is a pull request; pull_request 1 has has_merged=true.
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
n := &activities_model.Notification{
ID: 999,
UserID: 2,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourcePullRequest,
IssueID: issue.ID,
Issue: issue,
Repository: repo,
}
thread := ToNotificationThread(t.Context(), n)
require.NotNil(t, thread.Subject)
assert.Equal(t, api.NotifySubjectPull, thread.Subject.Type)
assert.Equal(t, api.NotifySubjectStateMerged, thread.Subject.State)
})
t.Run("open pull request returns open state", func(t *testing.T) {
// Issue 3 is a pull request; pull_request 2 has has_merged=false.
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
n := &activities_model.Notification{
ID: 998,
UserID: 2,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourcePullRequest,
IssueID: issue.ID,
Issue: issue,
Repository: repo,
}
thread := ToNotificationThread(t.Context(), n)
require.NotNil(t, thread.Subject)
assert.Equal(t, api.NotifySubjectPull, thread.Subject.Type)
assert.Equal(t, api.NotifySubjectStateOpen, thread.Subject.State)
})
}
func newRepoNotification(t *testing.T, repoID, userID int64) *activities_model.Notification {
t.Helper()

View File

@ -97,6 +97,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
Created: pr.Issue.CreatedUnix.AsTimePtr(),
Updated: pr.Issue.UpdatedUnix.AsTimePtr(),
PinOrder: util.Iif(apiIssue.PinOrder == -1, 0, apiIssue.PinOrder),
ContentVersion: apiIssue.ContentVersion,
// output "[]" rather than null to align to github outputs
RequestedReviewers: []*api.User{},
@ -372,6 +373,7 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs
Created: pr.Issue.CreatedUnix.AsTimePtr(),
Updated: pr.Issue.UpdatedUnix.AsTimePtr(),
PinOrder: util.Iif(apiIssue.PinOrder == -1, 0, apiIssue.PinOrder),
ContentVersion: apiIssue.ContentVersion,
AllowMaintainerEdit: pr.AllowMaintainerEdit,

View File

@ -9,6 +9,7 @@ import (
git_model "code.gitea.io/gitea/models/git"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/commitstatus"
api "code.gitea.io/gitea/modules/structs"
)
@ -55,6 +56,8 @@ func ToCombinedStatus(ctx context.Context, commitID string, statuses []*git_mode
if combinedStatus != nil {
status.Statuses = ToCommitStatuses(ctx, statuses)
status.State = combinedStatus.State
} else {
status.State = commitstatus.CommitStatusPending
}
return &status
}

View File

@ -521,7 +521,7 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors)
// swagger:model MergePullRequestOption
type MergePullRequestForm struct {
// required: true
// enum: merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged
// enum: ["merge","rebase","rebase-merge","squash","fast-forward-only","manually-merged"]
Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged)"`
MergeTitleField string
MergeMessageField string

View File

@ -44,7 +44,7 @@ parts:
source: .
stage-packages: [ git, sqlite3, openssh-client ]
build-packages: [ git, libpam0g-dev, libsqlite3-dev, build-essential]
build-snaps: [ go/1.25/stable, node/22/stable ]
build-snaps: [ go/1.26/stable, node/24/stable ]
build-environment:
- LDFLAGS: ""
override-pull: |

View File

@ -1,11 +1,9 @@
{
"consumes": [
"application/json",
"text/plain"
"application/json"
],
"produces": [
"application/json",
"text/html"
"application/json"
],
"schemes": [
"https",
@ -86,7 +84,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunnersResponse"
"$ref": "#/responses/RunnerList"
},
"400": {
"$ref": "#/responses/error"
@ -135,7 +133,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -205,7 +203,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -2008,7 +2006,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunnersResponse"
"$ref": "#/responses/RunnerList"
},
"400": {
"$ref": "#/responses/error"
@ -2073,7 +2071,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -2157,7 +2155,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -4989,7 +4987,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunnersResponse"
"$ref": "#/responses/RunnerList"
},
"400": {
"$ref": "#/responses/error"
@ -5068,7 +5066,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -5166,7 +5164,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -5287,7 +5285,7 @@
"required": true
},
{
"type": "string",
"type": "integer",
"description": "id of the run",
"name": "run",
"in": "path",
@ -10230,7 +10228,7 @@
}
],
"responses": {
"200": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
@ -10364,6 +10362,7 @@
}
},
"patch": {
"description": "Pass `content_version` to enable optimistic locking on body edits.\nIf the version doesn't match the current value, the request fails with 409 Conflict.\n",
"consumes": [
"application/json"
],
@ -11969,7 +11968,7 @@
}
],
"responses": {
"200": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
@ -14495,6 +14494,9 @@
"200": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
},
@ -18670,7 +18672,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunnersResponse"
"$ref": "#/responses/RunnerList"
},
"400": {
"$ref": "#/responses/error"
@ -18719,7 +18721,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -18789,7 +18791,7 @@
],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunner"
"$ref": "#/responses/Runner"
},
"400": {
"$ref": "#/responses/error"
@ -23887,7 +23889,16 @@
"x-go-name": "CommitID"
},
"event": {
"$ref": "#/definitions/ReviewStateType"
"type": "string",
"enum": [
"APPROVED",
"PENDING",
"COMMENT",
"REQUEST_CHANGES",
"REQUEST_REVIEW"
],
"x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user",
"x-go-name": "Event"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
@ -24756,6 +24767,11 @@
"type": "string",
"x-go-name": "Body"
},
"content_version": {
"type": "integer",
"format": "int64",
"x-go-name": "ContentVersion"
},
"due_date": {
"type": "string",
"format": "date-time",
@ -24835,6 +24851,10 @@
"state": {
"description": "State indicates the updated state of the milestone",
"type": "string",
"enum": [
"open",
"closed"
],
"x-go-name": "State"
},
"title": {
@ -24924,6 +24944,11 @@
"type": "string",
"x-go-name": "Body"
},
"content_version": {
"type": "integer",
"format": "int64",
"x-go-name": "ContentVersion"
},
"due_date": {
"type": "string",
"format": "date-time",
@ -26209,6 +26234,11 @@
"format": "int64",
"x-go-name": "Comments"
},
"content_version": {
"type": "integer",
"format": "int64",
"x-go-name": "ContentVersion"
},
"created_at": {
"type": "string",
"format": "date-time",
@ -26272,7 +26302,13 @@
"$ref": "#/definitions/RepositoryMeta"
},
"state": {
"$ref": "#/definitions/StateType"
"type": "string",
"enum": [
"open",
"closed"
],
"x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
"x-go-name": "State"
},
"time_estimate": {
"type": "integer",
@ -26373,7 +26409,16 @@
"x-go-name": "ID"
},
"type": {
"$ref": "#/definitions/IssueFormFieldType"
"type": "string",
"enum": [
"markdown",
"textarea",
"input",
"dropdown",
"checkboxes"
],
"x-go-enum-desc": "markdown IssueFormFieldTypeMarkdown\ntextarea IssueFormFieldTypeTextarea\ninput IssueFormFieldTypeInput\ndropdown IssueFormFieldTypeDropdown\ncheckboxes IssueFormFieldTypeCheckboxes",
"x-go-name": "Type"
},
"validations": {
"type": "object",
@ -26383,23 +26428,18 @@
"visible": {
"type": "array",
"items": {
"$ref": "#/definitions/IssueFormFieldVisible"
"type": "string",
"enum": [
"form",
"content"
],
"x-go-enum-desc": "form IssueFormFieldVisibleForm\ncontent IssueFormFieldVisibleContent"
},
"x-go-name": "Visible"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"IssueFormFieldType": {
"type": "string",
"title": "IssueFormFieldType defines issue form field type, can be \"markdown\", \"textarea\", \"input\", \"dropdown\" or \"checkboxes\"",
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"IssueFormFieldVisible": {
"description": "IssueFormFieldVisible defines issue form field visible",
"type": "string",
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"IssueLabelsOption": {
"description": "IssueLabelsOption a collection of labels",
"type": "object",
@ -26897,7 +26937,14 @@
"x-go-name": "OpenIssues"
},
"state": {
"$ref": "#/definitions/StateType"
"description": "State indicates if the milestone is open or closed\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
"type": "string",
"enum": [
"open",
"closed"
],
"x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
"x-go-name": "State"
},
"title": {
"description": "Title is the title of the milestone",
@ -27111,7 +27158,15 @@
"x-go-name": "LatestCommentURL"
},
"state": {
"$ref": "#/definitions/StateType"
"description": "State indicates the current state of the notification subject\nopen NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request",
"type": "string",
"enum": [
"open",
"closed",
"merged"
],
"x-go-enum-desc": "open NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request",
"x-go-name": "State"
},
"title": {
"description": "Title is the title of the notification subject",
@ -27119,7 +27174,16 @@
"x-go-name": "Title"
},
"type": {
"$ref": "#/definitions/NotifySubjectType"
"description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification",
"type": "string",
"enum": [
"Issue",
"Pull",
"Commit",
"Repository"
],
"x-go-enum-desc": "Issue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification",
"x-go-name": "Type"
},
"url": {
"description": "URL is the API URL for the notification subject",
@ -27169,11 +27233,6 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"NotifySubjectType": {
"description": "NotifySubjectType represent type of notification subject",
"type": "string",
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"OAuth2Application": {
"type": "object",
"title": "OAuth2Application represents an OAuth2 application.",
@ -27682,6 +27741,11 @@
"format": "int64",
"x-go-name": "Comments"
},
"content_version": {
"type": "integer",
"format": "int64",
"x-go-name": "ContentVersion"
},
"created_at": {
"type": "string",
"format": "date-time",
@ -27806,7 +27870,14 @@
"x-go-name": "ReviewComments"
},
"state": {
"$ref": "#/definitions/StateType"
"description": "The current state of the pull request\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
"type": "string",
"enum": [
"open",
"closed"
],
"x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
"x-go-name": "State"
},
"title": {
"description": "The title of the pull request",
@ -27898,7 +27969,16 @@
"x-go-name": "Stale"
},
"state": {
"$ref": "#/definitions/ReviewStateType"
"type": "string",
"enum": [
"APPROVED",
"PENDING",
"COMMENT",
"REQUEST_CHANGES",
"REQUEST_REVIEW"
],
"x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user",
"x-go-name": "State"
},
"submitted_at": {
"type": "string",
@ -28635,11 +28715,6 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"ReviewStateType": {
"description": "ReviewStateType review state type",
"type": "string",
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"RunDetails": {
"description": "RunDetails returns workflow_dispatch runid and url",
"type": "object",
@ -28714,11 +28789,6 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"StateType": {
"description": "StateType issue state type",
"type": "string",
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"StopWatch": {
"description": "StopWatch represent a running stopwatch",
"type": "object",
@ -28772,7 +28842,16 @@
"x-go-name": "Body"
},
"event": {
"$ref": "#/definitions/ReviewStateType"
"type": "string",
"enum": [
"APPROVED",
"PENDING",
"COMMENT",
"REQUEST_CHANGES",
"REQUEST_REVIEW"
],
"x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user",
"x-go-name": "Event"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"

View File

@ -44,7 +44,7 @@ func TestAPIIssuesReactions(t *testing.T) {
req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
Reaction: "zzz",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
MakeRequest(t, req, http.StatusNoContent)
// Add allowed reaction
req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
@ -111,7 +111,7 @@ func TestAPICommentReactions(t *testing.T) {
req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
Reaction: "eyes",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
MakeRequest(t, req, http.StatusNoContent)
t.Run("UnrelatedCommentID", func(t *testing.T) {
// Using the ID of a comment that does not belong to the repository must fail

View File

@ -25,9 +25,19 @@ import (
"github.com/stretchr/testify/assert"
)
func TestAPIListIssues(t *testing.T) {
func TestAPIIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("ListIssues", testAPIListIssues)
t.Run("ListIssuesPublicOnly", testAPIListIssuesPublicOnly)
t.Run("SearchIssues", testAPISearchIssues)
t.Run("SearchIssuesWithLabels", testAPISearchIssuesWithLabels)
t.Run("EditIssue", testAPIEditIssue)
t.Run("IssueContentVersion", testAPIIssueContentVersion)
t.Run("CreateIssue", testAPICreateIssue)
t.Run("CreateIssueParallel", testAPICreateIssueParallel)
}
func testAPIListIssues(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
@ -75,9 +85,7 @@ func TestAPIListIssues(t *testing.T) {
}
}
func TestAPIListIssuesPublicOnly(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testAPIListIssuesPublicOnly(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo1.OwnerID})
@ -103,8 +111,7 @@ func TestAPIListIssuesPublicOnly(t *testing.T) {
MakeRequest(t, req, http.StatusForbidden)
}
func TestAPICreateIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testAPICreateIssue(t *testing.T) {
const body, title = "apiTestBody", "apiTestTitle"
repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
@ -142,9 +149,7 @@ func TestAPICreateIssue(t *testing.T) {
MakeRequest(t, req, http.StatusForbidden)
}
func TestAPICreateIssueParallel(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testAPICreateIssueParallel(t *testing.T) {
// FIXME: There seems to be a bug in github.com/mattn/go-sqlite3 with sqlite_unlock_notify, when doing concurrent writes to the same database,
// some requests may get stuck in "go-sqlite3.(*SQLiteRows).Next", "go-sqlite3.(*SQLiteStmt).exec" and "go-sqlite3.unlock_notify_wait",
// because the "unlock_notify_wait" never returns and the internal lock never gets releases.
@ -152,7 +157,7 @@ func TestAPICreateIssueParallel(t *testing.T) {
// The trigger is: a previous test created issues and made the real issue indexer queue start processing, then this test does concurrent writing.
// Adding this "Sleep" makes go-sqlite3 "finish" some internal operations before concurrent writes and then won't get stuck.
// To reproduce: make a new test run these 2 tests enough times:
// > func TestBug() { for i := 0; i < 100; i++ { testAPICreateIssue(t); testAPICreateIssueParallel(t) } }
// > func testBug() { for i := 0; i < 100; i++ { testAPICreateIssue(t); testAPICreateIssueParallel(t) } }
// Usually the test gets stuck in fewer than 10 iterations without this "sleep".
time.Sleep(time.Second)
@ -197,9 +202,7 @@ func TestAPICreateIssueParallel(t *testing.T) {
wg.Wait()
}
func TestAPIEditIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testAPIEditIssue(t *testing.T) {
issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10})
repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
@ -263,8 +266,7 @@ func TestAPIEditIssue(t *testing.T) {
assert.Equal(t, title, issueAfter.Title)
}
func TestAPISearchIssues(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testAPISearchIssues(t *testing.T) {
defer test.MockVariableValue(&setting.API.DefaultPagingNum, 20)()
expectedIssueCount := 20 // 20 is from the fixtures
@ -391,9 +393,7 @@ func TestAPISearchIssues(t *testing.T) {
assert.Len(t, apiIssues, 3)
}
func TestAPISearchIssuesWithLabels(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testAPISearchIssuesWithLabels(t *testing.T) {
// as this API was used in the frontend, it uses UI page size
expectedIssueCount := min(20, setting.UI.IssuePagingNum) // 20 is from the fixtures
@ -448,3 +448,56 @@ func TestAPISearchIssuesWithLabels(t *testing.T) {
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
}
func testAPIIssueContentVersion(t *testing.T) {
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repo.Name, issue.Index)
t.Run("ResponseIncludesContentVersion", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", urlStr).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
apiIssue := DecodeJSON(t, resp, &api.Issue{})
assert.GreaterOrEqual(t, apiIssue.ContentVersion, 0)
})
t.Run("EditWithCorrectVersion", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", urlStr).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var before api.Issue
DecodeJSON(t, resp, &before)
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
Body: new("updated body with correct version"),
ContentVersion: new(before.ContentVersion),
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
after := DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, "updated body with correct version", after.Body)
assert.Greater(t, after.ContentVersion, before.ContentVersion)
})
t.Run("EditWithWrongVersion", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
Body: new("should fail"),
ContentVersion: new(99999),
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusConflict)
})
t.Run("EditWithoutVersion", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
Body: new("edit without version succeeds"),
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
})
}

View File

@ -1401,7 +1401,10 @@ jobs:
assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName)
runID := webhookData.payloads[0].WorkflowRun.ID
// The first runner to pick up a task fires in_progress (Started.IsZero() is true only once per run).
// The second runner picking up an independent job does not fire another in_progress event.
for _, runner := range runners {
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@ -1411,38 +1414,51 @@ jobs:
// Call cancel ui api
// Only a web UI API exists for cancelling workflow runs, so use the UI endpoint.
cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", webhookData.payloads[0].WorkflowRun.ID)
cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", runID)
req := NewRequest(t, "POST", cancelURL)
session.MakeRequest(t, req, http.StatusOK)
assert.Len(t, webhookData.payloads, 2)
assert.Len(t, webhookData.payloads, 3)
// 4. Validate the second webhook payload
// 4. Validate the second webhook payload (in_progress, fired when the first runner picked up a job)
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Equal(t, "completed", webhookData.payloads[1].Action)
assert.Equal(t, "in_progress", webhookData.payloads[1].Action)
assert.Equal(t, "in_progress", webhookData.payloads[1].WorkflowRun.Status)
assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event)
assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status)
assert.Equal(t, runID, webhookData.payloads[1].WorkflowRun.ID)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[1].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName)
// Call rerun ui api
// Only a web UI API exists for rerunning workflow runs, so use the UI endpoint.
rerunURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/rerun", webhookData.payloads[0].WorkflowRun.ID)
req = NewRequest(t, "POST", rerunURL)
session.MakeRequest(t, req, http.StatusOK)
assert.Len(t, webhookData.payloads, 3)
// 5. Validate the third webhook payload
// 5. Validate the third webhook payload (completed, fired after cancel)
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Equal(t, "requested", webhookData.payloads[2].Action)
assert.Equal(t, "queued", webhookData.payloads[2].WorkflowRun.Status)
assert.Equal(t, "completed", webhookData.payloads[2].Action)
assert.Equal(t, "push", webhookData.payloads[2].WorkflowRun.Event)
assert.Equal(t, "completed", webhookData.payloads[2].WorkflowRun.Status)
assert.Equal(t, runID, webhookData.payloads[2].WorkflowRun.ID)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[2].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[2].Repo.FullName)
// Call rerun ui api
// Only a web UI API exists for rerunning workflow runs, so use the UI endpoint.
rerunURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/rerun", runID)
req = NewRequest(t, "POST", rerunURL)
session.MakeRequest(t, req, http.StatusOK)
assert.Len(t, webhookData.payloads, 4)
// 6. Validate the fourth webhook payload (requested, fired after rerun)
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Equal(t, "requested", webhookData.payloads[3].Action)
assert.Equal(t, "queued", webhookData.payloads[3].WorkflowRun.Status)
assert.Equal(t, "push", webhookData.payloads[3].WorkflowRun.Event)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[3].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[3].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[3].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[3].Repo.FullName)
}
func testWorkflowRunEventsOnCancellingAbandonedRun(t *testing.T, webhookData *workflowRunWebhook, allJobsAbandoned bool) {
@ -1572,13 +1588,28 @@ jobs:
err = actions.CancelAbandonedJobs(ctx)
assert.NoError(t, err)
assert.Len(t, webhookData.payloads, 2)
assert.Equal(t, "completed", webhookData.payloads[1].Action)
assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status)
assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha)
assert.Equal(t, repoName, webhookData.payloads[1].Repo.Name)
assert.Equal(t, "user2/"+repoName, webhookData.payloads[1].Repo.FullName)
if allJobsAbandoned {
// No runner picked up any task, so no in_progress event was fired.
assert.Len(t, webhookData.payloads, 2)
assert.Equal(t, "completed", webhookData.payloads[1].Action)
assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status)
assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha)
assert.Equal(t, repoName, webhookData.payloads[1].Repo.Name)
assert.Equal(t, "user2/"+repoName, webhookData.payloads[1].Repo.FullName)
} else {
// The first runner pick-up fired in_progress before the run was abandoned.
assert.Len(t, webhookData.payloads, 3)
assert.Equal(t, "in_progress", webhookData.payloads[1].Action)
assert.Equal(t, "in_progress", webhookData.payloads[1].WorkflowRun.Status)
assert.Equal(t, "completed", webhookData.payloads[2].Action)
assert.Equal(t, "completed", webhookData.payloads[2].WorkflowRun.Status)
assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha)
assert.Equal(t, repoName, webhookData.payloads[2].Repo.Name)
assert.Equal(t, "user2/"+repoName, webhookData.payloads[2].Repo.FullName)
}
}
func testWorkflowRunOnStoppingEndlessTasksForMultipleRuns(t *testing.T, webhookData *workflowRunWebhook) {
@ -1741,20 +1772,23 @@ jobs:
// 7. validate the webhook is triggered
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Len(t, webhookData.payloads, 3)
assert.Equal(t, "completed", webhookData.payloads[1].Action)
assert.Len(t, webhookData.payloads, 4)
// payloads[1] is the in_progress event fired when the runner picked up wf1-job
assert.Equal(t, "in_progress", webhookData.payloads[1].Action)
assert.Equal(t, "in_progress", webhookData.payloads[1].WorkflowRun.Status)
assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event)
assert.Equal(t, "completed", webhookData.payloads[2].Action)
assert.Equal(t, "push", webhookData.payloads[2].WorkflowRun.Event)
// 3. validate the webhook is triggered
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Len(t, webhookData.payloads, 3)
assert.Equal(t, "requested", webhookData.payloads[2].Action)
assert.Equal(t, "queued", webhookData.payloads[2].WorkflowRun.Status)
assert.Equal(t, "workflow_run", webhookData.payloads[2].WorkflowRun.Event)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[2].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[2].Repo.FullName)
// 8. validate the webhook is triggered (requested, wf2 triggered by wf1 completion)
assert.Len(t, webhookData.payloads, 4)
assert.Equal(t, "requested", webhookData.payloads[3].Action)
assert.Equal(t, "queued", webhookData.payloads[3].WorkflowRun.Status)
assert.Equal(t, "workflow_run", webhookData.payloads[3].WorkflowRun.Event)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[3].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[3].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[3].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[3].Repo.FullName)
}
func testWebhookWorkflowRunDepthLimit(t *testing.T, webhookData *workflowRunWebhook) {

View File

@ -24,8 +24,8 @@
.markup .anchor {
float: left;
padding-right: 4px;
margin-left: -20px;
padding-inline-end: 4px;
margin-inline-start: -20px;
color: inherit;
}
@ -151,7 +151,7 @@ In markup content, we always use bottom margin for all elements */
.markup ul,
.markup ol {
padding-left: 2em;
padding-inline-start: 2em;
}
.markup ul.no-list,
@ -173,13 +173,14 @@ In markup content, we always use bottom margin for all elements */
}
.markup .task-list-item input[type="checkbox"] {
margin: 0 .6em .25em -1.4em;
margin-bottom: 0.25em;
margin-inline: -1.4em 0.6em;
vertical-align: middle;
padding: 0;
}
.markup .task-list-item input[type="checkbox"] + p {
margin-left: -0.2em;
margin-inline-start: -0.2em;
display: inline;
}
@ -192,7 +193,7 @@ In markup content, we always use bottom margin for all elements */
}
.markup input[type="checkbox"] {
margin-right: .25em;
margin-inline-end: .25em;
margin-bottom: .25em;
cursor: default;
opacity: 1 !important; /* override fomantic on edit preview */
@ -239,7 +240,7 @@ In markup content, we always use bottom margin for all elements */
}
.markup blockquote {
margin-left: 0;
margin-inline-start: 0;
padding: 0 15px;
color: var(--color-text-light-2);
border-left: 0.25em solid var(--color-secondary);
@ -318,12 +319,12 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
.markup img[align="right"],
.markup video[align="right"] {
padding-left: 20px;
padding-inline-start: 20px;
}
.markup img[align="left"],
.markup video[align="left"] {
padding-right: 28px;
padding-inline-end: 28px;
}
.markup span.frame {
@ -395,7 +396,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
.markup span.float-left {
display: block;
float: left;
margin-right: 13px;
margin-inline-end: 13px;
overflow: hidden;
}
@ -406,7 +407,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
.markup span.float-right {
display: block;
float: right;
margin-left: 13px;
margin-inline-start: 13px;
overflow: hidden;
}
@ -508,7 +509,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
.markup .ui.list .list,
.markup ol.ui.list ol,
.markup ul.ui.list ul {
padding-left: 2em;
padding-inline-start: 2em;
}
.markup details.frontmatter-content summary {

View File

@ -10,6 +10,7 @@ import {
} from './EditorUpload.ts';
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
import {renderPreviewPanelContent} from '../repo-editor.ts';
import {toggleTasklistCheckbox} from '../../markup/tasklist.ts';
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
import {initTextExpander} from './TextExpander.ts';
import {showErrorToast} from '../../modules/toast.ts';
@ -236,6 +237,20 @@ export class ComboMarkdownEditor {
const response = await POST(this.previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent(panelPreviewer, data);
// enable task list checkboxes in preview and sync state back to the editor
for (const checkbox of panelPreviewer.querySelectorAll<HTMLInputElement>('.task-list-item input[type=checkbox]')) {
checkbox.disabled = false;
checkbox.addEventListener('input', () => {
const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1;
const newContent = toggleTasklistCheckbox(this.value(), position, checkbox.checked);
if (newContent === null) {
checkbox.checked = !checkbox.checked;
return;
}
this.value(newContent);
triggerEditorContentChanged(this.container);
});
}
});
}

View File

@ -0,0 +1,9 @@
import {toggleTasklistCheckbox} from './tasklist.ts';
test('toggleTasklistCheckbox', () => {
expect(toggleTasklistCheckbox('- [ ] task', 3, true)).toEqual('- [x] task');
expect(toggleTasklistCheckbox('- [x] task', 3, false)).toEqual('- [ ] task');
expect(toggleTasklistCheckbox('- [ ] task', 0, true)).toBeNull();
expect(toggleTasklistCheckbox('- [ ] task', 99, true)).toBeNull();
expect(toggleTasklistCheckbox('😀 - [ ] task', 8, true)).toEqual('😀 - [x] task');
});

View File

@ -3,6 +3,23 @@ import {showErrorToast} from '../modules/toast.ts';
const preventListener = (e: Event) => e.preventDefault();
/**
* Toggle a task list checkbox in markdown content.
* `position` is the byte offset of the space or `x` character inside `[ ]`.
* Returns the updated content, or null if the position is invalid.
*/
export function toggleTasklistCheckbox(content: string, position: number, checked: boolean): string | null {
const buffer = new TextEncoder().encode(content);
// Indexes may fall off the ends and return undefined.
if (buffer[position - 1] !== '['.charCodeAt(0) ||
buffer[position] !== ' '.charCodeAt(0) && buffer[position] !== 'x'.charCodeAt(0) ||
buffer[position + 1] !== ']'.charCodeAt(0)) {
return null;
}
buffer[position] = checked ? 'x'.charCodeAt(0) : ' '.charCodeAt(0);
return new TextDecoder().decode(buffer);
}
/**
* Attaches `input` handlers to markdown rendered tasklist checkboxes in comments.
*
@ -23,24 +40,17 @@ export function initMarkupTasklist(elMarkup: HTMLElement): void {
checkbox.setAttribute('data-editable', 'true');
checkbox.addEventListener('input', async () => {
const checkboxCharacter = checkbox.checked ? 'x' : ' ';
const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1;
const rawContent = container.querySelector('.raw-content')!;
const oldContent = rawContent.textContent;
const encoder = new TextEncoder();
const buffer = encoder.encode(oldContent);
// Indexes may fall off the ends and return undefined.
if (buffer[position - 1] !== '['.codePointAt(0) ||
buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) ||
buffer[position + 1] !== ']'.codePointAt(0)) {
// Position is probably wrong. Revert and don't allow change.
const newContent = toggleTasklistCheckbox(oldContent, position, checkbox.checked);
if (newContent === null) {
// Position is probably wrong. Revert and don't allow change.
checkbox.checked = !checkbox.checked;
throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`);
}
buffer.set(encoder.encode(checkboxCharacter), position);
const newContent = new TextDecoder().decode(buffer);
if (newContent === oldContent) {
return;