diff --git a/flake.lock b/flake.lock index 8c7ac0c196..246cfd4e79 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/flake.nix b/flake.nix index 6fb3891963..7b9fbb193c 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/modules/git/catfile_batch_reader.go b/modules/git/catfile_batch_reader.go index 8a0b342079..0c8fc740be 100644 --- a/modules/git/catfile_batch_reader.go +++ b/modules/git/catfile_batch_reader.go @@ -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 diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index fef85d77cb..fa4691c6a9 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -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) } diff --git a/modules/structs/activity.go b/modules/structs/activity.go index 9085495593..b896adfed5 100644 --- a/modules/structs/activity.go +++ b/modules/structs/activity.go @@ -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"` diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 57af38464a..931589696a 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -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 diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 2540481d0f..a34e4b0693 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -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 ( diff --git a/modules/structs/issue_milestone.go b/modules/structs/issue_milestone.go index 226c613d47..dd8bdc6cda 100644 --- a/modules/structs/issue_milestone.go +++ b/modules/structs/issue_milestone.go @@ -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"` diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go index cee5da6624..d7aa0783dc 100644 --- a/modules/structs/notifications.go +++ b/modules/structs/notifications.go @@ -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 ( diff --git a/modules/structs/org.go b/modules/structs/org.go index d79b1d1d1c..723689cb53 100644 --- a/modules/structs/org.go +++ b/modules/structs/org.go @@ -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"` diff --git a/modules/structs/org_team.go b/modules/structs/org_team.go index d34de5b6d2..f730a5681c 100644 --- a/modules/structs/org_team.go +++ b/modules/structs/org_team.go @@ -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. diff --git a/modules/structs/pull.go b/modules/structs/pull.go index 3ad2f78bd3..ad320e2b82 100644 --- a/modules/structs/pull.go +++ b/modules/structs/pull.go @@ -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 diff --git a/modules/structs/pull_review.go b/modules/structs/pull_review.go index f44d2f84f5..de0677efab 100644 --- a/modules/structs/pull_review.go +++ b/modules/structs/pull_review.go @@ -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"` diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 3507cc410a..7cd64fd7a4 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -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"` diff --git a/modules/structs/repo_collaborator.go b/modules/structs/repo_collaborator.go index 9ede7f075a..6b315df403 100644 --- a/modules/structs/repo_collaborator.go +++ b/modules/structs/repo_collaborator.go @@ -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"` } diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 59665062b7..53ce5aeae2 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -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 diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go index 93983f6c7e..3d27c87935 100644 --- a/routers/api/v1/admin/runners.go +++ b/routers/api/v1/admin/runners.go @@ -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": diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ea595407d1..e1d836b5c8 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -11,11 +11,9 @@ // // Consumes: // - application/json -// - text/plain // // Produces: // - application/json -// - text/html // // Security: // - BasicAuth : diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 18ed602ddb..01b57b3fac 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -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": diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 0c48f732ab..7ac8a10575 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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": diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index db205380e4..20ccd099a4 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -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 } diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index e535b5e009..1f313acde8 100644 --- a/routers/api/v1/repo/issue_reaction.go +++ b/routers/api/v1/repo/issue_reaction.go @@ -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) } } diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index f405a3152f..ef86f413b7 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -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": diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go index 667bdb36fe..e06b022f35 100644 --- a/routers/api/v1/user/runners.go +++ b/routers/api/v1/user/runners.go @@ -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": diff --git a/services/actions/task.go b/services/actions/task.go index a21b600998..2cb10b6cd8 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -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 } diff --git a/services/convert/issue.go b/services/convert/issue.go index acd67fece4..61f11d8f19 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -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 { diff --git a/services/convert/notification.go b/services/convert/notification.go index e91bc7dcde..3a1ae09dc5 100644 --- a/services/convert/notification.go +++ b/services/convert/notification.go @@ -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: diff --git a/services/convert/notification_test.go b/services/convert/notification_test.go index 718a070819..0a4f9d6c0a 100644 --- a/services/convert/notification_test.go +++ b/services/convert/notification_test.go @@ -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() diff --git a/services/convert/pull.go b/services/convert/pull.go index bb675811f2..5c7c99f2ce 100644 --- a/services/convert/pull.go +++ b/services/convert/pull.go @@ -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, diff --git a/services/convert/status.go b/services/convert/status.go index fe8240a8f7..a8ef94d107 100644 --- a/services/convert/status.go +++ b/services/convert/status.go @@ -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 } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 8b69c6bcc6..7ccf0aa622 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -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 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 4f3c4e9ff4..a1112def0c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -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: | diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index adc6c18175..5ae0f197df 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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" diff --git a/tests/integration/api_issue_reaction_test.go b/tests/integration/api_issue_reaction_test.go index 01588f9900..d099e72edb 100644 --- a/tests/integration/api_issue_reaction_test.go +++ b/tests/integration/api_issue_reaction_test.go @@ -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 diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 8d85543dc8..c3e96059de 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -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) + }) +} diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 9ac9cced70..4b72962d4f 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -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) { diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index 6ca6f95c69..c86510d5cf 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -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 { diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 468f3fc5ca..f16a71a6c5 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -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('.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); + }); + } }); } diff --git a/web_src/js/markup/tasklist.test.ts b/web_src/js/markup/tasklist.test.ts new file mode 100644 index 0000000000..ec5eceebd0 --- /dev/null +++ b/web_src/js/markup/tasklist.test.ts @@ -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'); +}); diff --git a/web_src/js/markup/tasklist.ts b/web_src/js/markup/tasklist.ts index 7f3417c2bb..557afeaea5 100644 --- a/web_src/js/markup/tasklist.ts +++ b/web_src/js/markup/tasklist.ts @@ -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;