Merge branch 'go-gitea:main' into main

This commit is contained in:
jerryyummy 2025-06-22 00:51:20 +08:00 committed by GitHub
commit 29f084e495
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
126 changed files with 4056 additions and 2044 deletions

View File

@ -7,7 +7,7 @@
"version": "lts"
},
"ghcr.io/devcontainers/features/git-lfs:1.2.2": {},
"ghcr.io/devcontainers-contrib/features/poetry:2": {},
"ghcr.io/devcontainers-extra/features/poetry:2": {},
"ghcr.io/devcontainers/features/python:1": {
"version": "3.12"
},

2
go.mod
View File

@ -51,7 +51,7 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/go-ap/activitypub v0.0.0-20250409143848-7113328b1f3d
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/cors v1.2.1
github.com/go-co-op/gocron v1.37.0
github.com/go-enry/go-enry/v2 v2.9.2

4
go.sum
View File

@ -289,8 +289,8 @@ github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5La
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=

View File

@ -166,6 +166,17 @@ func (run *ActionRun) GetPullRequestEventPayload() (*api.PullRequestPayload, err
return nil, fmt.Errorf("event %s is not a pull request event", run.Event)
}
func (run *ActionRun) GetWorkflowRunEventPayload() (*api.WorkflowRunPayload, error) {
if run.Event == webhook_module.HookEventWorkflowRun {
var payload api.WorkflowRunPayload
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
return nil, err
}
return &payload, nil
}
return nil, fmt.Errorf("event %s is not a workflow run event", run.Event)
}
func (run *ActionRun) IsSchedule() bool {
return run.ScheduleID > 0
}

View File

@ -80,22 +80,31 @@ type FindRunJobOptions struct {
func (opts FindRunJobOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RunID > 0 {
cond = cond.And(builder.Eq{"run_id": opts.RunID})
cond = cond.And(builder.Eq{"`action_run_job`.run_id": opts.RunID})
}
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
}
if opts.OwnerID > 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
cond = cond.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
}
if opts.CommitSHA != "" {
cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA})
cond = cond.And(builder.Eq{"`action_run_job`.commit_sha": opts.CommitSHA})
}
if len(opts.Statuses) > 0 {
cond = cond.And(builder.In("status", opts.Statuses))
cond = cond.And(builder.In("`action_run_job`.status", opts.Statuses))
}
if opts.UpdatedBefore > 0 {
cond = cond.And(builder.Lt{"updated": opts.UpdatedBefore})
cond = cond.And(builder.Lt{"`action_run_job`.updated": opts.UpdatedBefore})
}
return cond
}
func (opts FindRunJobOptions) ToJoins() []db.JoinFunc {
if opts.OwnerID > 0 {
return []db.JoinFunc{
func(sess db.Engine) error {
sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID)
return nil
},
}
}
return nil
}

View File

@ -72,39 +72,50 @@ type FindRunOptions struct {
TriggerEvent webhook_module.HookEventType
Approved bool // not util.OptionalBool, it works only when it's true
Status []Status
CommitSHA string
}
func (opts FindRunOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
}
if opts.OwnerID > 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
cond = cond.And(builder.Eq{"`action_run`.repo_id": opts.RepoID})
}
if opts.WorkflowID != "" {
cond = cond.And(builder.Eq{"workflow_id": opts.WorkflowID})
cond = cond.And(builder.Eq{"`action_run`.workflow_id": opts.WorkflowID})
}
if opts.TriggerUserID > 0 {
cond = cond.And(builder.Eq{"trigger_user_id": opts.TriggerUserID})
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
}
if opts.Approved {
cond = cond.And(builder.Gt{"approved_by": 0})
cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
}
if len(opts.Status) > 0 {
cond = cond.And(builder.In("status", opts.Status))
cond = cond.And(builder.In("`action_run`.status", opts.Status))
}
if opts.Ref != "" {
cond = cond.And(builder.Eq{"ref": opts.Ref})
cond = cond.And(builder.Eq{"`action_run`.ref": opts.Ref})
}
if opts.TriggerEvent != "" {
cond = cond.And(builder.Eq{"trigger_event": opts.TriggerEvent})
cond = cond.And(builder.Eq{"`action_run`.trigger_event": opts.TriggerEvent})
}
if opts.CommitSHA != "" {
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
}
return cond
}
func (opts FindRunOptions) ToJoins() []db.JoinFunc {
if opts.OwnerID > 0 {
return []db.JoinFunc{func(sess db.Engine) error {
sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID)
return nil
}}
}
return nil
}
func (opts FindRunOptions) ToOrders() string {
return "`id` DESC"
return "`action_run`.`id` DESC"
}
type StatusInfo struct {

View File

@ -9,6 +9,7 @@
ref: "refs/heads/master"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 1
started: 1683636528
@ -28,6 +29,7 @@
ref: "refs/heads/master"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 1
started: 1683636528
@ -47,6 +49,7 @@
ref: "refs/heads/master"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 6 # running
started: 1683636528
@ -66,6 +69,47 @@
ref: "refs/heads/test"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 1
started: 1683636528
stopped: 1683636626
created: 1683636108
updated: 1683636626
need_approval: 0
approved_by: 0
-
id: 802
title: "workflow run list"
repo_id: 5
owner_id: 3
workflow_id: "test.yaml"
index: 191
trigger_user_id: 1
ref: "refs/heads/test"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 1
started: 1683636528
stopped: 1683636626
created: 1683636108
updated: 1683636626
need_approval: 0
approved_by: 0
-
id: 803
title: "workflow run list for user"
repo_id: 2
owner_id: 0
workflow_id: "test.yaml"
index: 192
trigger_user_id: 1
ref: "refs/heads/test"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 1
started: 1683636528
@ -86,6 +130,7 @@
ref: "refs/heads/test"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 2
started: 1683636528

View File

@ -99,3 +99,33 @@
status: 2
started: 1683636528
stopped: 1683636626
-
id: 203
run_id: 802
repo_id: 5
owner_id: 0
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job2
attempt: 1
job_id: job2
needs: '["job1"]'
task_id: 51
status: 5
started: 1683636528
stopped: 1683636626
-
id: 204
run_id: 803
repo_id: 2
owner_id: 0
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job2
attempt: 1
job_id: job2
needs: '["job1"]'
task_id: 51
status: 5
started: 1683636528
stopped: 1683636626

View File

@ -112,7 +112,6 @@ type LFSMetaObject struct {
ID int64 `xorm:"pk autoincr"`
lfs.Pointer `xorm:"extends"`
RepositoryID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
Existing bool `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
@ -146,7 +145,6 @@ func NewLFSMetaObject(ctx context.Context, repoID int64, p lfs.Pointer) (*LFSMet
if err != nil {
return nil, err
} else if exist {
m.Existing = true
return m, committer.Commit()
}

View File

@ -73,7 +73,7 @@ func TestWebhook_EventsArray(t *testing.T) {
"pull_request", "pull_request_assign", "pull_request_label", "pull_request_milestone",
"pull_request_comment", "pull_request_review_approved", "pull_request_review_rejected",
"pull_request_review_comment", "pull_request_sync", "pull_request_review_request", "wiki", "repository", "release",
"package", "status", "workflow_job",
"package", "status", "workflow_run", "workflow_job",
},
(&Webhook{
HookEvent: &webhook_module.HookEvent{SendEverything: true},

View File

@ -246,6 +246,10 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
webhook_module.HookEventPackage:
return matchPackageEvent(payload.(*api.PackagePayload), evt)
case // workflow_run
webhook_module.HookEventWorkflowRun:
return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
default:
log.Warn("unsupported event %q", triggedEvent)
return false
@ -691,3 +695,53 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
}
return matchTimes == len(evt.Acts())
}
func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts() {
switch cond {
case "types":
action := payload.Action
for _, val := range vals {
if glob.MustCompile(val, '/').Match(action) {
matchTimes++
break
}
}
case "workflows":
workflow := payload.Workflow
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{workflow.Name}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "branches":
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "branches-ignore":
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
default:
log.Warn("workflow run event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts())
}

View File

@ -63,8 +63,11 @@ func processNodeA(ctx *RenderContext, node *html.Node) {
func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
next = img.NextSibling
attrSrc, hasLazy := "", false
for i, imgAttr := range img.Attr {
hasLazy = hasLazy || imgAttr.Key == "loading" && imgAttr.Val == "lazy"
if imgAttr.Key != "src" {
attrSrc = imgAttr.Val
continue
}
@ -72,8 +75,8 @@ func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
isLinkable := imgSrcOrigin != "" && !strings.HasPrefix(imgSrcOrigin, "data:")
// By default, the "<img>" tag should also be clickable,
// because frontend use `<img>` to paste the re-scaled image into the markdown,
// so it must match the default markdown image behavior.
// because frontend uses `<img>` to paste the re-scaled image into the Markdown,
// so it must match the default Markdown image behavior.
cnt := 0
for p := img.Parent; isLinkable && p != nil && cnt < 2; p = p.Parent {
if hasParentAnchor := p.Type == html.ElementNode && p.Data == "a"; hasParentAnchor {
@ -98,6 +101,9 @@ func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
imgAttr.Val = camoHandleLink(imgAttr.Val)
img.Attr[i] = imgAttr
}
if !RenderBehaviorForTesting.DisableAdditionalAttributes && !hasLazy && !strings.HasPrefix(attrSrc, "data:") {
img.Attr = append(img.Attr, html.Attribute{Key: "loading", Val: "lazy"})
}
return next
}

View File

@ -47,7 +47,7 @@ func TestRender_StandardLinks(t *testing.T) {
func TestRender_Images(t *testing.T) {
setting.AppURL = AppURL
test := func(input, expected string) {
render := func(input, expected string) {
buffer, err := markdown.RenderString(markup.NewTestRenderContext(FullURL), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
@ -59,27 +59,32 @@ func TestRender_Images(t *testing.T) {
result := util.URLJoin(FullURL, url)
// hint: With Markdown v2.5.2, there is a new syntax: [link](URL){:target="_blank"} , but we do not support it now
test(
render(
"!["+title+"]("+url+")",
`<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`)
test(
render(
"[["+title+"|"+url+"]]",
`<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`)
test(
render(
"[!["+title+"]("+url+")]("+href+")",
`<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`)
test(
render(
"!["+title+"]("+url+")",
`<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`)
test(
render(
"[["+title+"|"+url+"]]",
`<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`)
test(
render(
"[!["+title+"]("+url+")]("+href+")",
`<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`)
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, false)()
render(
"<a><img src='a.jpg'></a>", // by the way, empty "a" tag will be removed
`<p dir="auto"><img src="http://localhost:3000/user13/repo11/a.jpg" loading="lazy"/></p>`)
}
func TestTotal_RenderString(t *testing.T) {

View File

@ -52,6 +52,8 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
policy.AllowAttrs("loading").OnElements("img")
// Allow generally safe attributes (reference: https://github.com/jch/html-pipeline)
generalSafeAttrs := []string{
"abbr", "accept", "accept-charset",

View File

@ -4,7 +4,6 @@
package container
import (
"errors"
"fmt"
"io"
"strings"
@ -72,20 +71,39 @@ type Manifest struct {
Size int64 `json:"size"`
}
func IsMediaTypeValid(mt string) bool {
return strings.HasPrefix(mt, "application/vnd.docker.") || strings.HasPrefix(mt, "application/vnd.oci.")
}
func IsMediaTypeImageManifest(mt string) bool {
return strings.EqualFold(mt, oci.MediaTypeImageManifest) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.v2+json")
}
func IsMediaTypeImageIndex(mt string) bool {
return strings.EqualFold(mt, oci.MediaTypeImageIndex) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.list.v2+json")
}
// ParseImageConfig parses the metadata of an image config
func ParseImageConfig(mt string, r io.Reader) (*Metadata, error) {
if strings.EqualFold(mt, helm.ConfigMediaType) {
func ParseImageConfig(mediaType string, r io.Reader) (*Metadata, error) {
if strings.EqualFold(mediaType, helm.ConfigMediaType) {
return parseHelmConfig(r)
}
// fallback to OCI Image Config
return parseOCIImageConfig(r)
// FIXME: this fallback is not right, we should strictly check the media type in the future
metadata, err := parseOCIImageConfig(r)
if err != nil {
if !IsMediaTypeImageManifest(mediaType) {
return &Metadata{Platform: "unknown/unknown"}, nil
}
return nil, err
}
return metadata, nil
}
func parseOCIImageConfig(r io.Reader) (*Metadata, error) {
var image oci.Image
// EOF means empty input, still use the default data
if err := json.NewDecoder(r).Decode(&image); err != nil && !errors.Is(err, io.EOF) {
if err := json.NewDecoder(r).Decode(&image); err != nil {
return nil, err
}

View File

@ -59,10 +59,8 @@ func TestParseImageConfig(t *testing.T) {
assert.ElementsMatch(t, []string{author}, metadata.Authors)
assert.Equal(t, projectURL, metadata.ProjectURL)
assert.Equal(t, repositoryURL, metadata.RepositoryURL)
}
func TestParseOCIImageConfig(t *testing.T) {
metadata, err := parseOCIImageConfig(strings.NewReader(""))
metadata, err = ParseImageConfig("anything-unknown", strings.NewReader(""))
require.NoError(t, err)
assert.Equal(t, &Metadata{Type: TypeOCI, Platform: DefaultPlatform, ImageLayers: []string{}}, metadata)
assert.Equal(t, &Metadata{Platform: "unknown/unknown"}, metadata)
}

View File

@ -470,6 +470,22 @@ func (p *CommitStatusPayload) JSONPayload() ([]byte, error) {
return json.MarshalIndent(p, "", " ")
}
// WorkflowRunPayload represents a payload information of workflow run event.
type WorkflowRunPayload struct {
Action string `json:"action"`
Workflow *ActionWorkflow `json:"workflow"`
WorkflowRun *ActionWorkflowRun `json:"workflow_run"`
PullRequest *PullRequest `json:"pull_request,omitempty"`
Organization *Organization `json:"organization,omitempty"`
Repo *Repository `json:"repository"`
Sender *User `json:"sender"`
}
// JSONPayload implements Payload
func (p *WorkflowRunPayload) JSONPayload() ([]byte, error) {
return json.MarshalIndent(p, "", " ")
}
// WorkflowJobPayload represents a payload information of workflow job event.
type WorkflowJobPayload struct {
Action string `json:"action"`

View File

@ -86,9 +86,39 @@ type ActionArtifact struct {
// ActionWorkflowRun represents a WorkflowRun
type ActionWorkflowRun struct {
ID int64 `json:"id"`
RepositoryID int64 `json:"repository_id"`
HeadSha string `json:"head_sha"`
ID int64 `json:"id"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
DisplayTitle string `json:"display_title"`
Path string `json:"path"`
Event string `json:"event"`
RunAttempt int64 `json:"run_attempt"`
RunNumber int64 `json:"run_number"`
RepositoryID int64 `json:"repository_id,omitempty"`
HeadSha string `json:"head_sha"`
HeadBranch string `json:"head_branch,omitempty"`
Status string `json:"status"`
Actor *User `json:"actor,omitempty"`
TriggerActor *User `json:"trigger_actor,omitempty"`
Repository *Repository `json:"repository,omitempty"`
HeadRepository *Repository `json:"head_repository,omitempty"`
Conclusion string `json:"conclusion,omitempty"`
// swagger:strfmt date-time
StartedAt time.Time `json:"started_at"`
// swagger:strfmt date-time
CompletedAt time.Time `json:"completed_at"`
}
// ActionWorkflowRunsResponse returns ActionWorkflowRuns
type ActionWorkflowRunsResponse struct {
Entries []*ActionWorkflowRun `json:"workflow_runs"`
TotalCount int64 `json:"total_count"`
}
// ActionWorkflowJobsResponse returns ActionWorkflowJobs
type ActionWorkflowJobsResponse struct {
Entries []*ActionWorkflowJob `json:"jobs"`
TotalCount int64 `json:"total_count"`
}
// ActionArtifactsResponse returns ActionArtifacts

View File

@ -136,6 +136,7 @@ type UpdateBranchProtectionPriories struct {
type MergeUpstreamRequest struct {
Branch string `json:"branch"`
FfOnly bool `json:"ff_only"`
}
type MergeUpstreamResponse struct {

View File

@ -66,6 +66,8 @@ func (o *UpdateFileOptions) Branch() string {
return o.FileOptions.BranchName
}
// FIXME: ChangeFileOperation.SHA is NOT required for update or delete if last commit is provided in the options.
// ChangeFileOperation for creating, updating or deleting a file
type ChangeFileOperation struct {
// indicates what to do with the file

View File

@ -38,6 +38,7 @@ const (
HookEventPullRequestReview HookEventType = "pull_request_review"
// Actions event only
HookEventSchedule HookEventType = "schedule"
HookEventWorkflowRun HookEventType = "workflow_run"
HookEventWorkflowJob HookEventType = "workflow_job"
)
@ -67,6 +68,7 @@ func AllEvents() []HookEventType {
HookEventRelease,
HookEventPackage,
HookEventStatus,
HookEventWorkflowRun,
HookEventWorkflowJob,
}
}

View File

@ -1354,7 +1354,7 @@ editor.update = Update %s
editor.delete = Delete %s
editor.patch = Apply Patch
editor.patching = Patching:
editor.fail_to_apply_patch = Unable to apply patch "%s"
editor.fail_to_apply_patch = Unable to apply patch
editor.new_patch = New Patch
editor.commit_message_desc = Add an optional extended description…
editor.signoff_desc = Add a Signed-off-by trailer by the committer at the end of the commit log message.
@ -1374,8 +1374,7 @@ editor.branch_already_exists = Branch "%s" already exists in this repository.
editor.directory_is_a_file = Directory name "%s" is already used as a filename in this repository.
editor.file_is_a_symlink = `"%s" is a symbolic link. Symbolic links cannot be edited in the web editor`
editor.filename_is_a_directory = Filename "%s" is already used as a directory name in this repository.
editor.file_editing_no_longer_exists = The file being edited, "%s", no longer exists in this repository.
editor.file_deleting_no_longer_exists = The file being deleted, "%s", no longer exists in this repository.
editor.file_modifying_no_longer_exists = The file being modified, "%s", no longer exists in this repository.
editor.file_changed_while_editing = The file contents have changed since you started editing. <a target="_blank" rel="noopener noreferrer" href="%s">Click here</a> to see them or <strong>Commit Changes again</strong> to overwrite them.
editor.file_already_exists = A file named "%s" already exists in this repository.
editor.commit_id_not_matching = The Commit ID does not match the ID when you began editing. Commit into a patch branch and then merge.
@ -1383,8 +1382,6 @@ editor.push_out_of_date = The push appears to be out of date.
editor.commit_empty_file_header = Commit an empty file
editor.commit_empty_file_text = The file you're about to commit is empty. Proceed?
editor.no_changes_to_show = There are no changes to show.
editor.fail_to_update_file = Failed to update/create file "%s".
editor.fail_to_update_file_summary = Error Message:
editor.push_rejected_no_message = The change was rejected by the server without a message. Please check Git Hooks.
editor.push_rejected = The change was rejected by the server. Please check Git Hooks.
editor.push_rejected_summary = Full Rejection Message:
@ -1398,6 +1395,8 @@ editor.user_no_push_to_branch = User cannot push to branch
editor.require_signed_commit = Branch requires a signed commit
editor.cherry_pick = Cherry-pick %s onto:
editor.revert = Revert %s onto:
editor.failed_to_commit = Failed to commit changes.
editor.failed_to_commit_summary = Error Message:
commits.desc = Browse source code change history.
commits.commits = Commits
@ -2403,6 +2402,8 @@ settings.event_pull_request_review_request_desc = Pull request review requested
settings.event_pull_request_approvals = Pull Request Approvals
settings.event_pull_request_merge = Pull Request Merge
settings.event_header_workflow = Workflow Events
settings.event_workflow_run = Workflow Run
settings.event_workflow_run_desc = Gitea Actions Workflow run queued, waiting, in progress, or completed.
settings.event_workflow_job = Workflow Jobs
settings.event_workflow_job_desc = Gitea Actions Workflow job queued, waiting, in progress, or completed.
settings.event_package = Package

View File

@ -1332,7 +1332,9 @@ editor.upload_file=Uaslódáil Comhad
editor.edit_file=Cuir Comhad in eagar
editor.preview_changes=Athruithe Réamhamhar
editor.cannot_edit_lfs_files=Ní féidir comhaid LFS a chur in eagar sa chomhéadan gréasáin.
editor.cannot_edit_too_large_file=Tá an comhad rómhór le cur in eagar.
editor.cannot_edit_non_text_files=Ní féidir comhaid dhénártha a chur in eagar sa chomhéadan gréasáin.
editor.file_not_editable_hint=Ach is féidir leat é a athainmniú nó a bhogadh fós.
editor.edit_this_file=Cuir Comhad in eagar
editor.this_file_locked=Tá an comhad faoi ghlas
editor.must_be_on_a_branch=Caithfidh tú a bheith ar bhrainse chun athruithe a dhéanamh nó a mholadh ar an gcomhad seo.

View File

@ -425,8 +425,7 @@ account_activated=Обліковий запис активовано
prohibit_login=Вхід заборонено
prohibit_login_desc=Ваш обліковий запис заблоковано для входу, зверніться до адміністратора сайту.
resent_limit_prompt=Ви вже надсилали запит на активацію нещодавно. Зачекайте 3 хвилини і спробуйте ще раз.
has_unconfirmed_mail=
Привіт %s, у вас непідтверджена адреса електронної пошти (<b>%s</b>). Якщо ви не отримали листа з підтвердженням або вам потрібно надіслати новий, будь ласка, натисніть кнопку нижче.
has_unconfirmed_mail=Привіт %s, у вас непідтверджена адреса електронної пошти (<b>%s</b>). Якщо ви не отримали листа з підтвердженням або вам потрібно надіслати новий, будь ласка, натисніть кнопку нижче.
change_unconfirmed_mail_address=Якщо ваша адреса електронної пошти для реєстрації невірна, ви можете змінити її тут і надіслати новий лист з підтвердженням.
resend_mail=Натисніть тут, щоб повторно надіслати лист з активацією
email_not_associate=Ця адреса електронної пошти не пов'язана з жодним обліковим записом.

View File

@ -1332,7 +1332,9 @@ editor.upload_file=上传文件
editor.edit_file=编辑文件
editor.preview_changes=预览变更
editor.cannot_edit_lfs_files=无法在 web 界面中编辑 lfs 文件。
editor.cannot_edit_too_large_file=文件过大,无法编辑。
editor.cannot_edit_non_text_files=网页不能编辑二进制文件。
editor.file_not_editable_hint=但您仍然可以重命名或移动它。
editor.edit_this_file=编辑文件
editor.this_file_locked=文件已锁定
editor.must_be_on_a_branch=您必须在某个分支上才能对此文件进行修改操作。

View File

@ -693,6 +693,8 @@ func ContainerRoutes() *web.Router {
&container.Auth{},
})
// TODO: Content Discovery / References (not implemented yet)
r.Get("", container.ReqContainerAccess, container.DetermineSupport)
r.Group("/token", func() {
r.Get("", container.Authenticate)

View File

@ -320,6 +320,7 @@ func PostBlobsUploads(ctx *context.Context) {
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
func GetBlobsUpload(ctx *context.Context) {
image := ctx.PathParam("image")
uuid := ctx.PathParam("uuid")
upload, err := packages_model.GetBlobUploadByID(ctx, uuid)
@ -334,6 +335,7 @@ func GetBlobsUpload(ctx *context.Context) {
// FIXME: undefined behavior when the uploaded content is empty: https://github.com/opencontainers/distribution-spec/issues/578
respHeaders := &containerHeaders{
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
UploadUUID: upload.ID,
Status: http.StatusNoContent,
}
@ -386,7 +388,7 @@ func PatchBlobsUpload(ctx *context.Context) {
UploadUUID: uploader.ID,
Status: http.StatusAccepted,
}
if contentRange != "" {
if uploader.Size() > 0 {
respHeaders.Range = fmt.Sprintf("0-%d", uploader.Size()-1)
}
setResponseHeaders(ctx.Resp, respHeaders)

View File

@ -29,18 +29,6 @@ import (
oci "github.com/opencontainers/image-spec/specs-go/v1"
)
func isMediaTypeValid(mt string) bool {
return strings.HasPrefix(mt, "application/vnd.docker.") || strings.HasPrefix(mt, "application/vnd.oci.")
}
func isMediaTypeImageManifest(mt string) bool {
return strings.EqualFold(mt, oci.MediaTypeImageManifest) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.v2+json")
}
func isMediaTypeImageIndex(mt string) bool {
return strings.EqualFold(mt, oci.MediaTypeImageIndex) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.list.v2+json")
}
// manifestCreationInfo describes a manifest to create
type manifestCreationInfo struct {
MediaType string
@ -66,16 +54,16 @@ func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packag
return "", err
}
if !isMediaTypeValid(mci.MediaType) {
if !container_module.IsMediaTypeValid(mci.MediaType) {
mci.MediaType = index.MediaType
if !isMediaTypeValid(mci.MediaType) {
if !container_module.IsMediaTypeValid(mci.MediaType) {
return "", errManifestInvalid.WithMessage("MediaType not recognized")
}
}
if isMediaTypeImageManifest(mci.MediaType) {
if container_module.IsMediaTypeImageManifest(mci.MediaType) {
return processOciImageManifest(ctx, mci, buf)
} else if isMediaTypeImageIndex(mci.MediaType) {
} else if container_module.IsMediaTypeImageIndex(mci.MediaType) {
return processOciImageIndex(ctx, mci, buf)
}
return "", errManifestInvalid
@ -201,7 +189,7 @@ func processOciImageIndex(ctx context.Context, mci *manifestCreationInfo, buf *p
}
for _, manifest := range index.Manifests {
if !isMediaTypeImageManifest(manifest.MediaType) {
if !container_module.IsMediaTypeImageManifest(manifest.MediaType) {
return errManifestInvalid
}
@ -336,7 +324,7 @@ func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, met
return nil, err
}
if isMediaTypeImageIndex(mci.MediaType) {
if container_module.IsMediaTypeImageIndex(mci.MediaType) {
if pv.CreatedUnix.AsTime().Before(time.Now().Add(-24 * time.Hour)) {
if err = packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
return nil, err

View File

@ -0,0 +1,93 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"code.gitea.io/gitea/routers/api/v1/shared"
"code.gitea.io/gitea/services/context"
)
// ListWorkflowJobs Lists all jobs
func ListWorkflowJobs(ctx *context.APIContext) {
// swagger:operation GET /admin/actions/jobs admin listAdminWorkflowJobs
// ---
// summary: Lists all jobs
// produces:
// - application/json
// parameters:
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
shared.ListJobs(ctx, 0, 0, 0)
}
// ListWorkflowRuns Lists all runs
func ListWorkflowRuns(ctx *context.APIContext) {
// swagger:operation GET /admin/actions/runs admin listAdminWorkflowRuns
// ---
// summary: Lists all runs
// produces:
// - application/json
// parameters:
// - name: event
// in: query
// description: workflow event name
// type: string
// required: false
// - name: branch
// in: query
// description: workflow branch
// type: string
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
// in: query
// description: triggered by user
// type: string
// required: false
// - name: head_sha
// in: query
// description: triggering sha of the workflow run
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowRunsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
shared.ListRuns(ctx, 0, 0)
}

View File

@ -942,6 +942,8 @@ func Routes() *web.Router {
m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner)
m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner)
})
m.Get("/runs", reqToken(), reqChecker, act.ListWorkflowRuns)
m.Get("/jobs", reqToken(), reqChecker, act.ListWorkflowJobs)
})
}
@ -1078,6 +1080,9 @@ func Routes() *web.Router {
m.Get("/{runner_id}", reqToken(), user.GetRunner)
m.Delete("/{runner_id}", reqToken(), user.DeleteRunner)
})
m.Get("/runs", reqToken(), user.ListWorkflowRuns)
m.Get("/jobs", reqToken(), user.ListWorkflowJobs)
})
m.Get("/followers", user.ListMyFollowers)
@ -1202,6 +1207,7 @@ func Routes() *web.Router {
}, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions))
m.Group("/actions/jobs", func() {
m.Get("/{job_id}", repo.GetWorkflowJob)
m.Get("/{job_id}/logs", repo.DownloadActionsRunJobLogs)
}, reqToken(), reqRepoReader(unit.TypeActions))
@ -1280,9 +1286,13 @@ func Routes() *web.Router {
}, reqToken(), reqAdmin())
m.Group("/actions", func() {
m.Get("/tasks", repo.ListActionTasks)
m.Group("/runs/{run}", func() {
m.Get("/artifacts", repo.GetArtifactsOfRun)
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
m.Group("/runs", func() {
m.Group("/{run}", func() {
m.Get("", repo.GetWorkflowRun)
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
m.Get("/jobs", repo.ListWorkflowRunJobs)
m.Get("/artifacts", repo.GetArtifactsOfRun)
})
})
m.Get("/artifacts", repo.GetArtifacts)
m.Group("/artifacts/{artifact_id}", func() {
@ -1734,11 +1744,15 @@ func Routes() *web.Router {
Patch(bind(api.EditHookOption{}), admin.EditHook).
Delete(admin.DeleteHook)
})
m.Group("/actions/runners", func() {
m.Get("", admin.ListRunners)
m.Post("/registration-token", admin.CreateRegistrationToken)
m.Get("/{runner_id}", admin.GetRunner)
m.Delete("/{runner_id}", admin.DeleteRunner)
m.Group("/actions", func() {
m.Group("/runners", func() {
m.Get("", admin.ListRunners)
m.Post("/registration-token", admin.CreateRegistrationToken)
m.Get("/{runner_id}", admin.GetRunner)
m.Delete("/{runner_id}", admin.DeleteRunner)
})
m.Get("/runs", admin.ListWorkflowRuns)
m.Get("/jobs", admin.ListWorkflowJobs)
})
m.Group("/runners", func() {
m.Get("/registration-token", admin.GetRegistrationToken)

View File

@ -570,6 +570,96 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
}
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/actions/jobs organization getOrgWorkflowJobs
// ---
// summary: Get org-level workflow jobs
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0)
}
func (Action) ListWorkflowRuns(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/actions/runs organization getOrgWorkflowRuns
// ---
// summary: Get org-level workflow runs
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: event
// in: query
// description: workflow event name
// type: string
// required: false
// - name: branch
// in: query
// description: workflow branch
// type: string
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
// in: query
// description: triggered by user
// type: string
// required: false
// - name: head_sha
// in: query
// description: triggering sha of the workflow run
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowRunsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)
}
var _ actions_service.API = new(Action)
// Action implements actions_service.API

View File

@ -650,6 +650,114 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
}
// GetWorkflowRunJobs Lists all jobs for a workflow run.
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs
// ---
// summary: Lists all jobs for a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: name of the owner
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
repoID := ctx.Repo.Repository.ID
shared.ListJobs(ctx, 0, repoID, 0)
}
// ListWorkflowRuns Lists all runs for a repository run.
func (Action) ListWorkflowRuns(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs repository getWorkflowRuns
// ---
// summary: Lists all runs for a repository run
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: name of the owner
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: event
// in: query
// description: workflow event name
// type: string
// required: false
// - name: branch
// in: query
// description: workflow branch
// type: string
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
// in: query
// description: triggered by user
// type: string
// required: false
// - name: head_sha
// in: query
// description: triggering sha of the workflow run
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ArtifactsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
repoID := ctx.Repo.Repository.ID
shared.ListRuns(ctx, 0, repoID)
}
var _ actions_service.API = new(Action)
// Action implements actions_service.API
@ -756,7 +864,7 @@ func ActionsListRepositoryWorkflows(ctx *context.APIContext) {
// "500":
// "$ref": "#/responses/error"
workflows, err := actions_service.ListActionWorkflows(ctx)
workflows, err := convert.ListActionWorkflows(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository)
if err != nil {
ctx.APIErrorInternal(err)
return
@ -802,7 +910,7 @@ func ActionsGetWorkflow(ctx *context.APIContext) {
// "$ref": "#/responses/error"
workflowID := ctx.PathParam("workflow_id")
workflow, err := actions_service.GetActionWorkflow(ctx, workflowID)
workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
@ -992,6 +1100,157 @@ func ActionsEnableWorkflow(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
// GetWorkflowRun Gets a specific workflow run.
func GetWorkflowRun(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun
// ---
// summary: Gets a specific workflow run
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: name of the owner
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: run
// in: path
// description: id of the run
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/WorkflowRun"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
runID := ctx.PathParamInt64("run")
job, _, err := db.GetByID[actions_model.ActionRun](ctx, runID)
if err != nil || job.RepoID != ctx.Repo.Repository.ID {
ctx.APIError(http.StatusNotFound, util.ErrNotExist)
}
convertedArtifact, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, job)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convertedArtifact)
}
// ListWorkflowRunJobs Lists all jobs for a workflow run.
func ListWorkflowRunJobs(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs repository listWorkflowRunJobs
// ---
// summary: Lists all jobs for a workflow run
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: name of the owner
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: run
// in: path
// description: runid of the workflow run
// type: integer
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
repoID := ctx.Repo.Repository.ID
runID := ctx.PathParamInt64("run")
// Avoid the list all jobs functionality for this api route to be used with a runID == 0.
if runID <= 0 {
ctx.APIError(http.StatusBadRequest, util.NewInvalidArgumentErrorf("runID must be a positive integer"))
return
}
// runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID.
// no additional checks for runID are needed here
shared.ListJobs(ctx, 0, repoID, runID)
}
// GetWorkflowJob Gets a specific workflow job for a workflow run.
func GetWorkflowJob(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs/{job_id} repository getWorkflowJob
// ---
// summary: Gets a specific workflow job for a workflow run
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: name of the owner
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: job_id
// in: path
// description: id of the job
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/WorkflowJob"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
jobID := ctx.PathParamInt64("job_id")
job, _, err := db.GetByID[actions_model.ActionRunJob](ctx, jobID)
if err != nil || job.RepoID != ctx.Repo.Repository.ID {
ctx.APIError(http.StatusNotFound, util.ErrNotExist)
}
convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, job)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convertedWorkflowJob)
}
// GetArtifacts Lists all artifacts for a repository.
func GetArtifactsOfRun(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/artifacts repository getArtifactsOfRun

View File

@ -1181,7 +1181,7 @@ func MergeUpstream(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.MergeUpstreamRequest)
mergeStyle, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, form.Branch)
mergeStyle, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, form.Branch, form.FfOnly)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err)

View File

@ -470,6 +470,9 @@ func ChangeFiles(ctx *context.APIContext) {
ctx.APIError(http.StatusUnprocessableEntity, err)
return
}
// FIXME: actually now we support more operations like "rename", "upload"
// FIXME: ChangeFileOperation.SHA is NOT required for update or delete if last commit is provided in the options.
// Need to fully fix them in API
changeRepoFile := &files_service.ChangeRepoFile{
Operation: file.Operation,
TreePath: file.Path,

View File

@ -0,0 +1,187 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package shared
import (
"fmt"
"net/http"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/webhook"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
)
// ListJobs lists jobs for api route validated ownerID and repoID
// ownerID == 0 and repoID == 0 means all jobs
// ownerID == 0 and repoID != 0 means all jobs for the given repo
// ownerID != 0 and repoID == 0 means all jobs for the given user/org
// ownerID != 0 and repoID != 0 undefined behavior
// runID == 0 means all jobs
// runID is used as an additional filter together with ownerID and repoID to only return jobs for the given run
// Access rights are checked at the API route level
func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
if ownerID != 0 && repoID != 0 {
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
opts := actions_model.FindRunJobOptions{
OwnerID: ownerID,
RepoID: repoID,
RunID: runID,
ListOptions: utils.GetListOptions(ctx),
}
for _, status := range ctx.FormStrings("status") {
values, err := convertToInternal(status)
if err != nil {
ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
return
}
opts.Statuses = append(opts.Statuses, values...)
}
jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
res := new(api.ActionWorkflowJobsResponse)
res.TotalCount = total
res.Entries = make([]*api.ActionWorkflowJob, len(jobs))
isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
for i := range jobs {
var repository *repo_model.Repository
if isRepoLevel {
repository = ctx.Repo.Repository
} else {
repository, err = repo_model.GetRepositoryByID(ctx, jobs[i].RepoID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i])
if err != nil {
ctx.APIErrorInternal(err)
return
}
res.Entries[i] = convertedWorkflowJob
}
ctx.JSON(http.StatusOK, &res)
}
func convertToInternal(s string) ([]actions_model.Status, error) {
switch s {
case "pending", "waiting", "requested", "action_required":
return []actions_model.Status{actions_model.StatusBlocked}, nil
case "queued":
return []actions_model.Status{actions_model.StatusWaiting}, nil
case "in_progress":
return []actions_model.Status{actions_model.StatusRunning}, nil
case "completed":
return []actions_model.Status{
actions_model.StatusSuccess,
actions_model.StatusFailure,
actions_model.StatusSkipped,
actions_model.StatusCancelled,
}, nil
case "failure":
return []actions_model.Status{actions_model.StatusFailure}, nil
case "success":
return []actions_model.Status{actions_model.StatusSuccess}, nil
case "skipped", "neutral":
return []actions_model.Status{actions_model.StatusSkipped}, nil
case "cancelled", "timed_out":
return []actions_model.Status{actions_model.StatusCancelled}, nil
default:
return nil, fmt.Errorf("invalid status %s", s)
}
}
// ListRuns lists jobs for api route validated ownerID and repoID
// ownerID == 0 and repoID == 0 means all runs
// ownerID == 0 and repoID != 0 means all runs for the given repo
// ownerID != 0 and repoID == 0 means all runs for the given user/org
// ownerID != 0 and repoID != 0 undefined behavior
// Access rights are checked at the API route level
func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
if ownerID != 0 && repoID != 0 {
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
opts := actions_model.FindRunOptions{
OwnerID: ownerID,
RepoID: repoID,
ListOptions: utils.GetListOptions(ctx),
}
if event := ctx.FormString("event"); event != "" {
opts.TriggerEvent = webhook.HookEventType(event)
}
if branch := ctx.FormString("branch"); branch != "" {
opts.Ref = string(git.RefNameFromBranch(branch))
}
for _, status := range ctx.FormStrings("status") {
values, err := convertToInternal(status)
if err != nil {
ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
return
}
opts.Status = append(opts.Status, values...)
}
if actor := ctx.FormString("actor"); actor != "" {
user, err := user_model.GetUserByName(ctx, actor)
if err != nil {
ctx.APIErrorInternal(err)
return
}
opts.TriggerUserID = user.ID
}
if headSHA := ctx.FormString("head_sha"); headSHA != "" {
opts.CommitSHA = headSHA
}
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
res := new(api.ActionWorkflowRunsResponse)
res.TotalCount = total
res.Entries = make([]*api.ActionWorkflowRun, len(runs))
isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
for i := range runs {
var repository *repo_model.Repository
if isRepoLevel {
repository = ctx.Repo.Repository
} else {
repository, err = repo_model.GetRepositoryByID(ctx, runs[i].RepoID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i])
if err != nil {
ctx.APIErrorInternal(err)
return
}
res.Entries[i] = convertedRun
}
ctx.JSON(http.StatusOK, &res)
}

View File

@ -443,6 +443,34 @@ type swaggerRepoTasksList struct {
Body api.ActionTaskResponse `json:"body"`
}
// WorkflowRunsList
// swagger:response WorkflowRunsList
type swaggerActionWorkflowRunsResponse struct {
// in:body
Body api.ActionWorkflowRunsResponse `json:"body"`
}
// WorkflowRun
// swagger:response WorkflowRun
type swaggerWorkflowRun struct {
// in:body
Body api.ActionWorkflowRun `json:"body"`
}
// WorkflowJobsList
// swagger:response WorkflowJobsList
type swaggerActionWorkflowJobsResponse struct {
// in:body
Body api.ActionWorkflowJobsResponse `json:"body"`
}
// WorkflowJob
// swagger:response WorkflowJob
type swaggerWorkflowJob struct {
// in:body
Body api.ActionWorkflowJob `json:"body"`
}
// ArtifactsList
// swagger:response ArtifactsList
type swaggerRepoArtifactsList struct {

View File

@ -12,6 +12,7 @@ import (
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/shared"
"code.gitea.io/gitea/routers/api/v1/utils"
actions_service "code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/context"
@ -356,3 +357,86 @@ func ListVariables(ctx *context.APIContext) {
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, variables)
}
// ListWorkflowRuns lists workflow runs
func ListWorkflowRuns(ctx *context.APIContext) {
// swagger:operation GET /user/actions/runs user getUserWorkflowRuns
// ---
// summary: Get workflow runs
// parameters:
// - name: event
// in: query
// description: workflow event name
// type: string
// required: false
// - name: branch
// in: query
// description: workflow branch
// type: string
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
// in: query
// description: triggered by user
// type: string
// required: false
// - name: head_sha
// in: query
// description: triggering sha of the workflow run
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/WorkflowRunsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
shared.ListRuns(ctx, ctx.Doer.ID, 0)
}
// ListWorkflowJobs lists workflow jobs
func ListWorkflowJobs(ctx *context.APIContext) {
// swagger:operation GET /user/actions/jobs user getUserWorkflowJobs
// ---
// summary: Get workflow jobs
// parameters:
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
}

View File

@ -173,6 +173,7 @@ func updateHookEvents(events []string) webhook_module.HookEvents {
hookEvents[webhook_module.HookEventRelease] = util.SliceContainsString(events, string(webhook_module.HookEventRelease), true)
hookEvents[webhook_module.HookEventPackage] = util.SliceContainsString(events, string(webhook_module.HookEventPackage), true)
hookEvents[webhook_module.HookEventStatus] = util.SliceContainsString(events, string(webhook_module.HookEventStatus), true)
hookEvents[webhook_module.HookEventWorkflowRun] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowRun), true)
hookEvents[webhook_module.HookEventWorkflowJob] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowJob), true)
// Issues

View File

@ -304,7 +304,7 @@ func ViewPost(ctx *context_module.Context) {
if task != nil {
steps, logs, err := convertToViewModel(ctx, req.LogCursors, task)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("convertToViewModel", err)
return
}
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...)
@ -408,7 +408,7 @@ func Rerun(ctx *context_module.Context) {
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("GetRunByIndex", err)
return
}
@ -426,7 +426,7 @@ func Rerun(ctx *context_module.Context) {
run.Started = 0
run.Stopped = 0
if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("UpdateRun", err)
return
}
}
@ -441,7 +441,7 @@ func Rerun(ctx *context_module.Context) {
// if the job has needs, it should be set to "blocked" status to wait for other jobs
shouldBlock := len(j.Needs) > 0
if err := rerunJob(ctx, j, shouldBlock); err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("RerunJob", err)
return
}
}
@ -455,7 +455,7 @@ func Rerun(ctx *context_module.Context) {
// jobs other than the specified one should be set to "blocked" status
shouldBlock := j.JobID != job.JobID
if err := rerunJob(ctx, j, shouldBlock); err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("RerunJob", err)
return
}
}
@ -485,7 +485,7 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou
}
actions_service.CreateCommitStatus(ctx, job)
_ = job.LoadAttributes(ctx)
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
return nil
@ -547,7 +547,7 @@ func Cancel(ctx *context_module.Context) {
}
return nil
}); err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("StopTask", err)
return
}
@ -557,7 +557,11 @@ func Cancel(ctx *context_module.Context) {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
if len(updatedjobs) > 0 {
job := updatedjobs[0]
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
ctx.JSON(http.StatusOK, struct{}{})
}
@ -593,12 +597,18 @@ func Approve(ctx *context_module.Context) {
}
return nil
}); err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("UpdateRunJob", err)
return
}
actions_service.CreateCommitStatus(ctx, jobs...)
if len(updatedjobs) > 0 {
job := updatedjobs[0]
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
for _, job := range updatedjobs {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
@ -680,7 +690,7 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
return
}
if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("SetArtifactNeedDelete", err)
return
}
ctx.JSON(http.StatusOK, struct{}{})
@ -696,7 +706,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
ctx.HTTPError(http.StatusNotFound, err.Error())
return
}
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("GetRunByIndex", err)
return
}
@ -705,7 +715,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
ArtifactName: artifactName,
})
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("FindArtifacts", err)
return
}
if len(artifacts) == 0 {
@ -726,7 +736,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
err := actions.DownloadArtifactV4(ctx.Base, artifacts[0])
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("DownloadArtifactV4", err)
return
}
return
@ -739,7 +749,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
for _, art := range artifacts {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("ActionsArtifacts.Open", err)
return
}
@ -747,7 +757,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
if art.ContentEncoding == "gzip" {
r, err = gzip.NewReader(f)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("gzip.NewReader", err)
return
}
} else {
@ -757,11 +767,11 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
w, err := writer.Create(art.ArtifactPath)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("writer.Create", err)
return
}
if _, err := io.Copy(w, r); err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error())
ctx.ServerError("io.Copy", err)
return
}
}

View File

@ -258,7 +258,7 @@ func CreateBranch(ctx *context.Context) {
func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName)
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()

View File

@ -1,193 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"bytes"
"errors"
"net/http"
"strings"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/repository/files"
)
var tplCherryPick templates.TplName = "repo/editor/cherry_pick"
// CherryPick handles cherrypick GETs
func CherryPick(ctx *context.Context) {
ctx.Data["SHA"] = ctx.PathParam("sha")
cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(ctx.PathParam("sha"))
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(err)
return
}
ctx.ServerError("GetCommit", err)
return
}
if ctx.FormString("cherry-pick-type") == "revert" {
ctx.Data["CherryPickType"] = "revert"
ctx.Data["commit_summary"] = "revert " + ctx.PathParam("sha")
ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message()
} else {
ctx.Data["CherryPickType"] = "cherry-pick"
splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2)
ctx.Data["commit_summary"] = splits[0]
ctx.Data["commit_message"] = splits[1]
}
canCommit := renderCommitRights(ctx)
ctx.Data["TreePath"] = ""
if canCommit {
ctx.Data["commit_choice"] = frmCommitChoiceDirect
} else {
ctx.Data["commit_choice"] = frmCommitChoiceNewBranch
}
ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx)
ctx.Data["last_commit"] = ctx.Repo.CommitID
ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",")
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.HTML(http.StatusOK, tplCherryPick)
}
// CherryPickPost handles cherrypick POSTs
func CherryPickPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CherryPickForm)
sha := ctx.PathParam("sha")
ctx.Data["SHA"] = sha
if form.Revert {
ctx.Data["CherryPickType"] = "revert"
} else {
ctx.Data["CherryPickType"] = "cherry-pick"
}
canCommit := renderCommitRights(ctx)
branchName := ctx.Repo.BranchName
if form.CommitChoice == frmCommitChoiceNewBranch {
branchName = form.NewBranchName
}
ctx.Data["commit_summary"] = form.CommitSummary
ctx.Data["commit_message"] = form.CommitMessage
ctx.Data["commit_choice"] = form.CommitChoice
ctx.Data["new_branch_name"] = form.NewBranchName
ctx.Data["last_commit"] = ctx.Repo.CommitID
ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",")
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplCherryPick)
return
}
// Cannot commit to a an existing branch if user doesn't have rights
if branchName == ctx.Repo.BranchName && !canCommit {
ctx.Data["Err_NewBranchName"] = true
ctx.Data["commit_choice"] = frmCommitChoiceNewBranch
ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplCherryPick, &form)
return
}
message := strings.TrimSpace(form.CommitSummary)
if message == "" {
if form.Revert {
message = ctx.Locale.TrString("repo.commit.revert-header", sha)
} else {
message = ctx.Locale.TrString("repo.commit.cherry-pick-header", sha)
}
}
form.CommitMessage = strings.TrimSpace(form.CommitMessage)
if len(form.CommitMessage) > 0 {
message += "\n\n" + form.CommitMessage
}
gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail)
if !valid {
ctx.Data["Err_CommitEmail"] = true
ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplCherryPick, &form)
return
}
opts := &files.ApplyDiffPatchOptions{
LastCommitID: form.LastCommit,
OldBranch: ctx.Repo.BranchName,
NewBranch: branchName,
Message: message,
Author: gitCommitter,
Committer: gitCommitter,
}
// First lets try the simple plain read-tree -m approach
opts.Content = sha
if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, form.Revert, opts); err != nil {
if git_model.IsErrBranchAlreadyExists(err) {
// User has specified a branch that already exists
branchErr := err.(git_model.ErrBranchAlreadyExists)
ctx.Data["Err_NewBranchName"] = true
ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form)
return
} else if files.IsErrCommitIDDoesNotMatch(err) {
ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form)
return
}
// Drop through to the apply technique
buf := &bytes.Buffer{}
if form.Revert {
if err := git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), sha, buf); err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(errors.New("commit " + ctx.PathParam("sha") + " does not exist."))
return
}
ctx.ServerError("GetRawDiff", err)
return
}
} else {
if err := git.GetRawDiff(ctx.Repo.GitRepo, sha, git.RawDiffType("patch"), buf); err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(errors.New("commit " + ctx.PathParam("sha") + " does not exist."))
return
}
ctx.ServerError("GetRawDiff", err)
return
}
}
opts.Content = buf.String()
ctx.Data["FileContent"] = opts.Content
if _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
if git_model.IsErrBranchAlreadyExists(err) {
// User has specified a branch that already exists
branchErr := err.(git_model.ErrBranchAlreadyExists)
ctx.Data["Err_NewBranchName"] = true
ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form)
return
} else if files.IsErrCommitIDDoesNotMatch(err) {
ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form)
return
}
ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form)
return
}
}
if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) {
ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName))
} else {
ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/repository/files"
)
func NewDiffPatch(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_diffpatch")
if ctx.Written() {
return
}
ctx.Data["PageIsPatch"] = true
ctx.HTML(http.StatusOK, tplPatchFile)
}
// NewDiffPatchPost response for sending patch page
func NewDiffPatchPost(ctx *context.Context) {
parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() {
return
}
defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch")
_, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName,
NewBranch: parsed.TargetBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"),
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
})
if err != nil {
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
}
if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
return
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
}

View File

@ -0,0 +1,86 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"bytes"
"net/http"
"strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/repository/files"
)
func CherryPick(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_cherrypick")
if ctx.Written() {
return
}
fromCommitID := ctx.PathParam("sha")
ctx.Data["FromCommitID"] = fromCommitID
cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(fromCommitID)
if err != nil {
HandleGitError(ctx, "GetCommit", err)
return
}
if ctx.FormString("cherry-pick-type") == "revert" {
ctx.Data["CherryPickType"] = "revert"
ctx.Data["commit_summary"] = "revert " + ctx.PathParam("sha")
ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message()
} else {
ctx.Data["CherryPickType"] = "cherry-pick"
splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2)
ctx.Data["commit_summary"] = splits[0]
ctx.Data["commit_message"] = splits[1]
}
ctx.HTML(http.StatusOK, tplCherryPick)
}
func CherryPickPost(ctx *context.Context) {
fromCommitID := ctx.PathParam("sha")
parsed := parseEditorCommitSubmittedForm[*forms.CherryPickForm](ctx)
if ctx.Written() {
return
}
defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID))
opts := &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName,
NewBranch: parsed.TargetBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
}
// First try the simple plain read-tree -m approach
opts.Content = fromCommitID
if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, parsed.form.Revert, opts); err != nil {
// Drop through to the "apply" method
buf := &bytes.Buffer{}
if parsed.form.Revert {
err = git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), fromCommitID, buf)
} else {
err = git.GetRawDiff(ctx.Repo.GitRepo, fromCommitID, "patch", buf)
}
if err == nil {
opts.Content = buf.String()
_, err = files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts)
if err != nil {
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
}
}
if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
return
}
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
}

View File

@ -0,0 +1,82 @@
// Copyright 2025 Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/utils"
context_service "code.gitea.io/gitea/services/context"
files_service "code.gitea.io/gitea/services/repository/files"
)
func errorAs[T error](v error) (e T, ok bool) {
if errors.As(v, &e) {
return e, true
}
return e, false
}
func editorHandleFileOperationErrorRender(ctx *context_service.Context, message, summary, details string) {
flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
"Message": message,
"Summary": summary,
"Details": utils.SanitizeFlashErrorString(details),
})
if err == nil {
ctx.JSONError(flashError)
} else {
log.Error("RenderToHTML: %v", err)
ctx.JSONError(message + "\n" + summary + "\n" + utils.SanitizeFlashErrorString(details))
}
}
func editorHandleFileOperationError(ctx *context_service.Context, targetBranchName string, err error) {
if errAs := util.ErrorAsLocale(err); errAs != nil {
ctx.JSONError(ctx.Tr(errAs.TrKey, errAs.TrArgs...))
} else if errAs, ok := errorAs[git.ErrNotExist](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.file_modifying_no_longer_exists", errAs.RelPath))
} else if errAs, ok := errorAs[git_model.ErrLFSFileLocked](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.upload_file_is_locked", errAs.Path, errAs.UserName))
} else if errAs, ok := errorAs[files_service.ErrFilenameInvalid](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path))
} else if errAs, ok := errorAs[files_service.ErrFilePathInvalid](err); ok {
switch errAs.Type {
case git.EntryModeSymlink:
ctx.JSONError(ctx.Tr("repo.editor.file_is_a_symlink", errAs.Path))
case git.EntryModeTree:
ctx.JSONError(ctx.Tr("repo.editor.filename_is_a_directory", errAs.Path))
case git.EntryModeBlob:
ctx.JSONError(ctx.Tr("repo.editor.directory_is_a_file", errAs.Path))
default:
ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path))
}
} else if errAs, ok := errorAs[files_service.ErrRepoFileAlreadyExists](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.file_already_exists", errAs.Path))
} else if errAs, ok := errorAs[git.ErrBranchNotExist](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.branch_does_not_exist", errAs.Name))
} else if errAs, ok := errorAs[git_model.ErrBranchAlreadyExists](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", errAs.BranchName))
} else if files_service.IsErrCommitIDDoesNotMatch(err) {
ctx.JSONError(ctx.Tr("repo.editor.commit_id_not_matching"))
} else if files_service.IsErrCommitIDDoesNotMatch(err) || git.IsErrPushOutOfDate(err) {
ctx.JSONError(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(ctx.Repo.CommitID)+"..."+util.PathEscapeSegments(targetBranchName)))
} else if errAs, ok := errorAs[*git.ErrPushRejected](err); ok {
if errAs.Message == "" {
ctx.JSONError(ctx.Tr("repo.editor.push_rejected_no_message"))
} else {
editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.push_rejected"), ctx.Locale.TrString("repo.editor.push_rejected_summary"), errAs.Message)
}
} else if errors.Is(err, util.ErrNotExist) {
ctx.JSONError(ctx.Tr("error.not_found"))
} else {
setting.PanicInDevOrTesting("unclear err %T: %v", err, err)
editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.failed_to_commit"), ctx.Locale.TrString("repo.editor.failed_to_commit_summary"), err.Error())
}
}

View File

@ -0,0 +1,41 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"code.gitea.io/gitea/services/context"
files_service "code.gitea.io/gitea/services/repository/files"
)
func DiffPreviewPost(ctx *context.Context) {
content := ctx.FormString("content")
treePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if treePath == "" {
ctx.HTTPError(http.StatusBadRequest, "file name to diff is invalid")
return
}
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath)
if err != nil {
ctx.ServerError("GetTreeEntryByPath", err)
return
} else if entry.IsDir() {
ctx.HTTPError(http.StatusUnprocessableEntity)
return
}
diff, err := files_service.GetDiffPreview(ctx, ctx.Repo.Repository, ctx.Repo.BranchName, treePath, content)
if err != nil {
ctx.ServerError("GetDiffPreview", err)
return
}
if len(diff.Files) != 0 {
ctx.Data["File"] = diff.Files[0]
}
ctx.HTML(http.StatusOK, tplEditDiffPreview)
}

View File

@ -6,76 +6,27 @@ package repo
import (
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestCleanUploadName(t *testing.T) {
func TestEditorUtils(t *testing.T) {
unittest.PrepareTestEnv(t)
kases := map[string]string{
".git/refs/master": "",
"/root/abc": "root/abc",
"./../../abc": "abc",
"a/../.git": "",
"a/../../../abc": "abc",
"../../../acd": "acd",
"../../.git/abc": "",
"..\\..\\.git/abc": "..\\..\\.git/abc",
"..\\../.git/abc": "",
"..\\../.git": "",
"abc/../def": "def",
".drone.yml": ".drone.yml",
".abc/def/.drone.yml": ".abc/def/.drone.yml",
"..drone.yml.": "..drone.yml.",
"..a.dotty...name...": "..a.dotty...name...",
"..a.dotty../.folder../.name...": "..a.dotty../.folder../.name...",
}
for k, v := range kases {
assert.Equal(t, cleanUploadFileName(k), v)
}
}
func TestGetUniquePatchBranchName(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetPathParam("id", "1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
expectedBranchName := "user2-patch-1"
branchName := GetUniquePatchBranchName(ctx)
assert.Equal(t, expectedBranchName, branchName)
}
func TestGetClosestParentWithFiles(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetPathParam("id", "1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
branch := repo.DefaultBranch
gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
defer gitRepo.Close()
commit, _ := gitRepo.GetBranchCommit(branch)
var expectedTreePath string // Should return the root dir, empty string, since there are no subdirs in this repo
for _, deletedFile := range []string{
"dir1/dir2/dir3/file.txt",
"file.txt",
} {
treePath := GetClosestParentWithFiles(deletedFile, commit)
assert.Equal(t, expectedTreePath, treePath)
}
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
t.Run("getUniquePatchBranchName", func(t *testing.T) {
branchName := getUniquePatchBranchName(t.Context(), "user2", repo)
assert.Equal(t, "user2-patch-1", branchName)
})
t.Run("getClosestParentWithFiles", func(t *testing.T) {
gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
defer gitRepo.Close()
treePath := getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "docs/foo/bar")
assert.Equal(t, "docs", treePath)
treePath = getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "any/other")
assert.Empty(t, treePath)
})
}

View File

@ -0,0 +1,61 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload"
files_service "code.gitea.io/gitea/services/repository/files"
)
// UploadFileToServer upload file to server file dir not git
func UploadFileToServer(ctx *context.Context) {
file, header, err := ctx.Req.FormFile("file")
if err != nil {
ctx.ServerError("FormFile", err)
return
}
defer file.Close()
buf := make([]byte, 1024)
n, _ := util.ReadAtMost(file, buf)
if n > 0 {
buf = buf[:n]
}
err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes)
if err != nil {
ctx.HTTPError(http.StatusBadRequest, err.Error())
return
}
name := files_service.CleanGitTreePath(header.Filename)
if len(name) == 0 {
ctx.HTTPError(http.StatusBadRequest, "Upload file name is invalid")
return
}
uploaded, err := repo_model.NewUpload(ctx, name, buf, file)
if err != nil {
ctx.ServerError("NewUpload", err)
return
}
ctx.JSON(http.StatusOK, map[string]string{"uuid": uploaded.UUID})
}
// RemoveUploadFileFromServer remove file from server file dir
func RemoveUploadFileFromServer(ctx *context.Context) {
fileUUID := ctx.FormString("file")
if err := repo_model.DeleteUploadByUUID(ctx, fileUUID); err != nil {
ctx.ServerError("DeleteUploadByUUID", err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@ -0,0 +1,85 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"context"
"fmt"
"path"
"strings"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
context_service "code.gitea.io/gitea/services/context"
)
// getUniquePatchBranchName Gets a unique branch name for a new patch branch
// It will be in the form of <username>-patch-<num> where <num> is the first branch of this format
// that doesn't already exist. If we exceed 1000 tries or an error is thrown, we just return "" so the user has to
// type in the branch name themselves (will be an empty field)
func getUniquePatchBranchName(ctx context.Context, prefixName string, repo *repo_model.Repository) string {
prefix := prefixName + "-patch-"
for i := 1; i <= 1000; i++ {
branchName := fmt.Sprintf("%s%d", prefix, i)
if exist, err := git_model.IsBranchExist(ctx, repo.ID, branchName); err != nil {
log.Error("getUniquePatchBranchName: %v", err)
return ""
} else if !exist {
return branchName
}
}
return ""
}
// getClosestParentWithFiles Recursively gets the closest path of parent in a tree that has files when a file in a tree is
// deleted. It returns "" for the tree root if no parents other than the root have files.
func getClosestParentWithFiles(gitRepo *git.Repository, branchName, originTreePath string) string {
var f func(treePath string, commit *git.Commit) string
f = func(treePath string, commit *git.Commit) string {
if treePath == "" || treePath == "." {
return ""
}
// see if the tree has entries
if tree, err := commit.SubTree(treePath); err != nil {
return f(path.Dir(treePath), commit) // failed to get the tree, going up a dir
} else if entries, err := tree.ListEntries(); err != nil || len(entries) == 0 {
return f(path.Dir(treePath), commit) // no files in this dir, going up a dir
}
return treePath
}
commit, err := gitRepo.GetBranchCommit(branchName) // must get the commit again to get the latest change
if err != nil {
log.Error("GetBranchCommit: %v", err)
return ""
}
return f(originTreePath, commit)
}
// getContextRepoEditorConfig returns the editorconfig JSON string for given treePath or "null"
func getContextRepoEditorConfig(ctx *context_service.Context, treePath string) string {
ec, _, err := ctx.Repo.GetEditorconfig()
if err == nil {
def, err := ec.GetDefinitionForFilename(treePath)
if err == nil {
jsonStr, _ := json.Marshal(def)
return string(jsonStr)
}
}
return "null"
}
// getParentTreeFields returns list of parent tree names and corresponding tree paths based on given treePath.
// eg: []{"a", "b", "c"}, []{"a", "a/b", "a/b/c"}
// or: []{""}, []{""} for the root treePath
func getParentTreeFields(treePath string) (treeNames, treePaths []string) {
treeNames = strings.Split(treePath, "/")
treePaths = make([]string, len(treeNames))
for i := range treeNames {
treePaths[i] = strings.Join(treeNames[:i+1], "/")
}
return treeNames, treePaths
}

View File

@ -1,126 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"strings"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/repository/files"
)
const (
tplPatchFile templates.TplName = "repo/editor/patch"
)
// NewDiffPatch render create patch page
func NewDiffPatch(ctx *context.Context) {
canCommit := renderCommitRights(ctx)
ctx.Data["PageIsPatch"] = true
ctx.Data["commit_summary"] = ""
ctx.Data["commit_message"] = ""
if canCommit {
ctx.Data["commit_choice"] = frmCommitChoiceDirect
} else {
ctx.Data["commit_choice"] = frmCommitChoiceNewBranch
}
ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx)
ctx.Data["last_commit"] = ctx.Repo.CommitID
ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",")
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.HTML(http.StatusOK, tplPatchFile)
}
// NewDiffPatchPost response for sending patch page
func NewDiffPatchPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditRepoFileForm)
canCommit := renderCommitRights(ctx)
branchName := ctx.Repo.BranchName
if form.CommitChoice == frmCommitChoiceNewBranch {
branchName = form.NewBranchName
}
ctx.Data["PageIsPatch"] = true
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["FileContent"] = form.Content
ctx.Data["commit_summary"] = form.CommitSummary
ctx.Data["commit_message"] = form.CommitMessage
ctx.Data["commit_choice"] = form.CommitChoice
ctx.Data["new_branch_name"] = form.NewBranchName
ctx.Data["last_commit"] = ctx.Repo.CommitID
ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",")
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplPatchFile)
return
}
// Cannot commit to an existing branch if user doesn't have rights
if branchName == ctx.Repo.BranchName && !canCommit {
ctx.Data["Err_NewBranchName"] = true
ctx.Data["commit_choice"] = frmCommitChoiceNewBranch
ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form)
return
}
// CommitSummary is optional in the web form, if empty, give it a default message based on add or update
// `message` will be both the summary and message combined
message := strings.TrimSpace(form.CommitSummary)
if len(message) == 0 {
message = ctx.Locale.TrString("repo.editor.patch")
}
form.CommitMessage = strings.TrimSpace(form.CommitMessage)
if len(form.CommitMessage) > 0 {
message += "\n\n" + form.CommitMessage
}
gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail)
if !valid {
ctx.Data["Err_CommitEmail"] = true
ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplPatchFile, &form)
return
}
fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
LastCommitID: form.LastCommit,
OldBranch: ctx.Repo.BranchName,
NewBranch: branchName,
Message: message,
Content: strings.ReplaceAll(form.Content.Value(), "\r", ""),
Author: gitCommitter,
Committer: gitCommitter,
})
if err != nil {
if git_model.IsErrBranchAlreadyExists(err) {
// User has specified a branch that already exists
branchErr := err.(git_model.ErrBranchAlreadyExists)
ctx.Data["Err_NewBranchName"] = true
ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form)
return
} else if files.IsErrCommitIDDoesNotMatch(err) {
ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form)
return
}
ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form)
return
}
if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) {
ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName))
} else {
ctx.Redirect(ctx.Repo.RepoLink + "/commit/" + fileResponse.Commit.SHA)
}
}

View File

@ -381,7 +381,7 @@ func NewRelease(ctx *context.Context) {
ctx.Data["ShowCreateTagOnlyButton"] = false
ctx.Data["tag_name"] = rel.TagName
ctx.Data["tag_target"] = rel.Target
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
ctx.Data["title"] = rel.Title
ctx.Data["content"] = rel.Note
ctx.Data["attachments"] = rel.Attachments
@ -537,7 +537,7 @@ func EditRelease(ctx *context.Context) {
}
ctx.Data["ID"] = rel.ID
ctx.Data["tag_name"] = rel.TagName
ctx.Data["tag_target"] = rel.Target
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
ctx.Data["title"] = rel.Title
ctx.Data["content"] = rel.Note
ctx.Data["prerelease"] = rel.IsPrerelease
@ -583,7 +583,7 @@ func EditReleasePost(ctx *context.Context) {
return
}
ctx.Data["tag_name"] = rel.TagName
ctx.Data["tag_target"] = rel.Target
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
ctx.Data["title"] = rel.Title
ctx.Data["content"] = rel.Note
ctx.Data["prerelease"] = rel.IsPrerelease

View File

@ -185,6 +185,7 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent {
webhook_module.HookEventRepository: form.Repository,
webhook_module.HookEventPackage: form.Package,
webhook_module.HookEventStatus: form.Status,
webhook_module.HookEventWorkflowRun: form.WorkflowRun,
webhook_module.HookEventWorkflowJob: form.WorkflowJob,
},
BranchFilter: form.BranchFilter,

View File

@ -1315,11 +1315,11 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{username}/{reponame}", func() { // repo code
m.Group("", func() {
m.Group("", func() {
m.Post("/_preview/*", web.Bind(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost)
m.Combo("/_edit/*").Get(repo.EditFile).
m.Post("/_preview/*", repo.DiffPreviewPost)
m.Combo("/{editor_action:_edit}/*").Get(repo.EditFile).
Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost)
m.Combo("/{editor_action:_new}/*").Get(repo.EditFile).
Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost)
m.Combo("/_new/*").Get(repo.NewFile).
Post(web.Bind(forms.EditRepoFileForm{}), repo.NewFilePost)
m.Combo("/_delete/*").Get(repo.DeleteFile).
Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost)
m.Combo("/_upload/*", repo.MustBeAbleToUpload).Get(repo.UploadFile).
@ -1331,7 +1331,7 @@ func registerWebRoutes(m *web.Router) {
}, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch(), repo.WebGitOperationCommonData)
m.Group("", func() {
m.Post("/upload-file", repo.UploadFileToServer)
m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer)
m.Post("/upload-remove", repo.RemoveUploadFileFromServer)
}, repo.MustBeAbleToUpload, reqRepoCodeWriter)
}, repo.MustBeEditable, context.RepoMustNotBeArchived())

View File

@ -42,6 +42,10 @@ func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.Ac
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
if len(jobs) > 0 {
job := jobs[0]
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
}
}
@ -123,7 +127,7 @@ func CancelAbandonedJobs(ctx context.Context) error {
}
CreateCommitStatus(ctx, job)
if updated {
_ = job.LoadAttributes(ctx)
NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
}

View File

@ -33,4 +33,8 @@ type API interface {
GetRunner(*context.APIContext)
// DeleteRunner delete runner
DeleteRunner(*context.APIContext)
// ListWorkflowJobs list jobs
ListWorkflowJobs(*context.APIContext)
// ListWorkflowRuns list runs
ListWorkflowRuns(*context.APIContext)
}

View File

@ -11,6 +11,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
notify_service "code.gitea.io/gitea/services/notify"
@ -78,9 +79,30 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
if len(jobs) > 0 {
runUpdated := true
for _, job := range jobs {
if !job.Status.IsDone() {
runUpdated = false
break
}
}
if runUpdated {
NotifyWorkflowRunStatusUpdateWithReload(ctx, jobs[0])
}
}
return nil
}
func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) {
job.Run = nil
if err := job.LoadAttributes(ctx); err != nil {
log.Error("LoadAttributes: %v", err)
return
}
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
type jobStatusResolver struct {
statuses map[int64]actions_model.Status
needs map[int64][]int64

View File

@ -6,13 +6,16 @@ package actions
import (
"context"
actions_model "code.gitea.io/gitea/models/actions"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages"
perm_model "code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
@ -762,3 +765,41 @@ func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_m
Sender: convert.ToUser(ctx, doer, nil),
}).Notify(ctx)
}
func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
ctx = withMethod(ctx, "WorkflowRunStatusUpdate")
var org *api.Organization
if repo.Owner.IsOrganization() {
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
}
status := convert.ToWorkflowRunAction(run.Status)
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
if err != nil {
log.Error("GetActionWorkflow: %v", err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return
}
newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{
Action: status,
Workflow: convertedWorkflow,
WorkflowRun: convertedRun,
Organization: org,
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
Sender: convert.ToUser(ctx, sender, nil),
}).Notify(ctx)
}

View File

@ -178,7 +178,7 @@ func notify(ctx context.Context, input *notifyInput) error {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
if skipWorkflows(input, commit) {
if skipWorkflows(ctx, input, commit) {
return nil
}
@ -243,7 +243,7 @@ func notify(ctx context.Context, input *notifyInput) error {
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref.String())
}
func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool {
// skip workflow runs with a configured skip-ci string in commit message or pr title if the event is push or pull_request(_sync)
// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
skipWorkflowEvents := []webhook_module.HookEventType{
@ -263,6 +263,27 @@ func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
}
}
}
if input.Event == webhook_module.HookEventWorkflowRun {
wrun, ok := input.Payload.(*api.WorkflowRunPayload)
for i := 0; i < 5 && ok && wrun.WorkflowRun != nil; i++ {
if wrun.WorkflowRun.Event != "workflow_run" {
return false
}
r, err := actions_model.GetRunByRepoAndID(ctx, input.Repo.ID, wrun.WorkflowRun.ID)
if err != nil {
log.Error("GetRunByRepoAndID: %v", err)
return true
}
wrun, err = r.GetWorkflowRunEventPayload()
if err != nil {
log.Error("GetWorkflowRunEventPayload: %v", err)
return true
}
}
// skip workflow runs events exceeding the maxiumum of 5 recursive events
log.Debug("repo %s: skipped workflow_run because of recursive event of 5", input.Repo.RepoPath())
return true
}
return false
}
@ -372,6 +393,15 @@ func handleWorkflows(
continue
}
CreateCommitStatus(ctx, alljobs...)
if len(alljobs) > 0 {
job := alljobs[0]
err := job.LoadRun(ctx)
if err != nil {
log.Error("LoadRun: %v", err)
continue
}
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
for _, job := range alljobs {
notify_service.WorkflowJobStatusUpdate(ctx, input.Repo, input.Doer, job, nil)
}

View File

@ -157,6 +157,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
if err != nil {
log.Error("LoadAttributes: %v", err)
}
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
for _, job := range allJobs {
notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil)
}

View File

@ -5,9 +5,6 @@ package actions
import (
"fmt"
"net/http"
"net/url"
"path"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
@ -31,51 +28,8 @@ import (
"github.com/nektos/act/pkg/model"
)
func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
defaultBranch, _ := commit.GetBranchName()
workflowURL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), url.PathEscape(entry.Name()))
workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), url.PathEscape(entry.Name()))
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), url.PathEscape(entry.Name()), url.QueryEscape(ctx.Repo.Repository.DefaultBranch))
// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
// State types:
// - active
// - deleted
// - disabled_fork
// - disabled_inactivity
// - disabled_manually
state := "active"
if cfg.IsWorkflowDisabled(entry.Name()) {
state = "disabled_manually"
}
// The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined
// by retrieving the first and last commits for the file history. The first commit would indicate the creation date,
// while the last commit would represent the modification date. The DeletedAt could be determined by identifying
// the last commit where the file existed. However, this implementation has not been done here yet, as it would likely
// cause a significant performance degradation.
createdAt := commit.Author.When
updatedAt := commit.Author.When
return &api.ActionWorkflow{
ID: entry.Name(),
Name: entry.Name(),
Path: path.Join(folder, entry.Name()),
State: state,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
URL: workflowURL,
HTMLURL: workflowRepoURL,
BadgeURL: badgeURL,
}
}
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
workflow, err := GetActionWorkflow(ctx, workflowID)
workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID)
if err != nil {
return err
}
@ -92,42 +46,6 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
return repo_model.UpdateRepoUnit(ctx, cfgUnit)
}
func ListActionWorkflows(ctx *context.APIContext) ([]*api.ActionWorkflow, error) {
defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if err != nil {
ctx.APIErrorInternal(err)
return nil, err
}
folder, entries, err := actions.ListWorkflows(defaultBranchCommit)
if err != nil {
ctx.APIError(http.StatusNotFound, err.Error())
return nil, err
}
workflows := make([]*api.ActionWorkflow, len(entries))
for i, entry := range entries {
workflows[i] = getActionWorkflowEntry(ctx, defaultBranchCommit, folder, entry)
}
return workflows, nil
}
func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionWorkflow, error) {
entries, err := ListActionWorkflows(ctx)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.Name == workflowID {
return entry, nil
}
}
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
}
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error {
if workflowID == "" {
return util.ErrorWrapLocale(
@ -285,6 +203,15 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
log.Error("FindRunJobs: %v", err)
}
CreateCommitStatus(ctx, allJobs...)
if len(allJobs) > 0 {
job := allJobs[0]
err := job.LoadRun(ctx)
if err != nil {
log.Error("LoadRun: %v", err)
} else {
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
}
for _, job := range allJobs {
notify_service.WorkflowJobStatusUpdate(ctx, repo, doer, job, nil)
}

View File

@ -12,6 +12,8 @@ import (
)
// FormString returns the first value matching the provided key in the form as a string
// It works the same as http.Request.FormValue:
// try urlencoded request body first, then query string, then multipart form body
func (b *Base) FormString(key string, def ...string) string {
s := b.Req.FormValue(key)
if s == "" {
@ -20,7 +22,7 @@ func (b *Base) FormString(key string, def ...string) string {
return s
}
// FormStrings returns a string slice for the provided key from the form
// FormStrings returns a values for the key in the form (including query parameters), similar to FormString
func (b *Base) FormStrings(key string) []string {
if b.Req.Form == nil {
if err := b.Req.ParseMultipartForm(32 << 20); err != nil {

View File

@ -94,24 +94,22 @@ func RepoMustNotBeArchived() func(ctx *Context) {
}
}
// CanCommitToBranchResults represents the results of CanCommitToBranch
type CanCommitToBranchResults struct {
CanCommitToBranch bool
EditorEnabled bool
UserCanPush bool
RequireSigned bool
WillSign bool
SigningKey *git.SigningKey
WontSignReason string
type CommitFormBehaviors struct {
CanCommitToBranch bool
EditorEnabled bool
UserCanPush bool
RequireSigned bool
WillSign bool
SigningKey *git.SigningKey
WontSignReason string
CanCreatePullRequest bool
CanCreateBasePullRequest bool
}
// CanCommitToBranch returns true if repository is editable and user has proper access level
//
// and branch is not protected for push
func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.User) (CanCommitToBranchResults, error) {
func (r *Repository) PrepareCommitFormBehaviors(ctx *Context, doer *user_model.User) (*CommitFormBehaviors, error) {
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, r.Repository.ID, r.BranchName)
if err != nil {
return CanCommitToBranchResults{}, err
return nil, err
}
userCanPush := true
requireSigned := false
@ -138,7 +136,10 @@ func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.Use
}
}
return CanCommitToBranchResults{
canCreateBasePullRequest := ctx.Repo.Repository.BaseRepo != nil && ctx.Repo.Repository.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests)
canCreatePullRequest := ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest
return &CommitFormBehaviors{
CanCommitToBranch: canCommit,
EditorEnabled: canEnableEditor,
UserCanPush: userCanPush,
@ -146,6 +147,9 @@ func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.Use
WillSign: sign,
SigningKey: keyID,
WontSignReason: wontSignReason,
CanCreatePullRequest: canCreatePullRequest,
CanCreateBasePullRequest: canCreateBasePullRequest,
}, err
}

View File

@ -113,5 +113,7 @@ func AddUploadContext(ctx *context.Context, uploadType string) {
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
default:
setting.PanicInDevOrTesting("Invalid upload type: %s", uploadType)
}
}

View File

@ -5,8 +5,11 @@
package convert
import (
"bytes"
"context"
"fmt"
"net/url"
"path"
"strconv"
"strings"
"time"
@ -14,6 +17,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
@ -22,6 +26,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
@ -32,6 +37,7 @@ import (
"code.gitea.io/gitea/services/gitdiff"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/nektos/act/pkg/model"
)
// ToEmail convert models.EmailAddress to api.Email
@ -241,6 +247,242 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
}, nil
}
func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) {
err := run.LoadAttributes(ctx)
if err != nil {
return nil, err
}
status, conclusion := ToActionsStatus(run.Status)
return &api.ActionWorkflowRun{
ID: run.ID,
URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID),
HTMLURL: run.HTMLURL(),
RunNumber: run.Index,
StartedAt: run.Started.AsLocalTime(),
CompletedAt: run.Stopped.AsLocalTime(),
Event: string(run.Event),
DisplayTitle: run.Title,
HeadBranch: git.RefName(run.Ref).BranchName(),
HeadSha: run.CommitSHA,
Status: status,
Conclusion: conclusion,
Path: fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref),
Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
TriggerActor: ToUser(ctx, run.TriggerUser, nil),
// We do not have a way to get a different User for the actor than the trigger user
Actor: ToUser(ctx, run.TriggerUser, nil),
}, nil
}
func ToWorkflowRunAction(status actions_model.Status) string {
var action string
switch status {
case actions_model.StatusWaiting, actions_model.StatusBlocked:
action = "requested"
case actions_model.StatusRunning:
action = "in_progress"
}
if status.IsDone() {
action = "completed"
}
return action
}
func ToActionsStatus(status actions_model.Status) (string, string) {
var action string
var conclusion string
switch status {
// This is a naming conflict of the webhook between Gitea and GitHub Actions
case actions_model.StatusWaiting:
action = "queued"
case actions_model.StatusBlocked:
action = "waiting"
case actions_model.StatusRunning:
action = "in_progress"
}
if status.IsDone() {
action = "completed"
switch status {
case actions_model.StatusSuccess:
conclusion = "success"
case actions_model.StatusCancelled:
conclusion = "cancelled"
case actions_model.StatusFailure:
conclusion = "failure"
case actions_model.StatusSkipped:
conclusion = "skipped"
}
}
return action, conclusion
}
// ToActionWorkflowJob convert a actions_model.ActionRunJob to an api.ActionWorkflowJob
// task is optional and can be nil
func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, job *actions_model.ActionRunJob) (*api.ActionWorkflowJob, error) {
err := job.LoadAttributes(ctx)
if err != nil {
return nil, err
}
jobIndex := 0
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
return nil, err
}
for i, j := range jobs {
if j.ID == job.ID {
jobIndex = i
break
}
}
status, conclusion := ToActionsStatus(job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
if job.TaskID != 0 {
if task == nil {
task, _, err = db.GetByID[actions_model.ActionTask](ctx, job.TaskID)
if err != nil {
return nil, err
}
}
runnerID = task.RunnerID
if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok {
runnerName = runner.Name
}
for i, step := range task.Steps {
stepStatus, stepConclusion := ToActionsStatus(job.Status)
steps = append(steps, &api.ActionWorkflowStep{
Name: step.Name,
Number: int64(i),
Status: stepStatus,
Conclusion: stepConclusion,
StartedAt: step.Started.AsTime().UTC(),
CompletedAt: step.Stopped.AsTime().UTC(),
})
}
}
return &api.ActionWorkflowJob{
ID: job.ID,
// missing api endpoint for this location
URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(), job.ID),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), jobIndex),
RunID: job.RunID,
// Missing api endpoint for this location, artifacts are available under a nested url
RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID),
Name: job.Name,
Labels: job.RunsOn,
RunAttempt: job.Attempt,
HeadSha: job.Run.CommitSHA,
HeadBranch: git.RefName(job.Run.Ref).BranchName(),
Status: status,
Conclusion: conclusion,
RunnerID: runnerID,
RunnerName: runnerName,
Steps: steps,
CreatedAt: job.Created.AsTime().UTC(),
StartedAt: job.Started.AsTime().UTC(),
CompletedAt: job.Stopped.AsTime().UTC(),
}, nil
}
func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
defaultBranch, _ := commit.GetBranchName()
workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), util.PathEscapeSegments(entry.Name()))
workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name()))
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), util.PathEscapeSegments(entry.Name()), url.QueryEscape(repo.DefaultBranch))
// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
// State types:
// - active
// - deleted
// - disabled_fork
// - disabled_inactivity
// - disabled_manually
state := "active"
if cfg.IsWorkflowDisabled(entry.Name()) {
state = "disabled_manually"
}
// The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined
// by retrieving the first and last commits for the file history. The first commit would indicate the creation date,
// while the last commit would represent the modification date. The DeletedAt could be determined by identifying
// the last commit where the file existed. However, this implementation has not been done here yet, as it would likely
// cause a significant performance degradation.
createdAt := commit.Author.When
updatedAt := commit.Author.When
content, err := actions.GetContentFromEntry(entry)
name := entry.Name()
if err == nil {
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
if err == nil {
// Only use the name when specified in the workflow file
if workflow.Name != "" {
name = workflow.Name
}
} else {
log.Error("getActionWorkflowEntry: Failed to parse workflow: %v", err)
}
} else {
log.Error("getActionWorkflowEntry: Failed to get content from entry: %v", err)
}
return &api.ActionWorkflow{
ID: entry.Name(),
Name: name,
Path: path.Join(folder, entry.Name()),
State: state,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
URL: workflowURL,
HTMLURL: workflowRepoURL,
BadgeURL: badgeURL,
}
}
func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository) ([]*api.ActionWorkflow, error) {
defaultBranchCommit, err := gitrepo.GetBranchCommit(repo.DefaultBranch)
if err != nil {
return nil, err
}
folder, entries, err := actions.ListWorkflows(defaultBranchCommit)
if err != nil {
return nil, err
}
workflows := make([]*api.ActionWorkflow, len(entries))
for i, entry := range entries {
workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, folder, entry)
}
return workflows, nil
}
func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string) (*api.ActionWorkflow, error) {
entries, err := ListActionWorkflows(ctx, gitrepo, repo)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.ID == workflowID {
return entry, nil
}
}
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
}
// ToActionArtifact convert a actions_model.ActionArtifact to an api.ActionArtifact
func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArtifact) (*api.ActionArtifact, error) {
url := fmt.Sprintf("%s/actions/artifacts/%d", repo.APIURL(), art.ID)

View File

@ -10,7 +10,6 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
@ -234,6 +233,7 @@ type WebhookForm struct {
Release bool
Package bool
Status bool
WorkflowRun bool
WorkflowJob bool
Active bool
BranchFilter string `binding:"GlobPattern"`
@ -680,129 +680,6 @@ func (f *NewWikiForm) Validate(req *http.Request, errs binding.Errors) binding.E
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// ___________ .___.__ __
// \_ _____/ __| _/|__|/ |_
// | __)_ / __ | | \ __\
// | \/ /_/ | | || |
// /_______ /\____ | |__||__|
// \/ \/
// EditRepoFileForm form for changing repository file
type EditRepoFileForm struct {
TreePath string `binding:"Required;MaxSize(500)"`
Content optional.Option[string]
CommitSummary string `binding:"MaxSize(100)"`
CommitMessage string
CommitChoice string `binding:"Required;MaxSize(50)"`
NewBranchName string `binding:"GitRefName;MaxSize(100)"`
LastCommit string
Signoff bool
CommitEmail string
}
// Validate validates the fields
func (f *EditRepoFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// EditPreviewDiffForm form for changing preview diff
type EditPreviewDiffForm struct {
Content string
}
// Validate validates the fields
func (f *EditPreviewDiffForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// _________ .__ __________.__ __
// \_ ___ \| |__ __________________ ___.__. \______ \__| ____ | | __
// / \ \/| | \_/ __ \_ __ \_ __ < | | | ___/ |/ ___\| |/ /
// \ \___| Y \ ___/| | \/| | \/\___ | | | | \ \___| <
// \______ /___| /\___ >__| |__| / ____| |____| |__|\___ >__|_ \
// \/ \/ \/ \/ \/ \/
// CherryPickForm form for changing repository file
type CherryPickForm struct {
CommitSummary string `binding:"MaxSize(100)"`
CommitMessage string
CommitChoice string `binding:"Required;MaxSize(50)"`
NewBranchName string `binding:"GitRefName;MaxSize(100)"`
LastCommit string
Revert bool
Signoff bool
CommitEmail string
}
// Validate validates the fields
func (f *CherryPickForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// ____ ___ .__ .___
// | | \______ | | _________ __| _/
// | | /\____ \| | / _ \__ \ / __ |
// | | / | |_> > |_( <_> ) __ \_/ /_/ |
// |______/ | __/|____/\____(____ /\____ |
// |__| \/ \/
//
// UploadRepoFileForm form for uploading repository file
type UploadRepoFileForm struct {
TreePath string `binding:"MaxSize(500)"`
CommitSummary string `binding:"MaxSize(100)"`
CommitMessage string
CommitChoice string `binding:"Required;MaxSize(50)"`
NewBranchName string `binding:"GitRefName;MaxSize(100)"`
Files []string
Signoff bool
CommitEmail string
}
// Validate validates the fields
func (f *UploadRepoFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// RemoveUploadFileForm form for removing uploaded file
type RemoveUploadFileForm struct {
File string `binding:"Required;MaxSize(50)"`
}
// Validate validates the fields
func (f *RemoveUploadFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// ________ .__ __
// \______ \ ____ | | _____/ |_ ____
// | | \_/ __ \| | _/ __ \ __\/ __ \
// | ` \ ___/| |_\ ___/| | \ ___/
// /_______ /\___ >____/\___ >__| \___ >
// \/ \/ \/ \/
// DeleteRepoFileForm form for deleting repository file
type DeleteRepoFileForm struct {
CommitSummary string `binding:"MaxSize(100)"`
CommitMessage string
CommitChoice string `binding:"Required;MaxSize(50)"`
NewBranchName string `binding:"GitRefName;MaxSize(100)"`
LastCommit string
Signoff bool
CommitEmail string
}
// Validate validates the fields
func (f *DeleteRepoFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// ___________.__ ___________ __
// \__ ___/|__| _____ ____ \__ ___/___________ ____ | | __ ___________
// | | | |/ \_/ __ \ | | \_ __ \__ \ _/ ___\| |/ // __ \_ __ \

View File

@ -0,0 +1,57 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forms
import (
"net/http"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
"gitea.com/go-chi/binding"
)
type CommitCommonForm struct {
TreePath string `binding:"MaxSize(500)"`
CommitSummary string `binding:"MaxSize(100)"`
CommitMessage string
CommitChoice string `binding:"Required;MaxSize(50)"`
NewBranchName string `binding:"GitRefName;MaxSize(100)"`
LastCommit string
Signoff bool
CommitEmail string
}
func (f *CommitCommonForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
type CommitCommonFormInterface interface {
GetCommitCommonForm() *CommitCommonForm
}
func (f *CommitCommonForm) GetCommitCommonForm() *CommitCommonForm {
return f
}
type EditRepoFileForm struct {
CommitCommonForm
Content optional.Option[string]
}
type DeleteRepoFileForm struct {
CommitCommonForm
}
type UploadRepoFileForm struct {
CommitCommonForm
Files []string
}
type CherryPickForm struct {
CommitCommonForm
Revert bool
}

View File

@ -528,7 +528,7 @@ func TestEmbedBase64Images(t *testing.T) {
require.NoError(t, err)
mailBody := msgs[0].Body
assert.Regexp(t, `MSG-BEFORE <a[^>]+><img src="data:image/png;base64,iVBORw0KGgo="/></a> MSG-AFTER`, mailBody)
assert.Regexp(t, `MSG-BEFORE <a[^>]+><img src="data:image/png;base64,iVBORw0KGgo=".*/></a> MSG-AFTER`, mailBody)
})
t.Run("EmbedInstanceImageSkipExternalImage", func(t *testing.T) {

View File

@ -79,5 +79,7 @@ type Notifier interface {
CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus)
WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun)
WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask)
}

View File

@ -376,6 +376,12 @@ func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit
}
}
func WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
for _, notifier := range notifiers {
notifier.WorkflowRunStatusUpdate(ctx, repo, sender, run)
}
}
func WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) {
for _, notifier := range notifiers {
notifier.WorkflowJobStatusUpdate(ctx, repo, sender, job, task)

View File

@ -214,5 +214,8 @@ func (*NullNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.R
func (*NullNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) {
}
func (*NullNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
}
func (*NullNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) {
}

View File

@ -42,7 +42,7 @@ func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, refComm
}
// Check that the path given in opts.treePath is valid (not a git path)
cleanTreePath := CleanUploadFileName(treePath)
cleanTreePath := CleanGitTreePath(treePath)
if cleanTreePath == "" && treePath != "" {
return nil, ErrFilenameInvalid{
Path: treePath,
@ -103,7 +103,7 @@ func GetObjectTypeFromTreeEntry(entry *git.TreeEntry) ContentType {
// GetContents gets the metadata on a file's contents. Ref can be a branch, commit or tag
func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treePath string, forList bool) (*api.ContentsResponse, error) {
// Check that the path given in opts.treePath is valid (not a git path)
cleanTreePath := CleanUploadFileName(treePath)
cleanTreePath := CleanGitTreePath(treePath)
if cleanTreePath == "" && treePath != "" {
return nil, ErrFilenameInvalid{
Path: treePath,

View File

@ -134,9 +134,8 @@ func (err ErrFilenameInvalid) Unwrap() error {
return util.ErrInvalidArgument
}
// CleanUploadFileName Trims a filename and returns empty string if it is a .git directory
func CleanUploadFileName(name string) string {
// Rebase the filename
// CleanGitTreePath cleans a tree path for git, it returns an empty string the path is invalid (e.g.: contains ".git" part)
func CleanGitTreePath(name string) string {
name = util.PathJoinRel(name)
// Git disallows any filenames to have a .git directory in them.
for part := range strings.SplitSeq(name, "/") {
@ -144,5 +143,8 @@ func CleanUploadFileName(name string) string {
return ""
}
}
if name == "." {
name = ""
}
return name
}

View File

@ -10,17 +10,9 @@ import (
)
func TestCleanUploadFileName(t *testing.T) {
t.Run("Clean regular file", func(t *testing.T) {
name := "this/is/test"
cleanName := CleanUploadFileName(name)
expectedCleanName := name
assert.Equal(t, expectedCleanName, cleanName)
})
t.Run("Clean a .git path", func(t *testing.T) {
name := "this/is/test/.git"
cleanName := CleanUploadFileName(name)
expectedCleanName := ""
assert.Equal(t, expectedCleanName, cleanName)
})
assert.Equal(t, "", CleanGitTreePath("")) //nolint
assert.Equal(t, "", CleanGitTreePath(".")) //nolint
assert.Equal(t, "a/b", CleanGitTreePath("a/b"))
assert.Equal(t, "", CleanGitTreePath(".git/b")) //nolint
assert.Equal(t, "", CleanGitTreePath("a/.git")) //nolint
}

View File

@ -88,8 +88,26 @@ func (err ErrRepoFileDoesNotExist) Unwrap() error {
return util.ErrNotExist
}
type LazyReadSeeker interface {
io.ReadSeeker
io.Closer
OpenLazyReader() error
}
// ChangeRepoFiles adds, updates or removes multiple files in the given repository
func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ChangeRepoFilesOptions) (*structs.FilesResponse, error) {
func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ChangeRepoFilesOptions) (_ *structs.FilesResponse, errRet error) {
var addedLfsPointers []lfs.Pointer
defer func() {
if errRet != nil {
for _, lfsPointer := range addedLfsPointers {
_, err := git_model.RemoveLFSMetaObjectByOid(ctx, repo.ID, lfsPointer.Oid)
if err != nil {
log.Error("ChangeRepoFiles: RemoveLFSMetaObjectByOid failed: %v", err)
}
}
}
}()
err := repo.MustNotBeArchived()
if err != nil {
return nil, err
@ -127,14 +145,14 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
}
// Check that the path given in opts.treePath is valid (not a git path)
treePath := CleanUploadFileName(file.TreePath)
treePath := CleanGitTreePath(file.TreePath)
if treePath == "" {
return nil, ErrFilenameInvalid{
Path: file.TreePath,
}
}
// If there is a fromTreePath (we are copying it), also clean it up
fromTreePath := CleanUploadFileName(file.FromTreePath)
fromTreePath := CleanGitTreePath(file.FromTreePath)
if fromTreePath == "" && file.FromTreePath != "" {
return nil, ErrFilenameInvalid{
Path: file.FromTreePath,
@ -241,10 +259,14 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
lfsContentStore := lfs.NewContentStore()
for _, file := range opts.Files {
switch file.Operation {
case "create", "update", "rename":
if err = CreateUpdateRenameFile(ctx, t, file, lfsContentStore, repo.ID, hasOldBranch); err != nil {
case "create", "update", "rename", "upload":
addedLfsPointer, err := modifyFile(ctx, t, file, lfsContentStore, repo.ID)
if err != nil {
return nil, err
}
if addedLfsPointer != nil {
addedLfsPointers = append(addedLfsPointers, *addedLfsPointer)
}
case "delete":
if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil {
return nil, err
@ -366,18 +388,29 @@ func (err ErrSHAOrCommitIDNotProvided) Error() string {
// handles the check for various issues for ChangeRepoFiles
func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRepoFilesOptions) error {
if file.Operation == "update" || file.Operation == "delete" || file.Operation == "rename" {
fromEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath)
if err != nil {
return err
// check old entry (fromTreePath/fromEntry)
if file.Operation == "update" || file.Operation == "upload" || file.Operation == "delete" || file.Operation == "rename" {
var fromEntryIDString string
{
fromEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath)
if file.Operation == "upload" && git.IsErrNotExist(err) {
fromEntry = nil
} else if err != nil {
return err
}
if fromEntry != nil {
fromEntryIDString = fromEntry.ID.String()
file.Options.executable = fromEntry.IsExecutable() // FIXME: legacy hacky approach, it shouldn't prepare the "Options" in the "check" function
}
}
if file.SHA != "" {
// If the SHA given doesn't match the SHA of the fromTreePath, throw error
if file.SHA != fromEntry.ID.String() {
if file.SHA != fromEntryIDString {
return pull_service.ErrSHADoesNotMatch{
Path: file.Options.treePath,
GivenSHA: file.SHA,
CurrentSHA: fromEntry.ID.String(),
CurrentSHA: fromEntryIDString,
}
}
} else if opts.LastCommitID != "" {
@ -399,11 +432,10 @@ func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRep
// haven't been made. We throw an error if one wasn't provided.
return ErrSHAOrCommitIDNotProvided{}
}
// FIXME: legacy hacky approach, it shouldn't prepare the "Options" in the "check" function
file.Options.executable = fromEntry.IsExecutable()
}
if file.Operation == "create" || file.Operation == "update" || file.Operation == "rename" {
// check new entry (treePath/treeEntry)
if file.Operation == "create" || file.Operation == "update" || file.Operation == "upload" || file.Operation == "rename" {
// For operation's target path, we need to make sure no parts of the path are existing files or links
// except for the last item in the path (which is the file name).
// And that shouldn't exist IF it is a new file OR is being moved to a new path.
@ -454,18 +486,23 @@ func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRep
return nil
}
func CreateUpdateRenameFile(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, contentStore *lfs.ContentStore, repoID int64, hasOldBranch bool) error {
func modifyFile(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, contentStore *lfs.ContentStore, repoID int64) (addedLfsPointer *lfs.Pointer, _ error) {
if rd, ok := file.ContentReader.(LazyReadSeeker); ok {
if err := rd.OpenLazyReader(); err != nil {
return nil, fmt.Errorf("OpenLazyReader: %w", err)
}
defer rd.Close()
}
// Get the two paths (might be the same if not moving) from the index if they exist
filesInIndex, err := t.LsFiles(ctx, file.TreePath, file.FromTreePath)
if err != nil {
return fmt.Errorf("UpdateRepoFile: %w", err)
return nil, fmt.Errorf("LsFiles: %w", err)
}
// If is a new file (not updating) then the given path shouldn't exist
if file.Operation == "create" {
if slices.Contains(filesInIndex, file.TreePath) {
return ErrRepoFileAlreadyExists{
Path: file.TreePath,
}
return nil, ErrRepoFileAlreadyExists{Path: file.TreePath}
}
}
@ -474,7 +511,7 @@ func CreateUpdateRenameFile(ctx context.Context, t *TemporaryUploadRepository, f
for _, indexFile := range filesInIndex {
if indexFile == file.Options.fromTreePath {
if err = t.RemoveFilesFromIndex(ctx, file.FromTreePath); err != nil {
return err
return nil, err
}
}
}
@ -482,45 +519,46 @@ func CreateUpdateRenameFile(ctx context.Context, t *TemporaryUploadRepository, f
var writeObjectRet *writeRepoObjectRet
switch file.Operation {
case "create", "update":
writeObjectRet, err = writeRepoObjectForCreateOrUpdate(ctx, t, file)
case "create", "update", "upload":
writeObjectRet, err = writeRepoObjectForModify(ctx, t, file)
case "rename":
writeObjectRet, err = writeRepoObjectForRename(ctx, t, file)
default:
return util.NewInvalidArgumentErrorf("unknown file modification operation: '%s'", file.Operation)
return nil, util.NewInvalidArgumentErrorf("unknown file modification operation: '%s'", file.Operation)
}
if err != nil {
return err
return nil, err
}
// Add the object to the index, the "file.Options.executable" is set in handleCheckErrors by the caller (legacy hacky approach)
if err = t.AddObjectToIndex(ctx, util.Iif(file.Options.executable, "100755", "100644"), writeObjectRet.ObjectHash, file.Options.treePath); err != nil {
return err
return nil, err
}
if writeObjectRet.LfsContent == nil {
return nil // No LFS pointer, so nothing to do
return nil, nil // No LFS pointer, so nothing to do
}
defer writeObjectRet.LfsContent.Close()
// Now we must store the content into an LFS object
lfsMetaObject, err := git_model.NewLFSMetaObject(ctx, repoID, writeObjectRet.LfsPointer)
if err != nil {
return err
return nil, err
}
if exist, err := contentStore.Exists(lfsMetaObject.Pointer); err != nil {
return err
} else if exist {
return nil
}
err = contentStore.Put(lfsMetaObject.Pointer, writeObjectRet.LfsContent)
exist, err := contentStore.Exists(lfsMetaObject.Pointer)
if err != nil {
if _, errRemove := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); errRemove != nil {
return fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, errRemove, err)
return nil, err
}
if !exist {
err = contentStore.Put(lfsMetaObject.Pointer, writeObjectRet.LfsContent)
if err != nil {
if _, errRemove := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); errRemove != nil {
return nil, fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, errRemove, err)
}
return nil, err
}
}
return err
return &lfsMetaObject.Pointer, nil
}
func checkIsLfsFileInGitAttributes(ctx context.Context, t *TemporaryUploadRepository, paths []string) (ret []bool, err error) {
@ -544,8 +582,8 @@ type writeRepoObjectRet struct {
LfsPointer lfs.Pointer
}
// writeRepoObjectForCreateOrUpdate hashes the git object for create or update operations
func writeRepoObjectForCreateOrUpdate(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile) (ret *writeRepoObjectRet, err error) {
// writeRepoObjectForModify hashes the git object for create or update operations
func writeRepoObjectForModify(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile) (ret *writeRepoObjectRet, err error) {
ret = &writeRepoObjectRet{}
treeObjectContentReader := file.ContentReader
if setting.LFS.StartServer {
@ -574,7 +612,7 @@ func writeRepoObjectForCreateOrUpdate(ctx context.Context, t *TemporaryUploadRep
return ret, nil
}
// writeRepoObjectForRename the same as writeRepoObjectForCreateOrUpdate buf for "rename"
// writeRepoObjectForRename the same as writeRepoObjectForModify buf for "rename"
func writeRepoObjectForRename(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile) (ret *writeRepoObjectRet, err error) {
lastCommitID, err := t.GetLastCommit(ctx)
if err != nil {

View File

@ -8,15 +8,11 @@ import (
"fmt"
"os"
"path"
"strings"
"sync"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/attribute"
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/log"
)
// UploadRepoFileOptions contains the uploaded repository file options
@ -32,23 +28,48 @@ type UploadRepoFileOptions struct {
Committer *IdentityOptions
}
type uploadInfo struct {
upload *repo_model.Upload
lfsMetaObject *git_model.LFSMetaObject
type lazyLocalFileReader struct {
*os.File
localFilename string
counter int
mu sync.Mutex
}
func cleanUpAfterFailure(ctx context.Context, infos *[]uploadInfo, t *TemporaryUploadRepository, original error) error {
for _, info := range *infos {
if info.lfsMetaObject == nil {
continue
}
if !info.lfsMetaObject.Existing {
if _, err := git_model.RemoveLFSMetaObjectByOid(ctx, t.repo.ID, info.lfsMetaObject.Oid); err != nil {
original = fmt.Errorf("%w, %v", original, err) // We wrap the original error - as this is the underlying error that required the fallback
var _ LazyReadSeeker = (*lazyLocalFileReader)(nil)
func (l *lazyLocalFileReader) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.counter > 0 {
l.counter--
if l.counter == 0 {
if err := l.File.Close(); err != nil {
return fmt.Errorf("close file %s: %w", l.localFilename, err)
}
l.File = nil
}
return nil
}
return original
return fmt.Errorf("file %s already closed", l.localFilename)
}
func (l *lazyLocalFileReader) OpenLazyReader() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.File != nil {
l.counter++
return nil
}
file, err := os.Open(l.localFilename)
if err != nil {
return err
}
l.File = file
l.counter = 1
return nil
}
// UploadRepoFiles uploads files to the given repository
@ -62,178 +83,29 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
return fmt.Errorf("GetUploadsByUUIDs [uuids: %v]: %w", opts.Files, err)
}
names := make([]string, len(uploads))
infos := make([]uploadInfo, len(uploads))
for i, upload := range uploads {
// Check file is not lfs locked, will return nil if lock setting not enabled
filepath := path.Join(opts.TreePath, upload.Name)
lfsLock, err := git_model.GetTreePathLock(ctx, repo.ID, filepath)
if err != nil {
return err
}
if lfsLock != nil && lfsLock.OwnerID != doer.ID {
u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID)
if err != nil {
return err
}
return git_model.ErrLFSFileLocked{RepoID: repo.ID, Path: filepath, UserName: u.Name}
}
names[i] = upload.Name
infos[i] = uploadInfo{upload: upload}
changeOpts := &ChangeRepoFilesOptions{
LastCommitID: opts.LastCommitID,
OldBranch: opts.OldBranch,
NewBranch: opts.NewBranch,
Message: opts.Message,
Signoff: opts.Signoff,
Author: opts.Author,
Committer: opts.Committer,
}
t, err := NewTemporaryUploadRepository(repo)
if err != nil {
return err
}
defer t.Close()
hasOldBranch := true
if err = t.Clone(ctx, opts.OldBranch, true); err != nil {
if !git.IsErrBranchNotExist(err) || !repo.IsEmpty {
return err
}
if err = t.Init(ctx, repo.ObjectFormatName); err != nil {
return err
}
hasOldBranch = false
opts.LastCommitID = ""
}
if hasOldBranch {
if err = t.SetDefaultIndex(ctx); err != nil {
return err
}
}
var attributesMap map[string]*attribute.Attributes
// when uploading to an empty repo, the old branch doesn't exist, but some "global gitattributes" or "info/attributes" may exist
if setting.LFS.StartServer {
attributesMap, err = attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
Attributes: []string{attribute.Filter},
Filenames: names,
for _, upload := range uploads {
changeOpts.Files = append(changeOpts.Files, &ChangeRepoFile{
Operation: "upload",
TreePath: path.Join(opts.TreePath, upload.Name),
ContentReader: &lazyLocalFileReader{localFilename: upload.LocalPath()},
})
if err != nil {
return err
}
}
// Copy uploaded files into repository.
// TODO: there is a small problem: when uploading LFS files with ".gitattributes", the "check-attr" runs before this loop,
// so LFS files are not able to be added as LFS objects. Ideally we need to do in 3 steps in the future:
// 1. Add ".gitattributes" to git index
// 2. Run "check-attr" (the previous attribute.CheckAttributes call)
// 3. Add files to git index (this loop)
// This problem is trivial so maybe no need to spend too much time on it at the moment.
for i := range infos {
if err := copyUploadedLFSFileIntoRepository(ctx, &infos[i], attributesMap, t, opts.TreePath); err != nil {
return err
}
}
// Now write the tree
treeHash, err := t.WriteTree(ctx)
_, err = ChangeRepoFiles(ctx, repo, doer, changeOpts)
if err != nil {
return err
}
// Now commit the tree
commitOpts := &CommitTreeUserOptions{
ParentCommitID: opts.LastCommitID,
TreeHash: treeHash,
CommitMessage: opts.Message,
SignOff: opts.Signoff,
DoerUser: doer,
AuthorIdentity: opts.Author,
CommitterIdentity: opts.Committer,
}
commitHash, err := t.CommitTree(ctx, commitOpts)
if err != nil {
return err
}
// Now deal with LFS objects
for i := range infos {
if infos[i].lfsMetaObject == nil {
continue
}
infos[i].lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, infos[i].lfsMetaObject.RepositoryID, infos[i].lfsMetaObject.Pointer)
if err != nil {
// OK Now we need to cleanup
return cleanUpAfterFailure(ctx, &infos, t, err)
}
// Don't move the files yet - we need to ensure that
// everything can be inserted first
}
// OK now we can insert the data into the store - there's no way to clean up the store
// once it's in there, it's in there.
contentStore := lfs.NewContentStore()
for _, info := range infos {
if err := uploadToLFSContentStore(info, contentStore); err != nil {
return cleanUpAfterFailure(ctx, &infos, t, err)
}
}
// Then push this tree to NewBranch
if err := t.Push(ctx, doer, commitHash, opts.NewBranch); err != nil {
return err
}
return repo_model.DeleteUploads(ctx, uploads...)
}
func copyUploadedLFSFileIntoRepository(ctx context.Context, info *uploadInfo, attributesMap map[string]*attribute.Attributes, t *TemporaryUploadRepository, treePath string) error {
file, err := os.Open(info.upload.LocalPath())
if err != nil {
return err
}
defer file.Close()
var objectHash string
if setting.LFS.StartServer && attributesMap[info.upload.Name] != nil && attributesMap[info.upload.Name].Get(attribute.Filter).ToString().Value() == "lfs" {
// Handle LFS
// FIXME: Inefficient! this should probably happen in models.Upload
pointer, err := lfs.GeneratePointer(file)
if err != nil {
return err
}
info.lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: t.repo.ID}
if objectHash, err = t.HashObjectAndWrite(ctx, strings.NewReader(pointer.StringContent())); err != nil {
return err
}
} else if objectHash, err = t.HashObjectAndWrite(ctx, file); err != nil {
return err
}
// Add the object to the index
return t.AddObjectToIndex(ctx, "100644", objectHash, path.Join(treePath, info.upload.Name))
}
func uploadToLFSContentStore(info uploadInfo, contentStore *lfs.ContentStore) error {
if info.lfsMetaObject == nil {
return nil
}
exist, err := contentStore.Exists(info.lfsMetaObject.Pointer)
if err != nil {
return err
}
if !exist {
file, err := os.Open(info.upload.LocalPath())
if err != nil {
return err
}
defer file.Close()
// FIXME: Put regenerates the hash and copies the file over.
// I guess this strictly ensures the soundness of the store but this is inefficient.
if err := contentStore.Put(info.lfsMetaObject.Pointer, file); err != nil {
// OK Now we need to cleanup
// Can't clean up the store, once uploaded there they're there.
return err
}
if err := repo_model.DeleteUploads(ctx, uploads...); err != nil {
log.Error("DeleteUploads: %v", err)
}
return nil
}

View File

@ -18,7 +18,7 @@ import (
)
// MergeUpstream merges the base repository's default branch into the fork repository's current branch.
func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, branch string) (mergeStyle string, err error) {
func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, branch string, ffOnly bool) (mergeStyle string, err error) {
if err = repo.MustNotBeArchived(); err != nil {
return "", err
}
@ -45,6 +45,11 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
return "", err
}
// If ff_only is requested and fast-forward failed, return error
if ffOnly {
return "", util.NewInvalidArgumentErrorf("fast-forward merge not possible: branch has diverged")
}
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
fakeIssue := &issue_model.Issue{

View File

@ -176,6 +176,12 @@ func (dc dingtalkConvertor) Status(p *api.CommitStatusPayload) (DingtalkPayload,
return createDingtalkPayload(text, text, "Status Changed", p.TargetURL), nil
}
func (dingtalkConvertor) WorkflowRun(p *api.WorkflowRunPayload) (DingtalkPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true)
return createDingtalkPayload(text, text, "Workflow Run", p.WorkflowRun.HTMLURL), nil
}
func (dingtalkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DingtalkPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true)

View File

@ -278,6 +278,12 @@ func (d discordConvertor) Status(p *api.CommitStatusPayload) (DiscordPayload, er
return d.createPayload(p.Sender, text, "", p.TargetURL, color), nil
}
func (d discordConvertor) WorkflowRun(p *api.WorkflowRunPayload) (DiscordPayload, error) {
text, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false)
return d.createPayload(p.Sender, text, "", p.WorkflowRun.HTMLURL, color), nil
}
func (d discordConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DiscordPayload, error) {
text, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false)

View File

@ -5,9 +5,13 @@ package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
webhook_model "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/git"
@ -16,10 +20,12 @@ import (
)
type (
// FeishuPayload represents
// FeishuPayload represents the payload for Feishu webhook
FeishuPayload struct {
MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media
Content struct {
Timestamp int64 `json:"timestamp,omitempty"` // Unix timestamp for signature verification
Sign string `json:"sign,omitempty"` // Signature for verification
MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media
Content struct {
Text string `json:"text"`
} `json:"content"`
}
@ -172,15 +178,41 @@ func (fc feishuConvertor) Status(p *api.CommitStatusPayload) (FeishuPayload, err
return newFeishuTextPayload(text), nil
}
func (feishuConvertor) WorkflowRun(p *api.WorkflowRunPayload) (FeishuPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true)
return newFeishuTextPayload(text), nil
}
func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true)
return newFeishuTextPayload(text), nil
}
// feishuGenSign generates a signature for Feishu webhook
// https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
func feishuGenSign(secret string, timestamp int64) string {
// key="{timestamp}\n{secret}", then hmac-sha256, then base64 encode
stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
h := hmac.New(sha256.New, []byte(stringToSign))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
var pc payloadConvertor[FeishuPayload] = feishuConvertor{}
return newJSONRequest(pc, w, t, true)
payload, err := newPayload(feishuConvertor{}, []byte(t.PayloadContent), t.EventType)
if err != nil {
return nil, nil, err
}
// Add timestamp and signature if secret is provided
if w.Secret != "" {
timestamp := time.Now().Unix()
payload.Timestamp = timestamp
payload.Sign = feishuGenSign(w.Secret, timestamp)
}
return prepareJSONRequest(payload, w, t, false /* no default headers */)
}
func init() {

View File

@ -168,6 +168,7 @@ func TestFeishuJSONPayload(t *testing.T) {
URL: "https://feishu.example.com/",
Meta: `{}`,
HTTPMethod: "POST",
Secret: "secret",
}
task := &webhook_model.HookTask{
HookID: hook.ID,
@ -183,10 +184,13 @@ func TestFeishuJSONPayload(t *testing.T) {
assert.Equal(t, "POST", req.Method)
assert.Equal(t, "https://feishu.example.com/", req.URL.String())
assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
var body FeishuPayload
err = json.NewDecoder(req.Body).Decode(&body)
assert.NoError(t, err)
assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Content.Text)
assert.Equal(t, feishuGenSign(hook.Secret, body.Timestamp), body.Sign)
// a separate sign test, the result is generated by official python code, so the algo must be correct
assert.Equal(t, "rWZ84lcag1x9aBFhn1gtV4ZN+4gme3pilfQNMk86vKg=", feishuGenSign("a", 1))
}

View File

@ -327,6 +327,37 @@ func getStatusPayloadInfo(p *api.CommitStatusPayload, linkFormatter linkFormatte
return text, color
}
func getWorkflowRunPayloadInfo(p *api.WorkflowRunPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
description := p.WorkflowRun.Conclusion
if description == "" {
description = p.WorkflowRun.Status
}
refLink := linkFormatter(p.WorkflowRun.HTMLURL, fmt.Sprintf("%s(#%d)", p.WorkflowRun.DisplayTitle, p.WorkflowRun.ID)+"["+base.ShortSha(p.WorkflowRun.HeadSha)+"]:"+description)
text = fmt.Sprintf("Workflow Run %s: %s", p.Action, refLink)
switch description {
case "waiting":
color = orangeColor
case "queued":
color = orangeColorLight
case "success":
color = greenColor
case "failure":
color = redColor
case "cancelled":
color = yellowColor
case "skipped":
color = purpleColor
default:
color = greyColor
}
if withSender {
text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)
}
return text, color
}
func getWorkflowJobPayloadInfo(p *api.WorkflowJobPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
description := p.WorkflowJob.Conclusion
if description == "" {

View File

@ -252,6 +252,12 @@ func (m matrixConvertor) Status(p *api.CommitStatusPayload) (MatrixPayload, erro
return m.newPayload(text)
}
func (m matrixConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MatrixPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, htmlLinkFormatter, true)
return m.newPayload(text)
}
func (m matrixConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MatrixPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true)

View File

@ -318,6 +318,20 @@ func (m msteamsConvertor) Status(p *api.CommitStatusPayload) (MSTeamsPayload, er
), nil
}
func (msteamsConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MSTeamsPayload, error) {
title, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.WorkflowRun.HTMLURL,
color,
&MSTeamsFact{"WorkflowRun:", p.WorkflowRun.DisplayTitle},
), nil
}
func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, error) {
title, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false)

View File

@ -5,10 +5,8 @@ package webhook
import (
"context"
"fmt"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
@ -18,6 +16,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
@ -956,72 +955,17 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
}
err := job.LoadAttributes(ctx)
status, _ := convert.ToActionsStatus(job.Status)
convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job)
if err != nil {
log.Error("Error loading job attributes: %v", err)
log.Error("ToActionWorkflowJob: %v", err)
return
}
jobIndex := 0
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
log.Error("Error loading getting run jobs: %v", err)
return
}
for i, j := range jobs {
if j.ID == job.ID {
jobIndex = i
break
}
}
status, conclusion := toActionStatus(job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
if task != nil {
runnerID = task.RunnerID
if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok {
runnerName = runner.Name
}
for i, step := range task.Steps {
stepStatus, stepConclusion := toActionStatus(job.Status)
steps = append(steps, &api.ActionWorkflowStep{
Name: step.Name,
Number: int64(i),
Status: stepStatus,
Conclusion: stepConclusion,
StartedAt: step.Started.AsTime().UTC(),
CompletedAt: step.Stopped.AsTime().UTC(),
})
}
}
if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowJobPayload{
Action: status,
WorkflowJob: &api.ActionWorkflowJob{
ID: job.ID,
// missing api endpoint for this location
URL: fmt.Sprintf("%s/actions/runs/%d/jobs/%d", repo.APIURL(), job.RunID, job.ID),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), jobIndex),
RunID: job.RunID,
// Missing api endpoint for this location, artifacts are available under a nested url
RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID),
Name: job.Name,
Labels: job.RunsOn,
RunAttempt: job.Attempt,
HeadSha: job.Run.CommitSHA,
HeadBranch: git.RefName(job.Run.Ref).BranchName(),
Status: status,
Conclusion: conclusion,
RunnerID: runnerID,
RunnerName: runnerName,
Steps: steps,
CreatedAt: job.Created.AsTime().UTC(),
StartedAt: job.Started.AsTime().UTC(),
CompletedAt: job.Stopped.AsTime().UTC(),
},
Action: status,
WorkflowJob: convertedJob,
Organization: org,
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
Sender: convert.ToUser(ctx, sender, nil),
@ -1030,28 +974,46 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
}
}
func toActionStatus(status actions_model.Status) (string, string) {
var action string
var conclusion string
switch status {
// This is a naming conflict of the webhook between Gitea and GitHub Actions
case actions_model.StatusWaiting:
action = "queued"
case actions_model.StatusBlocked:
action = "waiting"
case actions_model.StatusRunning:
action = "in_progress"
func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
source := EventSource{
Repository: repo,
Owner: repo.Owner,
}
if status.IsDone() {
action = "completed"
switch status {
case actions_model.StatusSuccess:
conclusion = "success"
case actions_model.StatusCancelled:
conclusion = "cancelled"
case actions_model.StatusFailure:
conclusion = "failure"
}
var org *api.Organization
if repo.Owner.IsOrganization() {
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
}
status := convert.ToWorkflowRunAction(run.Status)
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
if err != nil {
log.Error("GetActionWorkflow: %v", err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return
}
if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowRun, &api.WorkflowRunPayload{
Action: status,
Workflow: convertedWorkflow,
WorkflowRun: convertedRun,
Organization: org,
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
Sender: convert.ToUser(ctx, sender, nil),
}); err != nil {
log.Error("PrepareWebhooks: %v", err)
}
return action, conclusion
}

View File

@ -114,6 +114,10 @@ func (pc packagistConvertor) Status(_ *api.CommitStatusPayload) (PackagistPayloa
return PackagistPayload{}, nil
}
func (pc packagistConvertor) WorkflowRun(_ *api.WorkflowRunPayload) (PackagistPayload, error) {
return PackagistPayload{}, nil
}
func (pc packagistConvertor) WorkflowJob(_ *api.WorkflowJobPayload) (PackagistPayload, error) {
return PackagistPayload{}, nil
}

View File

@ -29,6 +29,7 @@ type payloadConvertor[T any] interface {
Wiki(*api.WikiPayload) (T, error)
Package(*api.PackagePayload) (T, error)
Status(*api.CommitStatusPayload) (T, error)
WorkflowRun(*api.WorkflowRunPayload) (T, error)
WorkflowJob(*api.WorkflowJobPayload) (T, error)
}
@ -81,6 +82,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module
return convertUnmarshalledJSON(rc.Package, data)
case webhook_module.HookEventStatus:
return convertUnmarshalledJSON(rc.Status, data)
case webhook_module.HookEventWorkflowRun:
return convertUnmarshalledJSON(rc.WorkflowRun, data)
case webhook_module.HookEventWorkflowJob:
return convertUnmarshalledJSON(rc.WorkflowJob, data)
}
@ -92,7 +95,10 @@ func newJSONRequest[T any](pc payloadConvertor[T], w *webhook_model.Webhook, t *
if err != nil {
return nil, nil, err
}
return prepareJSONRequest(payload, w, t, withDefaultHeaders)
}
func prepareJSONRequest[T any](payload T, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) {
body, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return nil, nil, err

View File

@ -173,6 +173,12 @@ func (s slackConvertor) Status(p *api.CommitStatusPayload) (SlackPayload, error)
return s.createPayload(text, nil), nil
}
func (s slackConvertor) WorkflowRun(p *api.WorkflowRunPayload) (SlackPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, SlackLinkFormatter, true)
return s.createPayload(text, nil), nil
}
func (s slackConvertor) WorkflowJob(p *api.WorkflowJobPayload) (SlackPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, SlackLinkFormatter, true)

View File

@ -180,6 +180,12 @@ func (t telegramConvertor) Status(p *api.CommitStatusPayload) (TelegramPayload,
return createTelegramPayloadHTML(text), nil
}
func (telegramConvertor) WorkflowRun(p *api.WorkflowRunPayload) (TelegramPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, htmlLinkFormatter, true)
return createTelegramPayloadHTML(text), nil
}
func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true)

View File

@ -181,6 +181,12 @@ func (wc wechatworkConvertor) Status(p *api.CommitStatusPayload) (WechatworkPayl
return newWechatworkMarkdownPayload(text), nil
}
func (wc wechatworkConvertor) WorkflowRun(p *api.WorkflowRunPayload) (WechatworkPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true)
return newWechatworkMarkdownPayload(text), nil
}
func (wc wechatworkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (WechatworkPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true)

View File

@ -3,15 +3,13 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<form class="ui edit form" method="post" action="{{.RepoLink}}/_cherrypick/{{.SHA}}/{{.BranchName | PathEscapeSegments}}">
<form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_cherrypick/{{.FromCommitID}}/{{.BranchName | PathEscapeSegments}}">
{{.CsrfTokenHtml}}
<input type="hidden" name="last_commit" value="{{.last_commit}}">
<input type="hidden" name="page_has_posted" value="true">
<input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}">
<div class="repo-editor-header">
<div class="ui breadcrumb field {{if .Err_TreePath}}error{{end}}">
{{$shaurl := printf "%s/commit/%s" $.RepoLink (PathEscape .SHA)}}
{{$shalink := HTMLFormat `<a class="ui primary sha label" href="%s">%s</a>` $shaurl (ShortSha .SHA)}}
<div class="breadcrumb">
{{$shaurl := printf "%s/commit/%s" $.RepoLink (PathEscape .FromCommitID)}}
{{$shalink := HTMLFormat `<a class="ui primary sha label" href="%s">%s</a>` $shaurl (ShortSha .FromCommitID)}}
{{if eq .CherryPickType "revert"}}
{{ctx.Locale.Tr "repo.editor.revert" $shalink}}
{{else}}

View File

@ -1,11 +1,11 @@
<div class="commit-form-wrapper">
{{ctx.AvatarUtils.Avatar .SignedUser 40 "commit-avatar"}}
<div class="commit-form">
<h3>{{- if .CanCommitToBranch.WillSign}}
<span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CanCommitToBranch.SigningKey}}">{{svg "octicon-lock" 24}}</span>
<h3>{{- if .CommitFormBehaviors.WillSign}}
<span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormBehaviors.SigningKey}}">{{svg "octicon-lock" 24}}</span>
{{ctx.Locale.Tr "repo.editor.commit_signed_changes"}}
{{- else}}
<span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CanCommitToBranch.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span>
<span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CommitFormBehaviors.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span>
{{ctx.Locale.Tr "repo.editor.commit_changes"}}
{{- end}}</h3>
<div class="field">
@ -22,17 +22,17 @@
</div>
<div class="quick-pull-choice js-quick-pull-choice">
<div class="field">
<div class="ui radio checkbox {{if not .CanCommitToBranch.CanCommitToBranch}}disabled{{end}}">
<div class="ui radio checkbox {{if not .CommitFormBehaviors.CanCommitToBranch}}disabled{{end}}">
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="direct" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "direct"}}checked{{end}}>
<label>
{{svg "octicon-git-commit"}}
{{ctx.Locale.Tr "repo.editor.commit_directly_to_this_branch" .BranchName}}
{{if not .CanCommitToBranch.CanCommitToBranch}}
{{if not .CommitFormBehaviors.CanCommitToBranch}}
<div class="ui visible small warning message">
{{ctx.Locale.Tr "repo.editor.no_commit_to_branch"}}
<ul>
{{if not .CanCommitToBranch.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}}
{{if and .CanCommitToBranch.RequireSigned (not .CanCommitToBranch.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}}
{{if not .CommitFormBehaviors.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}}
{{if and .CommitFormBehaviors.RequireSigned (not .CommitFormBehaviors.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}}
</ul>
</div>
{{end}}
@ -42,14 +42,14 @@
{{if and (not .Repository.IsEmpty) (not .IsEditingFileOnly)}}
<div class="field">
<div class="ui radio checkbox">
{{if .CanCreatePullRequest}}
{{if .CommitFormBehaviors.CanCreatePullRequest}}
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.propose_file_change"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}>
{{else}}
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}>
{{end}}
<label>
{{svg "octicon-git-pull-request"}}
{{if .CanCreatePullRequest}}
{{if .CommitFormBehaviors.CanCreatePullRequest}}
{{ctx.Locale.Tr "repo.editor.create_new_branch"}}
{{else}}
{{ctx.Locale.Tr "repo.editor.create_new_branch_np"}}
@ -58,7 +58,7 @@
</div>
</div>
<div class="quick-pull-branch-name {{if not (eq .commit_choice "commit-to-new-branch")}}tw-hidden{{end}}">
<div class="new-branch-name-input field {{if .Err_NewBranchName}}error{{end}}">
<div class="new-branch-name-input field">
{{svg "octicon-git-branch"}}
<input type="text" name="new_branch_name" maxlength="100" value="{{.new_branch_name}}" class="input-contrast tw-mr-1 js-quick-pull-new-branch-name" placeholder="{{ctx.Locale.Tr "repo.editor.new_branch_name_desc"}}" {{if eq .commit_choice "commit-to-new-branch"}}required{{end}} title="{{ctx.Locale.Tr "repo.editor.new_branch_name"}}">
<span class="text-muted js-quick-pull-normalization-info"></span>
@ -67,7 +67,7 @@
{{end}}
</div>
{{if and .CommitCandidateEmails (gt (len .CommitCandidateEmails) 1)}}
<div class="field {{if .Err_CommitEmail}}error{{end}}">
<div class="field">
<label>{{ctx.Locale.Tr "repo.editor.commit_email"}}</label>
<select class="ui selection dropdown" name="commit_email">
{{- range $email := .CommitCandidateEmails -}}
@ -77,7 +77,8 @@
</div>
{{end}}
</div>
<button id="commit-button" type="submit" class="ui primary button" {{if .PageIsEdit}}disabled{{end}}>
<input type="hidden" name="last_commit" value="{{.last_commit}}">
<button id="commit-button" type="submit" class="ui primary button">
{{if eq .commit_choice "commit-to-new-branch"}}{{ctx.Locale.Tr "repo.editor.propose_file_change"}}{{else}}{{ctx.Locale.Tr "repo.editor.commit_changes"}}{{end}}
</button>
<a class="ui button red" href="{{if .ReturnURI}}{{.ReturnURI}}{{else}}{{$.BranchLink}}/{{PathEscapeSegments .TreePath}}{{end}}">{{ctx.Locale.Tr "repo.editor.cancel"}}</a>

View File

@ -0,0 +1,16 @@
<div class="breadcrumb">
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
{{$n := len .TreeNames}}
{{$l := Eval $n "-" 1}}
{{range $i, $v := .TreeNames}}
<div class="breadcrumb-divider">/</div>
{{if eq $i $l}}
<input id="file-name" maxlength="255" value="{{$v}}" placeholder="{{ctx.Locale.Tr (Iif $.PageIsUpload "repo.editor.add_subdir" "repo.editor.name_your_file")}}" data-editorconfig="{{$.EditorconfigJson}}" required autofocus>
<span data-tooltip-content="{{ctx.Locale.Tr "repo.editor.filename_help"}}">{{svg "octicon-info"}}</span>
{{else}}
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
{{end}}
{{end}}
<span>{{ctx.Locale.Tr "repo.editor.or"}} <a href="{{or .ReturnURI (print $.BranchLink "/" (PathEscapeSegments .TreePath))}}">{{ctx.Locale.Tr "repo.editor.cancel_lower"}}</a></span>
<input type="hidden" id="tree_path" name="tree_path" value="{{.TreePath}}">
</div>

View File

@ -3,9 +3,8 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<form class="ui form" method="post">
<form class="ui form form-fetch-action" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="last_commit" value="{{.last_commit}}">
{{template "repo/editor/commit_form" .}}
</form>
</div>

View File

@ -3,30 +3,13 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<form class="ui edit form" method="post"
<form class="ui edit form form-fetch-action" method="post"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
>
{{.CsrfTokenHtml}}
<input type="hidden" name="last_commit" value="{{.last_commit}}">
<input type="hidden" name="page_has_posted" value="{{.PageHasPosted}}">
<div class="repo-editor-header">
<div class="ui breadcrumb field{{if .Err_TreePath}} error{{end}}">
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
{{$n := len .TreeNames}}
{{$l := Eval $n "-" 1}}
{{range $i, $v := .TreeNames}}
<div class="breadcrumb-divider">/</div>
{{if eq $i $l}}
<input id="file-name" maxlength="255" value="{{$v}}" placeholder="{{ctx.Locale.Tr "repo.editor.name_your_file"}}" data-editorconfig="{{$.EditorconfigJson}}" required autofocus>
<span data-tooltip-content="{{ctx.Locale.Tr "repo.editor.filename_help"}}">{{svg "octicon-info"}}</span>
{{else}}
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
{{end}}
{{end}}
<span>{{ctx.Locale.Tr "repo.editor.or"}} <a href="{{if .ReturnURI}}{{.ReturnURI}}{{else}}{{$.BranchLink}}{{if not .IsNewFile}}/{{PathEscapeSegments .TreePath}}{{end}}{{end}}">{{ctx.Locale.Tr "repo.editor.cancel_lower"}}</a></span>
<input type="hidden" id="tree_path" name="tree_path" value="{{.TreePath}}" required>
</div>
{{template "repo/editor/common_breadcrumb" .}}
</div>
{{if not .NotEditableReason}}
<div class="field">

View File

@ -3,15 +3,13 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<form class="ui edit form" method="post" action="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}"
<form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
>
{{.CsrfTokenHtml}}
<input type="hidden" name="last_commit" value="{{.last_commit}}">
<input type="hidden" name="page_has_posted" value="{{.PageHasPosted}}">
<div class="repo-editor-header">
<div class="ui breadcrumb field {{if .Err_TreePath}}error{{end}}">
<div class="breadcrumb">
{{ctx.Locale.Tr "repo.editor.patching"}}
<a class="section" href="{{$.RepoLink}}">{{.Repository.FullName}}</a>
<div class="breadcrumb-divider">:</div>

View File

@ -3,25 +3,10 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<form class="ui comment form" method="post">
<form class="ui comment form form-fetch-action" method="post">
{{.CsrfTokenHtml}}
<div class="repo-editor-header">
<div class="ui breadcrumb field {{if .Err_TreePath}}error{{end}}">
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
{{$n := len .TreeNames}}
{{$l := Eval $n "-" 1}}
{{range $i, $v := .TreeNames}}
<div class="breadcrumb-divider">/</div>
{{if eq $i $l}}
<input type="text" id="file-name" maxlength="255" value="{{$v}}" placeholder="{{ctx.Locale.Tr "repo.editor.add_subdir"}}" autofocus>
<span data-tooltip-content="{{ctx.Locale.Tr "repo.editor.filename_help"}}">{{svg "octicon-info"}}</span>
{{else}}
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
{{end}}
{{end}}
<span>{{ctx.Locale.Tr "repo.editor.or"}} <a href="{{$.BranchLink}}{{if not .IsNewFile}}/{{.TreePath | PathEscapeSegments}}{{end}}">{{ctx.Locale.Tr "repo.editor.cancel_lower"}}</a></span>
<input type="hidden" id="tree_path" name="tree_path" value="{{.TreePath}}" required>
</div>
{{template "repo/editor/common_breadcrumb" .}}
</div>
<div class="field">
{{template "repo/upload" .}}

View File

@ -4,7 +4,7 @@
{{if $attachments}}
<div class="card-attachment-images">
{{range $attachments}}
<img src="{{.DownloadURL}}" alt="{{.Name}}" />
<img loading="lazy" src="{{.DownloadURL}}" alt="{{.Name}}" />
{{end}}
</div>
{{end}}

View File

@ -31,7 +31,7 @@
{{if FilenameIsImage .Name}}
{{if not (StringUtils.Contains (StringUtils.ToString $.RenderedContent) .UUID)}}
<a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}">
<img alt="{{.Name}}" src="{{.DownloadURL}}" title="{{ctx.Locale.Tr "repo.issues.attachment.open_tab" .Name}}">
<img loading="lazy" alt="{{.Name}}" src="{{.DownloadURL}}" title="{{ctx.Locale.Tr "repo.issues.attachment.open_tab" .Name}}">
</a>
{{end}}
{{end}}

View File

@ -615,7 +615,7 @@
<div class="timeline-item-group">
<div class="timeline-item event" id="{{.HashTag}}">
<a class="timeline-avatar"{{if gt .Poster.ID 0}} href="{{.Poster.HomeLink}}"{{end}}>
<img alt src="{{.Poster.AvatarLink ctx}}" width="40" height="40">
<img loading="lazy" alt src="{{.Poster.AvatarLink ctx}}" width="40" height="40">
</a>
<span class="badge grey">{{svg "octicon-x" 16}}</span>
<span class="text grey muted-links">

View File

@ -21,7 +21,7 @@
{{else if not .IsTextFile}}
<div class="view-raw">
{{if .IsImageFile}}
<img alt="{{$.RawFileLink}}" src="{{$.RawFileLink}}">
<img loading="lazy" alt="{{$.RawFileLink}}" src="{{$.RawFileLink}}">
{{else if .IsVideoFile}}
<video controls src="{{$.RawFileLink}}">
<strong>{{ctx.Locale.Tr "repo.video_not_supported_in_browser"}}</strong>

Some files were not shown because too many files have changed in this diff Show More