diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 0babe844101..c55e63db6bc 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -494,3 +494,17 @@ type PackagePayload struct { func (p *PackagePayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } + +// WorkflowDispatchPayload represents a workflow dispatch payload +type WorkflowDispatchPayload struct { + Workflow string `json:"workflow"` + Ref string `json:"ref"` + Inputs map[string]any `json:"inputs"` + Repository *Repository `json:"repository"` + Sender *User `json:"sender"` +} + +// JSONPayload implements Payload +func (p *WorkflowDispatchPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f3a7a389514..ef7628967c5 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -628,6 +628,7 @@ org_still_own_repo = "This organization still owns one or more repositories, del org_still_own_packages = "This organization still owns one or more packages, delete them first." target_branch_not_exist = Target branch does not exist. +target_ref_not_exist = Target ref does not exist %s admin_cannot_delete_self = You cannot delete yourself when you are an admin. Please remove your admin privileges first. @@ -3701,6 +3702,11 @@ workflow.disable_success = Workflow '%s' disabled successfully. workflow.enable = Enable Workflow workflow.enable_success = Workflow '%s' enabled successfully. workflow.disabled = Workflow is disabled. +workflow.run = Run Workflow +workflow.not_found = Workflow '%s' not found. +workflow.run_success = Workflow '%s' run successfully. +workflow.from_ref = Use workflow from +workflow.has_workflow_dispatch = This workflow has a workflow_dispatch event trigger. need_approval_desc = Need approval to run workflows for fork pull request. diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index a0f03ec7e90..63cf3e948a8 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -7,22 +7,28 @@ import ( "bytes" "fmt" "net/http" + "slices" "strings" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/repo" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "github.com/nektos/act/pkg/model" + "gopkg.in/yaml.v3" ) const ( @@ -58,8 +64,13 @@ func MustEnableActions(ctx *context.Context) { func List(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("actions.actions") ctx.Data["PageIsActions"] = true + workflowID := ctx.FormString("workflow") + actorID := ctx.FormInt64("actor") + status := ctx.FormInt("status") + ctx.Data["CurWorkflow"] = workflowID var workflows []Workflow + var curWorkflow *model.Workflow if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { ctx.ServerError("IsEmpty", err) return @@ -140,6 +151,10 @@ func List(ctx *context.Context) { workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") } workflows = append(workflows, workflow) + + if workflow.Entry.Name() == workflowID { + curWorkflow = wf + } } } ctx.Data["workflows"] = workflows @@ -150,17 +165,46 @@ func List(ctx *context.Context) { page = 1 } - workflow := ctx.FormString("workflow") - actorID := ctx.FormInt64("actor") - status := ctx.FormInt("status") - ctx.Data["CurWorkflow"] = workflow - actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() ctx.Data["ActionsConfig"] = actionsConfig - if len(workflow) > 0 && ctx.Repo.IsAdmin() { + if len(workflowID) > 0 && ctx.Repo.IsAdmin() { ctx.Data["AllowDisableOrEnableWorkflow"] = true - ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(workflow) + isWorkflowDisabled := actionsConfig.IsWorkflowDisabled(workflowID) + ctx.Data["CurWorkflowDisabled"] = isWorkflowDisabled + + if !isWorkflowDisabled && curWorkflow != nil { + workflowDispatchConfig := workflowDispatchConfig(curWorkflow) + if workflowDispatchConfig != nil { + ctx.Data["WorkflowDispatchConfig"] = workflowDispatchConfig + + branchOpts := git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptions{ + ListAll: true, + }, + } + branches, err := git_model.FindBranchNames(ctx, branchOpts) + if err != nil { + ctx.ServerError("FindBranchNames", err) + return + } + // always put default branch on the top if it exists + if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) { + branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch) + branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) + } + ctx.Data["Branches"] = branches + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + } + } } // if status or actor query param is not given to frontend href, (href="//actions") @@ -177,7 +221,7 @@ func List(ctx *context.Context) { PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }, RepoID: ctx.Repo.Repository.ID, - WorkflowID: workflow, + WorkflowID: workflowID, TriggerUserID: actorID, } @@ -214,7 +258,7 @@ func List(ctx *context.Context) { pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) pager.SetDefaultParams(ctx) - pager.AddParamString("workflow", workflow) + pager.AddParamString("workflow", workflowID) pager.AddParamString("actor", fmt.Sprint(actorID)) pager.AddParamString("status", fmt.Sprint(status)) ctx.Data["Page"] = pager @@ -222,3 +266,86 @@ func List(ctx *context.Context) { ctx.HTML(http.StatusOK, tplListActions) } + +type WorkflowDispatchInput struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + Default string `yaml:"default"` + Type string `yaml:"type"` + Options []string `yaml:"options"` +} + +type WorkflowDispatch struct { + Inputs []WorkflowDispatchInput +} + +func workflowDispatchConfig(w *model.Workflow) *WorkflowDispatch { + switch w.RawOn.Kind { + case yaml.ScalarNode: + var val string + if !decodeNode(w.RawOn, &val) { + return nil + } + if val == "workflow_dispatch" { + return &WorkflowDispatch{} + } + case yaml.SequenceNode: + var val []string + if !decodeNode(w.RawOn, &val) { + return nil + } + for _, v := range val { + if v == "workflow_dispatch" { + return &WorkflowDispatch{} + } + } + case yaml.MappingNode: + var val map[string]yaml.Node + if !decodeNode(w.RawOn, &val) { + return nil + } + + workflowDispatchNode, found := val["workflow_dispatch"] + if !found { + return nil + } + + var workflowDispatch WorkflowDispatch + var workflowDispatchVal map[string]yaml.Node + if !decodeNode(workflowDispatchNode, &workflowDispatchVal) { + return &workflowDispatch + } + + inputsNode, found := workflowDispatchVal["inputs"] + if !found || inputsNode.Kind != yaml.MappingNode { + return &workflowDispatch + } + + i := 0 + for { + if i+1 >= len(inputsNode.Content) { + break + } + var input WorkflowDispatchInput + if decodeNode(*inputsNode.Content[i+1], &input) { + input.Name = inputsNode.Content[i].Value + workflowDispatch.Inputs = append(workflowDispatch.Inputs, input) + } + i += 2 + } + return &workflowDispatch + + default: + return nil + } + return nil +} + +func decodeNode(node yaml.Node, out any) bool { + if err := node.Decode(out); err != nil { + log.Warn("Failed to decode node %v into %T: %v", node, out, err) + return false + } + return true +} diff --git a/routers/web/repo/actions/actions_test.go b/routers/web/repo/actions/actions_test.go new file mode 100644 index 00000000000..194704d14eb --- /dev/null +++ b/routers/web/repo/actions/actions_test.go @@ -0,0 +1,156 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "strings" + "testing" + + act_model "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" +) + +func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) { + yaml := ` + name: local-action-docker-url + ` + workflow, err := act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch := workflowDispatchConfig(workflow) + assert.Nil(t, workflowDispatch) + + yaml = ` + name: local-action-docker-url + on: push + ` + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.Nil(t, workflowDispatch) + + yaml = ` + name: local-action-docker-url + on: workflow_dispatch + ` + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.NotNil(t, workflowDispatch) + assert.Nil(t, workflowDispatch.Inputs) + + yaml = ` + name: local-action-docker-url + on: [push, pull_request] + ` + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.Nil(t, workflowDispatch) + + yaml = ` + name: local-action-docker-url + on: + push: + pull_request: + ` + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.Nil(t, workflowDispatch) + + yaml = ` + name: local-action-docker-url + on: [push, workflow_dispatch] + ` + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.NotNil(t, workflowDispatch) + assert.Nil(t, workflowDispatch.Inputs) + + yaml = ` + name: local-action-docker-url + on: + - push + - workflow_dispatch + ` + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.NotNil(t, workflowDispatch) + assert.Nil(t, workflowDispatch.Inputs) + + yaml = ` + name: local-action-docker-url + on: + push: + pull_request: + workflow_dispatch: + inputs: + ` + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.NotNil(t, workflowDispatch) + assert.Nil(t, workflowDispatch.Inputs) + + yaml = ` + name: local-action-docker-url + on: + push: + pull_request: + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + boolean_default_true: + description: 'Test scenario tags' + required: true + type: boolean + default: true + boolean_default_false: + description: 'Test scenario tags' + required: true + type: boolean + default: false + ` + + workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) + assert.NoError(t, err, "read workflow should succeed") + workflowDispatch = workflowDispatchConfig(workflow) + assert.NotNil(t, workflowDispatch) + assert.Equal(t, WorkflowDispatchInput{ + Name: "logLevel", + Default: "warning", + Description: "Log level", + Options: []string{ + "info", + "warning", + "debug", + }, + Required: true, + Type: "choice", + }, workflowDispatch.Inputs[0]) + assert.Equal(t, WorkflowDispatchInput{ + Name: "boolean_default_true", + Default: "true", + Description: "Test scenario tags", + Required: true, + Type: "boolean", + }, workflowDispatch.Inputs[1]) + assert.Equal(t, WorkflowDispatchInput{ + Name: "boolean_default_false", + Default: "false", + Description: "Test scenario tags", + Required: true, + Type: "boolean", + }, workflowDispatch.Inputs[2]) +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 6b422891648..11199d69eb3 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -18,18 +18,26 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" actions_service "code.gitea.io/gitea/services/actions" context_module "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "github.com/nektos/act/pkg/jobparser" + "github.com/nektos/act/pkg/model" "xorm.io/builder" ) @@ -745,3 +753,164 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status"))) ctx.JSONRedirect(redirectURL) } + +func Run(ctx *context_module.Context) { + redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(ctx.FormString("workflow")), + url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status"))) + + workflowID := ctx.FormString("workflow") + if len(workflowID) == 0 { + ctx.ServerError("workflow", nil) + return + } + + ref := ctx.FormString("ref") + if len(ref) == 0 { + ctx.ServerError("ref", nil) + return + } + + // can not rerun job when workflow is disabled + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(workflowID) { + ctx.Flash.Error(ctx.Tr("actions.workflow.disabled")) + ctx.Redirect(redirectURL) + return + } + + // get target commit of run from specified ref + refName := git.RefName(ref) + var runTargetCommit *git.Commit + var err error + if refName.IsTag() { + runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName()) + } else if refName.IsBranch() { + runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName()) + } else { + ctx.Flash.Error(ctx.Tr("form.git_ref_name_error", ref)) + ctx.Redirect(redirectURL) + return + } + if err != nil { + ctx.Flash.Error(ctx.Tr("form.target_ref_not_exist", ref)) + ctx.Redirect(redirectURL) + return + } + + // get workflow entry from default branch commit + defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + entries, err := actions.ListWorkflows(defaultBranchCommit) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + // find workflow from commit + var workflows []*jobparser.SingleWorkflow + for _, entry := range entries { + if entry.Name() == workflowID { + content, err := actions.GetContentFromEntry(entry) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + workflows, err = jobparser.Parse(content) + if err != nil { + ctx.ServerError("workflow", err) + return + } + break + } + } + + if len(workflows) == 0 { + ctx.Flash.Error(ctx.Tr("actions.workflow.not_found", workflowID)) + ctx.Redirect(redirectURL) + return + } + + // get inputs from post + workflow := &model.Workflow{ + RawOn: workflows[0].RawOn, + } + inputs := make(map[string]any) + if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { + for name, config := range workflowDispatch.Inputs { + value := ctx.Req.PostForm.Get(name) + if config.Type == "boolean" { + // https://www.w3.org/TR/html401/interact/forms.html + // https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked + // Checkboxes (and radio buttons) are on/off switches that may be toggled by the user. + // A switch is "on" when the control element's checked attribute is set. + // When a form is submitted, only "on" checkbox controls can become successful. + inputs[name] = strconv.FormatBool(value == "on") + } else if value != "" { + inputs[name] = value + } else { + inputs[name] = config.Default + } + } + } + + // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event + // https://docs.github.com/en/actions/learn-github-actions/contexts#github-context + // https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch + workflowDispatchPayload := &api.WorkflowDispatchPayload{ + Workflow: workflowID, + Ref: ref, + Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), + Inputs: inputs, + Sender: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone), + } + var eventPayload []byte + if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil { + ctx.ServerError("JSONPayload", err) + return + } + + run := &actions_model.ActionRun{ + Title: strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0], + RepoID: ctx.Repo.Repository.ID, + OwnerID: ctx.Repo.Repository.OwnerID, + WorkflowID: workflowID, + TriggerUserID: ctx.Doer.ID, + Ref: ref, + CommitSHA: runTargetCommit.ID.String(), + IsForkPullRequest: false, + Event: "workflow_dispatch", + TriggerEvent: "workflow_dispatch", + EventPayload: string(eventPayload), + Status: actions_model.StatusWaiting, + } + + // cancel running jobs of the same workflow + if err := actions_model.CancelPreviousJobs( + ctx, + run.RepoID, + run.Ref, + run.WorkflowID, + run.Event, + ); err != nil { + log.Error("CancelRunningJobs: %v", err) + } + + // Insert the action run and its associated jobs into the database + if err := actions_model.InsertRun(ctx, run, workflows); err != nil { + ctx.ServerError("workflow", err) + return + } + + alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + if err != nil { + log.Error("FindRunJobs: %v", err) + } + actions_service.CreateCommitStatus(ctx, alljobs...) + + ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID)) + ctx.Redirect(redirectURL) +} diff --git a/routers/web/web.go b/routers/web/web.go index 0e16c1ca6c9..4e917b5ede6 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1384,6 +1384,7 @@ func registerRoutes(m *web.Router) { m.Get("", actions.List) m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) + m.Post("/run", reqRepoAdmin, actions.Run) m.Group("/runs/{run}", func() { m.Combo(""). diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index b66d0e360ab..7d782c0adeb 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -76,6 +76,11 @@ {{end}} + + {{if .WorkflowDispatchConfig}} + {{template "repo/actions/workflow_dispatch" .}} + {{end}} + {{template "repo/actions/runs_list" .}} diff --git a/templates/repo/actions/workflow_dispatch.tmpl b/templates/repo/actions/workflow_dispatch.tmpl new file mode 100644 index 00000000000..21f3ef20772 --- /dev/null +++ b/templates/repo/actions/workflow_dispatch.tmpl @@ -0,0 +1,78 @@ +
+ {{ctx.Locale.Tr "actions.workflow.has_workflow_dispatch"}} + +
+ diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index 75b485cc983..5844037770c 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -43,6 +43,19 @@ function reloadConfirmDraftComment() { window.location.reload(); } +export function initBranchSelectorTabs() { + const elSelectBranch = document.querySelector('.ui.dropdown.select-branch'); + if (!elSelectBranch) return; + + $(elSelectBranch).find('.reference.column').on('click', function () { + hideElem($(elSelectBranch).find('.scrolling.reference-list-menu')); + showElem(this.getAttribute('data-target')); + queryElemChildren(this.parentNode, '.branch-tag-item', (el) => el.classList.remove('active')); + this.classList.add('active'); + return false; + }); +} + export function initRepoCommentForm() { const $commentForm = $('.comment.form'); if (!$commentForm.length) return; @@ -81,13 +94,6 @@ export function initRepoCommentForm() { elSelectBranch.querySelector('.text-branch-name').textContent = selectedText; } }); - $selectBranch.find('.reference.column').on('click', function () { - hideElem($selectBranch.find('.scrolling.reference-list-menu')); - showElem(this.getAttribute('data-target')); - queryElemChildren(this.parentNode, '.branch-tag-item', (el) => el.classList.remove('active')); - this.classList.add('active'); - return false; - }); } initBranchSelector(); diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 81b8828dba9..2bdc8655fe3 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -60,7 +60,7 @@ import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts'; import {initRepoBranchButton} from './features/repo-branch.ts'; import {initCommonOrganization} from './features/common-organization.ts'; import {initRepoWikiForm} from './features/repo-wiki.ts'; -import {initRepoCommentForm, initRepository} from './features/repo-legacy.ts'; +import {initRepoCommentForm, initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts'; import {initCopyContent} from './features/copycontent.ts'; import {initCaptcha} from './features/captcha.ts'; import {initRepositoryActionView} from './components/RepoActionView.vue'; @@ -182,6 +182,7 @@ onDomReady(() => { initRepoBranchButton, initRepoCodeView, initRepoCommentForm, + initBranchSelectorTabs, initRepoEllipsisButton, initRepoDiffCommitBranchesAndTags, initRepoEditor,