in base repo is consistent with the head commit of head branch in the head repo
+ // get head commit of PR
+ if branchOtherUnmergedPR != nil && pull.Flow == issues_model.PullRequestFlowGithub {
+ prHeadRef := pull.GetGitHeadRefName()
+ if err := pull.LoadBaseRepo(ctx); err != nil {
+ ctx.ServerError("Unable to load base repo", err)
+ return
+ }
+ prHeadCommitID, err := gitrepo.GetFullCommitID(ctx, pull.BaseRepo, prHeadRef)
+ if err != nil {
+ ctx.ServerError("Get head commit Id of pr fail", err)
+ return
+ }
+
+ // get head commit of branch in the head repo
+ if err := pull.LoadHeadRepo(ctx); err != nil {
+ ctx.ServerError("Unable to load head repo", err)
+ return
+ }
+ if exist, _ := git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.BaseBranch); !exist {
+ ctx.Flash.Error("The origin branch is delete, cannot reopen.")
+ return
+ }
+ headBranchRef := git.RefNameFromBranch(pull.HeadBranch)
+ headBranchCommitID, err := gitrepo.GetFullCommitID(ctx, pull.HeadRepo, headBranchRef.String())
+ if err != nil {
+ ctx.ServerError("Get head commit Id of head branch fail", err)
+ return
+ }
+
+ err = pull.LoadIssue(ctx)
+ if err != nil {
+ ctx.ServerError("load the issue of pull request error", err)
+ return
+ }
+
+ if prHeadCommitID != headBranchCommitID {
+ // force push to base repo
+ err := gitrepo.Push(ctx, pull.HeadRepo, pull.BaseRepo, git.PushOptions{
+ Branch: pull.HeadBranch + ":" + prHeadRef,
+ Force: true,
+ Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo),
+ })
+ if err != nil {
+ ctx.ServerError("force push error", err)
+ return
+ }
+ }
+ }
+ }
+
+ if form.Status == "close" && !issue.IsClosed {
+ if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
+ log.Error("CloseIssue: %v", err)
+ if issues_model.IsErrDependenciesLeft(err) {
+ if issue.IsPull {
+ ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked"))
+ }
+ }
+ } else {
+ if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil {
+ ctx.ServerError("stopTimerIfAvailable", err)
+ return
+ }
+ log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed)
+ }
+ } else if form.Status == "reopen" && issue.IsClosed && branchOtherUnmergedPR == nil {
+ if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
+ log.Error("ReopenIssue: %v", err)
+ ctx.Flash.Error("Unable to reopen.")
+ }
+ }
+ } // end if: handle close or reopen
+
+ // Redirect to the comment, add hashtag if it exists
+ redirect := fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, issueType, issue.Index)
+ if comment != nil {
+ redirect += "#" + comment.HashTag()
+ }
+ ctx.JSONRedirect(redirect)
}
// UpdateCommentContent change comment of issue's content
diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go
index 44bc8543b0..3ae0dab25b 100644
--- a/routers/web/repo/view_file.go
+++ b/routers/web/repo/view_file.go
@@ -119,12 +119,8 @@ func handleFileViewRenderSource(ctx *context.Context, attrs *attribute.Attribute
}
language := attrs.GetLanguage().Value()
- fileContent, lexerName, err := highlight.RenderFullFile(filename, language, buf)
+ fileContent, lexerName := highlight.RenderFullFile(filename, language, buf)
ctx.Data["LexerName"] = lexerName
- if err != nil {
- log.Error("highlight.RenderFullFile failed, fallback to plain text: %v", err)
- fileContent = highlight.RenderPlainText(buf)
- }
status := &charset.EscapeStatus{}
statuses := make([]*charset.EscapeStatus, len(fileContent))
for i, line := range fileContent {
diff --git a/services/context/org.go b/services/context/org.go
index 4c64ff72a9..bd20d807ef 100644
--- a/services/context/org.go
+++ b/services/context/org.go
@@ -132,10 +132,12 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
ctx.ServerError("IsOrgMember", err)
return
}
- ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
- if err != nil {
- ctx.ServerError("CanCreateOrgRepo", err)
- return
+ if ctx.Org.IsMember {
+ ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("CanCreateOrgRepo", err)
+ return
+ }
}
}
} else {
diff --git a/services/gitdiff/gitdiff_test.go b/services/gitdiff/gitdiff_test.go
index cfd99544cc..e1b358215f 100644
--- a/services/gitdiff/gitdiff_test.go
+++ b/services/gitdiff/gitdiff_test.go
@@ -1140,7 +1140,7 @@ func TestHighlightCodeLines(t *testing.T) {
ret := highlightCodeLinesForDiffFile(diffFile, true, []byte("a\nb\n"))
assert.Equal(t, map[int]template.HTML{
0: `a` + nl,
- 1: `b`,
+ 1: `b` + nl,
}, ret)
})
}
diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl
index 670c7f18ef..02a8db9157 100644
--- a/templates/repo/issue/view_content/pull_merge_box.tmpl
+++ b/templates/repo/issue/view_content/pull_merge_box.tmpl
@@ -221,6 +221,7 @@
{{end}}
{{$showGeneralMergeForm = true}}
diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl
index c17fa2aa0d..de93e6a6f0 100644
--- a/templates/user/dashboard/feeds.tmpl
+++ b/templates/user/dashboard/feeds.tmpl
@@ -110,7 +110,7 @@
{{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}}
{{$comment := index .GetIssueInfos 1}}
{{if $comment}}
- {{ctx.RenderUtils.MarkdownToHtml $comment}}
+ {{ctx.RenderUtils.MarkdownToHtml $comment}}
{{end}}
{{else if .GetOpType.InActions "merge_pull_request"}}
{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}
diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go
index 91d56507ed..634e06cf52 100644
--- a/tests/integration/actions_route_test.go
+++ b/tests/integration/actions_route_test.go
@@ -9,30 +9,40 @@ import (
"net/url"
"testing"
+ actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestActionsRoute(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
- user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
- user2Session := loginUser(t, user2.Name)
- user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("testActionsRouteForIDBasedURL", testActionsRouteForIDBasedURL)
+ t.Run("testActionsRouteForLegacyIndexBasedURL", testActionsRouteForLegacyIndexBasedURL)
+ })
+}
- repo1 := createActionsTestRepo(t, user2Token, "actions-route-test-1", false)
- runner1 := newMockRunner()
- runner1.registerAsRepoRunner(t, user2.Name, repo1.Name, "mock-runner", []string{"ubuntu-latest"}, false)
- repo2 := createActionsTestRepo(t, user2Token, "actions-route-test-2", false)
- runner2 := newMockRunner()
- runner2.registerAsRepoRunner(t, user2.Name, repo2.Name, "mock-runner", []string{"ubuntu-latest"}, false)
+func testActionsRouteForIDBasedURL(t *testing.T) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ user2Session := loginUser(t, user2.Name)
+ user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
- workflowTreePath := ".gitea/workflows/test.yml"
- workflowContent := `name: test
+ repo1 := createActionsTestRepo(t, user2Token, "actions-route-id-url-1", false)
+ runner1 := newMockRunner()
+ runner1.registerAsRepoRunner(t, user2.Name, repo1.Name, "mock-runner", []string{"ubuntu-latest"}, false)
+ repo2 := createActionsTestRepo(t, user2Token, "actions-route-id-url-2", false)
+ runner2 := newMockRunner()
+ runner2.registerAsRepoRunner(t, user2.Name, repo2.Name, "mock-runner", []string{"ubuntu-latest"}, false)
+
+ workflowTreePath := ".gitea/workflows/test.yml"
+ workflowContent := `name: test
on:
push:
paths:
@@ -44,57 +54,239 @@ jobs:
- run: echo job1
`
- opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+workflowTreePath, workflowContent)
- createWorkflowFile(t, user2Token, user2.Name, repo1.Name, workflowTreePath, opts)
- createWorkflowFile(t, user2Token, user2.Name, repo2.Name, workflowTreePath, opts)
+ opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+workflowTreePath, workflowContent)
+ createWorkflowFile(t, user2Token, user2.Name, repo1.Name, workflowTreePath, opts)
+ createWorkflowFile(t, user2Token, user2.Name, repo2.Name, workflowTreePath, opts)
- task1 := runner1.fetchTask(t)
- _, job1, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
- task2 := runner2.fetchTask(t)
- _, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
+ task1 := runner1.fetchTask(t)
+ _, job1, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
+ task2 := runner2.fetchTask(t)
+ _, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
- // run1 and job1 belong to repo1, success
- req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
- resp := user2Session.MakeRequest(t, req, http.StatusOK)
- var viewResp actions_web.ViewResponse
- DecodeJSON(t, resp, &viewResp)
- assert.Len(t, viewResp.State.Run.Jobs, 1)
- assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID)
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
+ user2Session.MakeRequest(t, req, http.StatusOK)
- // run2 and job2 do not belong to repo1, failure
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job2.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job1.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/workflow", user2.Name, repo1.Name, run2.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", user2.Name, repo1.Name, run2.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user2.Name, repo1.Name, run2.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/delete", user2.Name, repo1.Name, run2.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/test.txt", user2.Name, repo1.Name, run2.ID))
- user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "DELETE", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/test.txt", user2.Name, repo1.Name, run2.ID))
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+
+ // run1 and job1 belong to repo1, success
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
+ resp := user2Session.MakeRequest(t, req, http.StatusOK)
+ var viewResp actions_web.ViewResponse
+ DecodeJSON(t, resp, &viewResp)
+ assert.Len(t, viewResp.State.Run.Jobs, 1)
+ assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID)
+
+ // run2 and job2 do not belong to repo1, failure
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job1.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/workflow", user2.Name, repo1.Name, run2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", user2.Name, repo1.Name, run2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user2.Name, repo1.Name, run2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/delete", user2.Name, repo1.Name, run2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/test.txt", user2.Name, repo1.Name, run2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/test.txt", user2.Name, repo1.Name, run2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+
+ // make the tasks complete, then test rerun
+ runner1.execTask(t, task1, &mockTaskOutcome{
+ result: runnerv1.Result_RESULT_SUCCESS,
+ })
+ runner2.execTask(t, task2, &mockTaskOutcome{
+ result: runnerv1.Result_RESULT_SUCCESS,
+ })
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo1.Name, run2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo1.Name, run2.ID, job2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo1.Name, run1.ID, job2.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo1.Name, run2.ID, job1.ID))
+ user2Session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+func testActionsRouteForLegacyIndexBasedURL(t *testing.T) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ user2Session := loginUser(t, user2.Name)
+ user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ repo := createActionsTestRepo(t, user2Token, "actions-route-legacy-url", false)
+
+ mkRun := func(id, index int64, title, sha string) *actions_model.ActionRun {
+ return &actions_model.ActionRun{
+ ID: id,
+ Index: index,
+ RepoID: repo.ID,
+ OwnerID: user2.ID,
+ Title: title,
+ WorkflowID: "legacy-route.yml",
+ TriggerUserID: user2.ID,
+ Ref: "refs/heads/master",
+ CommitSHA: sha,
+ Status: actions_model.StatusWaiting,
+ }
+ }
+ mkJob := func(id, runID int64, name, sha string) *actions_model.ActionRunJob {
+ return &actions_model.ActionRunJob{
+ ID: id,
+ RunID: runID,
+ RepoID: repo.ID,
+ OwnerID: user2.ID,
+ CommitSHA: sha,
+ Name: name,
+ Status: actions_model.StatusWaiting,
+ }
+ }
+
+ // A small ID-based run/job pair that should always resolve directly.
+ smallIDRun := mkRun(80, 20, "legacy route small id", "aaa001")
+ smallIDJob := mkJob(170, smallIDRun.ID, "legacy-small-job", smallIDRun.CommitSHA)
+ // Another small run used to provide a job ID that belongs to a different run.
+ otherSmallRun := mkRun(90, 30, "legacy route other small", "aaa002")
+ otherSmallJob := mkJob(180, otherSmallRun.ID, "legacy-other-small-job", otherSmallRun.CommitSHA)
+
+ // A large-ID run whose legacy run index should redirect to its ID-based URL.
+ normalRun := mkRun(1500, 900, "legacy route normal", "aaa003")
+ normalRunJob := mkJob(1600, normalRun.ID, "legacy-normal-job", normalRun.CommitSHA)
+ // A run whose index collides with normalRun.ID to exercise summary-page ID-first behavior.
+ collisionRun := mkRun(2400, 1500, "legacy route collision", "aaa004")
+ collisionJobIdx0 := mkJob(2600, collisionRun.ID, "legacy-collision-job-1", collisionRun.CommitSHA)
+ collisionJobIdx1 := mkJob(2601, collisionRun.ID, "legacy-collision-job-2", collisionRun.CommitSHA)
+
+ // A small ID-based run/job pair that collides with a different legacy run/job index pair.
+ ambiguousIDRun := mkRun(3, 1, "legacy route ambiguous id", "aaa005")
+ ambiguousIDJob := mkJob(4, ambiguousIDRun.ID, "legacy-ambiguous-id-job", ambiguousIDRun.CommitSHA)
+ // The legacy run/job target for the ambiguous /runs/3/jobs/4 URL.
+ ambiguousLegacyRun := mkRun(1501, ambiguousIDRun.ID, "legacy route ambiguous legacy", "aaa006")
+ ambiguousLegacyJobIdx0 := mkJob(1601, ambiguousLegacyRun.ID, "legacy-ambiguous-legacy-job-0", ambiguousLegacyRun.CommitSHA)
+ ambiguousLegacyJobIdx1 := mkJob(1602, ambiguousLegacyRun.ID, "legacy-ambiguous-legacy-job-1", ambiguousLegacyRun.CommitSHA)
+ ambiguousLegacyJobIdx2 := mkJob(1603, ambiguousLegacyRun.ID, "legacy-ambiguous-legacy-job-2", ambiguousLegacyRun.CommitSHA)
+ ambiguousLegacyJobIdx3 := mkJob(1604, ambiguousLegacyRun.ID, "legacy-ambiguous-legacy-job-3", ambiguousLegacyRun.CommitSHA)
+ ambiguousLegacyJobIdx4 := mkJob(1605, ambiguousLegacyRun.ID, "legacy-ambiguous-legacy-job-4", ambiguousLegacyRun.CommitSHA) // job_index=4
+ ambiguousLegacyJobIdx5 := mkJob(1606, ambiguousLegacyRun.ID, "legacy-ambiguous-legacy-job-5", ambiguousLegacyRun.CommitSHA)
+ ambiguousLegacyJobs := []*actions_model.ActionRunJob{
+ ambiguousLegacyJobIdx0,
+ ambiguousLegacyJobIdx1,
+ ambiguousLegacyJobIdx2,
+ ambiguousLegacyJobIdx3,
+ ambiguousLegacyJobIdx4,
+ ambiguousLegacyJobIdx5,
+ }
+ targetAmbiguousLegacyJob := ambiguousLegacyJobs[int(ambiguousIDJob.ID)]
+
+ insertBeansWithExplicitIDs(t, "action_run",
+ smallIDRun, otherSmallRun, normalRun, ambiguousIDRun, ambiguousLegacyRun, collisionRun,
+ )
+ insertBeansWithExplicitIDs(t, "action_run_job",
+ smallIDJob, otherSmallJob, normalRunJob, ambiguousIDJob, collisionJobIdx0, collisionJobIdx1,
+ ambiguousLegacyJobIdx0, ambiguousLegacyJobIdx1, ambiguousLegacyJobIdx2, ambiguousLegacyJobIdx3, ambiguousLegacyJobIdx4, ambiguousLegacyJobIdx5,
+ )
+
+ t.Run("OnlyRunID", func(t *testing.T) {
+ // ID-based URLs must be valid
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, smallIDRun.ID))
+ user2Session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, normalRun.ID))
+ user2Session.MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("OnlyRunIndex", func(t *testing.T) {
+ // legacy run index should redirect to the ID-based URL
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, normalRun.Index))
+ resp := user2Session.MakeRequest(t, req, http.StatusFound)
+ assert.Equal(t, fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, normalRun.ID), resp.Header().Get("Location"))
+
+ // Best-effort compatibility prefers the run ID when the same number also exists as a legacy run index.
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.Index))
+ resp = user2Session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-run-id="%d"`, normalRun.ID)) // because collisionRun.Index == normalRun.ID
+
+ // by_index=1 should force the summary page to use the legacy run index interpretation.
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d?by_index=1", user2.Name, repo.Name, collisionRun.Index))
+ resp = user2Session.MakeRequest(t, req, http.StatusFound)
+ assert.Equal(t, fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.ID), resp.Header().Get("Location"))
+ })
+
+ t.Run("RunIDAndJobID", func(t *testing.T) {
+ // ID-based URLs must be valid
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, smallIDRun.ID, smallIDJob.ID))
+ user2Session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, normalRun.ID, normalRunJob.ID))
+ user2Session.MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("RunIndexAndJobIndex", func(t *testing.T) {
+ // /user2/repo2/actions/runs/3/jobs/4 is ambiguous:
+ // - it may resolve as the ID-based URL for run_id=3/job_id=4,
+ // - or as the legacy index-based URL for run_index=3/job_index=4 which should redirect to run_id=1501/job_id=1605.
+ idBasedURL := fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, ambiguousIDRun.ID, ambiguousIDJob.ID)
+ indexBasedURL := fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, ambiguousLegacyRun.Index, 4) // for ambiguousLegacyJobIdx4
+ assert.Equal(t, idBasedURL, indexBasedURL)
+ // When both interpretations are valid, prefer the ID-based target by default.
+ req := NewRequest(t, "GET", indexBasedURL)
+ user2Session.MakeRequest(t, req, http.StatusOK)
+ redirectURL := fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, ambiguousLegacyRun.ID, targetAmbiguousLegacyJob.ID)
+ // by_index=1 should explicitly force the legacy run/job index interpretation.
+ req = NewRequest(t, "GET", indexBasedURL+"?by_index=1")
+ resp := user2Session.MakeRequest(t, req, http.StatusFound)
+ assert.Equal(t, redirectURL, resp.Header().Get("Location"))
+
+ // legacy job index 0 should redirect to the first job's ID
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/0", user2.Name, repo.Name, collisionRun.Index))
+ resp = user2Session.MakeRequest(t, req, http.StatusFound)
+ assert.Equal(t, fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, collisionRun.ID, collisionJobIdx0.ID), resp.Header().Get("Location"))
+
+ // legacy job index 1 should redirect to the second job's ID
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/1", user2.Name, repo.Name, collisionRun.Index))
+ resp = user2Session.MakeRequest(t, req, http.StatusFound)
+ assert.Equal(t, fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, collisionRun.ID, collisionJobIdx1.ID), resp.Header().Get("Location"))
+ })
+
+ t.Run("InvalidURLs", func(t *testing.T) {
+ // the job ID from a different run should not match
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, smallIDRun.ID, otherSmallJob.ID))
user2Session.MakeRequest(t, req, http.StatusNotFound)
- // make the tasks complete, then test rerun
- runner1.execTask(t, task1, &mockTaskOutcome{
- result: runnerv1.Result_RESULT_SUCCESS,
- })
- runner2.execTask(t, task2, &mockTaskOutcome{
- result: runnerv1.Result_RESULT_SUCCESS,
- })
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo1.Name, run2.ID))
+ // resolve the run by index first and then return not found because the job index is out-of-range
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/2", user2.Name, repo.Name, normalRun.ID))
user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo1.Name, run2.ID, job2.ID))
+
+ // an out-of-range job index should return not found
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/2", user2.Name, repo.Name, collisionRun.Index))
user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo1.Name, run1.ID, job2.ID))
+
+ // a missing run number should return not found
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, 999999))
user2Session.MakeRequest(t, req, http.StatusNotFound)
- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo1.Name, run2.ID, job1.ID))
+
+ // a missing legacy run index should return not found
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/0", user2.Name, repo.Name, 999999))
user2Session.MakeRequest(t, req, http.StatusNotFound)
})
}
+
+func insertBeansWithExplicitIDs(t *testing.T, table string, beans ...any) {
+ t.Helper()
+ ctx, committer, err := db.TxContext(t.Context())
+ require.NoError(t, err)
+ defer committer.Close()
+
+ if setting.Database.Type.IsMSSQL() {
+ _, err = db.Exec(ctx, fmt.Sprintf("SET IDENTITY_INSERT [%s] ON", table))
+ require.NoError(t, err)
+ defer func() {
+ _, err = db.Exec(ctx, fmt.Sprintf("SET IDENTITY_INSERT [%s] OFF", table))
+ require.NoError(t, err)
+ }()
+ }
+ require.NoError(t, db.Insert(ctx, beans...))
+ require.NoError(t, committer.Commit())
+}
diff --git a/tests/integration/api_fork_test.go b/tests/integration/api_fork_test.go
index bd2d38ef4f..f99cc29fab 100644
--- a/tests/integration/api_fork_test.go
+++ b/tests/integration/api_fork_test.go
@@ -19,15 +19,35 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestCreateForkNoLogin(t *testing.T) {
+func TestAPIFork(t *testing.T) {
defer tests.PrepareTestEnv(t)()
+ t.Run("CreateForkNoLogin", testCreateForkNoLogin)
+ t.Run("CreateForkOrgNoCreatePermission", testCreateForkOrgNoCreatePermission)
+ t.Run("APIForkListLimitedAndPrivateRepos", testAPIForkListLimitedAndPrivateRepos)
+ t.Run("GetPrivateReposForks", testGetPrivateReposForks)
+}
+
+func testCreateForkNoLogin(t *testing.T) {
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{})
MakeRequest(t, req, http.StatusUnauthorized)
}
-func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
- defer tests.PrepareTestEnv(t)()
+func testCreateForkOrgNoCreatePermission(t *testing.T) {
+ user4Sess := loginUser(t, "user4")
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
+ canCreate, err := org_model.OrgFromUser(org).CanCreateOrgRepo(t.Context(), 4)
+ assert.NoError(t, err)
+ assert.False(t, canCreate)
+
+ user4Token := getTokenForLoggedInUser(t, user4Sess, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{
+ Organization: &org.Name,
+ }).AddTokenAuth(user4Token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func testAPIForkListLimitedAndPrivateRepos(t *testing.T) {
user1Sess := loginUser(t, "user1")
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
@@ -64,10 +84,7 @@ func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks")
resp := MakeRequest(t, req, http.StatusOK)
-
- var forks []*api.Repository
- DecodeJSON(t, resp, &forks)
-
+ forks := DecodeJSON(t, resp, []*api.Repository{})
assert.Empty(t, forks)
assert.Equal(t, "0", resp.Header().Get("X-Total-Count"))
})
@@ -78,9 +95,7 @@ func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
resp := MakeRequest(t, req, http.StatusOK)
- var forks []*api.Repository
- DecodeJSON(t, resp, &forks)
-
+ forks := DecodeJSON(t, resp, []*api.Repository{})
assert.Len(t, forks, 2)
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
@@ -88,28 +103,22 @@ func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
resp = MakeRequest(t, req, http.StatusOK)
-
- forks = []*api.Repository{}
- DecodeJSON(t, resp, &forks)
-
+ forks = DecodeJSON(t, resp, []*api.Repository{})
assert.Len(t, forks, 2)
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
})
}
-func TestGetPrivateReposForks(t *testing.T) {
- defer tests.PrepareTestEnv(t)()
-
+func testGetPrivateReposForks(t *testing.T) {
user1Sess := loginUser(t, "user1")
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) // private repository
privateOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23})
user1Token := getTokenForLoggedInUser(t, user1Sess, auth_model.AccessTokenScopeWriteRepository)
- forkedRepoName := "forked-repo"
// create fork from a private repository
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/"+repo2.FullName()+"/forks", &api.CreateForkOption{
Organization: &privateOrg.Name,
- Name: &forkedRepoName,
+ Name: new("forked-repo"),
}).AddTokenAuth(user1Token)
MakeRequest(t, req, http.StatusAccepted)
@@ -117,8 +126,7 @@ func TestGetPrivateReposForks(t *testing.T) {
req = NewRequest(t, "GET", "/api/v1/repos/"+repo2.FullName()+"/forks").AddTokenAuth(user1Token)
resp := MakeRequest(t, req, http.StatusOK)
- forks := []*api.Repository{}
- DecodeJSON(t, resp, &forks)
+ forks := DecodeJSON(t, resp, []*api.Repository{})
assert.Len(t, forks, 1)
assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
assert.Equal(t, "forked-repo", forks[0].Name)
diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go
index dae2c1c3c5..d60f66e785 100644
--- a/tests/integration/integration_test.go
+++ b/tests/integration/integration_test.go
@@ -415,7 +415,7 @@ func DecodeJSON[T any](t testing.TB, resp *httptest.ResponseRecorder, v T) (ret
// FIXME: JSON-KEY-CASE: for testing purpose only, because many structs don't provide `json` tags, they just use capitalized field names
decoder := json.NewDecoderCaseInsensitive(resp.Body)
- require.NoError(t, decoder.Decode(v))
+ require.NoError(t, decoder.Decode(&v))
return v
}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index f44a5d41ed..c23e3e1c19 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -33,6 +33,7 @@
@import "./modules/flexcontainer.css";
@import "./modules/codeeditor.css";
@import "./modules/chroma.css";
+@import "./modules/charescape.css";
@import "./shared/flex-list.css";
@import "./shared/milestone.css";
diff --git a/web_src/css/modules/charescape.css b/web_src/css/modules/charescape.css
new file mode 100644
index 0000000000..0c9cbb55b5
--- /dev/null
+++ b/web_src/css/modules/charescape.css
@@ -0,0 +1,48 @@
+/*
+Show the escaped and hide the real char:
+ {real-char}
+Only show the real-char:
+ {real-char}
+*/
+.broken-code-point:not([data-escaped]),
+.broken-code-point[data-escaped]::before {
+ border-radius: 4px;
+ padding: 0 2px;
+ color: var(--color-body);
+ background: var(--color-text-light-1);
+}
+
+.broken-code-point[data-escaped]::before {
+ visibility: visible;
+ content: attr(data-escaped);
+}
+.broken-code-point[data-escaped] .char {
+ /* make it copyable by selecting the text (AI suggestion, no other solution) */
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+/*
+Show the escaped and hide the real-char:
+
+ {real-char}
+
+Hide the escaped and show the real-char:
+
+ {real-char}
+
+*/
+.unicode-escaped .escaped-code-point[data-escaped]::before {
+ visibility: visible;
+ content: attr(data-escaped);
+ color: var(--color-red);
+}
+
+.unicode-escaped .escaped-code-point .char {
+ display: none;
+}
+
+.unicode-escaped .ambiguous-code-point {
+ border: 1px var(--color-yellow) solid;
+}
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 1dd5301338..95d6ca2169 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -8,26 +8,6 @@
min-width: 40% !important;
}
-.repository .unicode-escaped .escaped-code-point[data-escaped]::before {
- visibility: visible;
- content: attr(data-escaped);
- font-family: var(--fonts-monospace);
- color: var(--color-red);
-}
-
-.repository .unicode-escaped .escaped-code-point .char {
- display: none;
-}
-
-.repository .broken-code-point {
- font-family: var(--fonts-monospace);
- color: var(--color-blue);
-}
-
-.repository .unicode-escaped .ambiguous-code-point {
- border: 1px var(--color-yellow) solid;
-}
-
.issue-content {
display: flex;
align-items: flex-start;
diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts
index d964c88989..63510458f9 100644
--- a/web_src/js/markup/content.ts
+++ b/web_src/js/markup/content.ts
@@ -6,10 +6,20 @@ import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initMarkupRenderIframe} from './render-iframe.ts';
import {initMarkupRefIssue} from './refissue.ts';
+import {toggleElemClass} from '../utils/dom.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
+ if (el.matches('.truncated-markup')) {
+ // when the rendered markup is truncated (e.g.: user's home activity feed)
+ // we should not initialize any of the features (e.g.: code copy button), due to:
+ // * truncated markup already means that the container doesn't want to show complex contents
+ // * truncated markup may contain incomplete HTML/mermaid elements
+ // so the only thing we need to do is to remove the "is-loading" class added by the backend render.
+ toggleElemClass(el.querySelectorAll('.is-loading'), 'is-loading', false);
+ return;
+ }
initMarkupCodeCopy(el);
initMarkupTasklist(el);
initMarkupCodeMermaid(el);