// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package integration

import (
	"encoding/base64"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"testing"
	"time"

	runnerv1 "gitea.dev/actions-proto-go/runner/v1"
	actions_model "gitea.dev/models/actions"
	auth_model "gitea.dev/models/auth"
	repo_model "gitea.dev/models/repo"
	unit_model "gitea.dev/models/unit"
	"gitea.dev/models/unittest"
	user_model "gitea.dev/models/user"
	"gitea.dev/modules/commitstatus"
	"gitea.dev/modules/queue"
	api "gitea.dev/modules/structs"
	"gitea.dev/services/forms"
	repo_service "gitea.dev/services/repository"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const scopedPushWorkflow = `name: Scoped Push
on: push
jobs:
  scoped-job:
    runs-on: ubuntu-latest
    steps:
      - run: echo scoped
`

const scopedPRWorkflow = `name: Scoped PR
on: pull_request
jobs:
  scoped-pr-job:
    runs-on: ubuntu-latest
    steps:
      - run: echo scoped-pr
`

func TestActionsScopedWorkflows(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)

		// createTestRepo creates an Actions-enabled repo owned by user2, used as a scoped-workflow source or consumer.
		createTestRepo := func(t *testing.T, name string, private bool) *repo_model.Repository {
			apiRepo := createActionsTestRepo(t, user2Token, name, private)
			return unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
		}

		// registerUserScopedSource registers `source` as a user-level scoped-workflow source for user2 and marks `required` entry names
		registerUserScopedSource := func(t *testing.T, source *repo_model.Repository, required ...string) {
			addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add",
				map[string]string{"repo_name": source.Name})
			user2Session.MakeRequest(t, addReq, http.StatusOK)
			t.Cleanup(func() {
				removeReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/remove",
					map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
				user2Session.MakeRequest(t, removeReq, http.StatusOK)
			})
			if len(required) > 0 {
				vals := url.Values{"repo_id": {strconv.FormatInt(source.ID, 10)}, "workflow_ids": required, "required_workflow_ids": required}
				for _, id := range required {
					// a pattern that matches the source's scoped check regardless of its `name:` (each test source has one workflow)
					vals.Set("required_patterns["+id+"]", source.FullName()+": * / *")
				}
				reqReq := NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals)
				user2Session.MakeRequest(t, reqReq, http.StatusOK)
			}
		}

		t.Run("Trigger and run creation", func(t *testing.T) {
			// Registered at INSTANCE level via the admin route (owner/name resolution + OwnerID=0 storage);
			// the trigger->execute->rerun below proves an instance-level source drives a consumer run end-to-end and that a rerun stays scoped.
			adminSession := loginUser(t, "user1")
			source := createTestRepo(t, "sw-trigger-source", false)
			// commit the scoped workflow BEFORE registering so the source's own push does not self-trigger.
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
			adminAdd := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/add", map[string]string{"repo_name": source.FullName()})
			adminSession.MakeRequest(t, adminAdd, http.StatusOK)
			t.Cleanup(func() {
				rm := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/remove", map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
				adminSession.MakeRequest(t, rm, http.StatusOK)
			})
			unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: 0, SourceRepoID: source.ID})

			consumer := createTestRepo(t, "sw-trigger-consumer", false)
			runner := newMockRunner()
			runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-trigger-runner", []string{"ubuntu-latest"}, false)

			createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")

			run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
			assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
			assert.Equal(t, "push.yaml", run.WorkflowID)
			assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID}), "only the scoped run, no repo-level run")
			job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})

			// runs in the CONSUMER's context and reaches a terminal state
			task := runner.fetchTask(t)
			_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
			assert.Equal(t, consumer.ID, taskJob.RepoID)
			assert.Equal(t, run.ID, taskRun.ID)
			runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
			run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
			assert.Equal(t, actions_model.StatusSuccess, run.Status)

			// rerun: the rerun is still a scoped run and again executes in the consumer's context
			rerunReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", consumer.OwnerName, consumer.Name, run.ID, job.ID))
			user2Session.MakeRequest(t, rerunReq, http.StatusOK)
			unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: run.ID, Attempt: 2})
			task2 := runner.fetchTask(t)
			_, taskJob2, taskRun2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
			assert.Equal(t, consumer.ID, taskJob2.RepoID)
			assert.True(t, taskRun2.IsScopedRun, "the rerun is still a scoped run")
			runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
			run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
			assert.Equal(t, actions_model.StatusSuccess, run.Status)
		})

		t.Run("Opt-out", func(t *testing.T) {
			// opt-out: a consumer can disable a non-required scoped workflow, but a required one cannot be disabled.
			source := createTestRepo(t, "sw-optout-source", false)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
			registerUserScopedSource(t, source) // non-required

			// non-required: the kebab "Disable Workflow" item is an active link; disabling then makes a push produce no scoped run.
			consumer := createTestRepo(t, "sw-optout-consumer", false)
			optBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
				fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID)),
				http.StatusOK).Body.String()
			assert.Contains(t, optBody, "Disable Workflow")
			assert.Contains(t, optBody, "disable?workflow=push.yaml", "non-required scoped workflow: Disable Workflow is a clickable link")
			disableReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID))
			user2Session.MakeRequest(t, disableReq, http.StatusOK)
			createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
			assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "opted-out scoped workflow must not run")

			// required: the kebab "Disable Workflow" item is rendered disabled (no link), and the disable endpoint rejects it.
			reqSource := createTestRepo(t, "sw-optout-req-source", false)
			createRepoWorkflowFile(t, user2, user2Token, reqSource, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
			registerUserScopedSource(t, reqSource, "push.yaml") // required
			reqConsumer := createTestRepo(t, "sw-optout-req-consumer", false)
			requiredBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
				fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID)),
				http.StatusOK).Body.String()
			assert.Contains(t, requiredBody, "Disable Workflow")
			assert.Contains(t, requiredBody, `class="item disabled"`, "required scoped workflow: Disable Workflow is rendered disabled")
			assert.NotContains(t, requiredBody, "disable?workflow=push.yaml", "required scoped workflow: Disable Workflow has no clickable link")
			rejectReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID))
			user2Session.MakeRequest(t, rejectReq, http.StatusBadRequest) // scoped_required_cannot_disable
		})

		t.Run("Local uses resolves to source", func(t *testing.T) {
			// uses: ./ in a scoped workflow resolves against the SOURCE repo, not the consumer.
			// Here the reusable lib lives in the SCOPED workflow dir (allowed by ResolveUses), exercising that path end-to-end.
			source := createTestRepo(t, "sw-uses-source", false)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
  workflow_call:
jobs:
  lib_job_source:
    runs-on: ubuntu-latest
    steps:
      - run: echo from-source
`)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/caller.yaml", `name: Caller
on: push
jobs:
  caller_job:
    uses: ./.gitea/scoped_workflows/lib.yaml
`)

			consumer := createTestRepo(t, "sw-uses-consumer", false)
			// a DIFFERENT lib at the same path in the consumer; if uses:./ mis-resolved we would see this job
			createRepoWorkflowFile(t, user2, user2Token, consumer, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
  workflow_call:
jobs:
  lib_job_consumer:
    runs-on: ubuntu-latest
    steps:
      - run: echo from-consumer
`)
			// register only AFTER both repos' scoped files exist, so the setup pushes do not trigger
			registerUserScopedSource(t, source)
			createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")

			run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "caller.yaml"})
			callerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "caller_job"})
			assert.True(t, callerJob.IsReusableCaller)
			assert.True(t, callerJob.IsExpanded)
			assert.Equal(t, source.ID, callerJob.WorkflowSourceRepoID, "top-level caller's content source is the source repo")
			// the expanded child comes from the SOURCE's lib.yaml, not the consumer's same-path file
			unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_source", ParentJobID: callerJob.ID})
			unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_consumer"})
		})

		t.Run("Workflow dispatch", func(t *testing.T) {
			// a scoped on:workflow_dispatch workflow can be triggered manually from the consumer, via both the web form and the API
			source := createTestRepo(t, "sw-dispatch-source", false)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/dispatch.yaml", `name: Scoped Dispatch
on: workflow_dispatch
jobs:
  dispatch-job:
    runs-on: ubuntu-latest
    steps:
      - run: echo dispatch
`)
			registerUserScopedSource(t, source)
			consumer := createTestRepo(t, "sw-dispatch-consumer", false)

			// web form: /run?...&scoped_workflow_source_repo_id=
			webReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=dispatch.yaml&scoped_workflow_source_repo_id=%d&ref=refs/heads/%s",
				consumer.OwnerName, consumer.Name, source.ID, consumer.DefaultBranch))
			user2Session.MakeRequest(t, webReq, http.StatusSeeOther)
			run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml"})
			assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
			assert.NotEmpty(t, run.WorkflowCommitSHA, "scoped dispatch records the source default-branch commit")
			assert.Contains(t, run.Ref, consumer.DefaultBranch, "dispatch runs on the chosen consumer ref")

			// API: /actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=
			apiReq := NewRequestWithURLValues(t, "POST",
				fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID),
				url.Values{"ref": {consumer.DefaultBranch}}).AddTokenAuth(user2Token)
			MakeRequest(t, apiReq, http.StatusNoContent)
			assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml", Event: "workflow_dispatch"}),
				"both the web form and the API created a scoped dispatch run")
		})

		t.Run("Required scoped check gates the PR merge", func(t *testing.T) {
			// A required scoped workflow's check gates PR merges on a protected branch and cannot be bypassed,
			// whether or not the branch enables its own status check. The scoped check is added to the required set dynamically.
			source := createTestRepo(t, "sw-gate-source", false)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedPRWorkflow)
			registerUserScopedSource(t, source, "pr.yaml") // required

			// protectAndOpenPR protects consumer's default branch and opens a PR on `branch`, returning a merge-request builder.
			// When statusCheckEnabled it also configures "ci/manual" as the only CONFIGURED required context and satisfies it,
			// so the scoped check is the only thing that can gate the merge; otherwise the rule's own status check stays off.
			protectAndOpenPR := func(t *testing.T, consumer *repo_model.Repository, branch string, statusCheckEnabled bool) func() *RequestWrapper {
				pbValues := map[string]string{
					"rule_name":                  consumer.DefaultBranch,
					"enable_push":                "true",
					"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
				}
				if statusCheckEnabled {
					pbValues["enable_status_check"] = "true"
					pbValues["status_check_contexts"] = "ci/manual"
				}
				user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusSeeOther)

				prFile := &api.CreateFileOptions{
					FileOptions: api.FileOptions{
						BranchName: consumer.DefaultBranch, NewBranchName: branch, Message: "pr change",
						Author:    api.Identity{Name: user2.Name, Email: user2.Email},
						Committer: api.Identity{Name: user2.Name, Email: user2.Email},
						Dates:     api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
					},
					ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
				}
				createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "pr-change.txt", prFile)
				apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
				pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, branch)(t)
				require.NoError(t, err)

				if statusCheckEnabled {
					// satisfy the configured "ci/manual" check so only the scoped check can gate the merge
					manualStatus := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", consumer.OwnerName, consumer.Name, pr.Head.Sha),
						api.CreateStatusOption{State: commitstatus.CommitStatusSuccess, Context: "ci/manual", TargetURL: "http://test.ci/"}).AddTokenAuth(user2Token)
					user2Session.MakeRequest(t, manualStatus, http.StatusCreated)
				}

				return func() *RequestWrapper {
					return NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
						&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
				}
			}

			t.Run("pending blocks, success allows", func(t *testing.T) {
				consumer := createTestRepo(t, "sw-gate-consumer", false)
				runner := newMockRunner()
				runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-gate-runner", []string{"ubuntu-latest"}, false)

				mergeReq := protectAndOpenPR(t, consumer, "gate-pr", true)
				run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
				assert.Equal(t, source.ID, run.WorkflowRepoID)

				// the pending required scoped check blocks the merge
				assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
				user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)

				// the required scoped run succeeds ->  merge allowed
				task := runner.fetchTask(t)
				runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
				assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
				user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
			})

			t.Run("Actions disabled blocks merge (no bypass)", func(t *testing.T) {
				// must-present: disabling the consumer's Actions unit so the required scoped workflow cannot run.
				// Must BLOCK the merge (the required check is absent), not bypass it.
				consumer := createTestRepo(t, "sw-noact-consumer", false)
				require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), consumer, nil, []unit_model.Type{unit_model.TypeActions}))

				mergeReq := protectAndOpenPR(t, consumer, "noact-pr", true)
				assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
					"Actions disabled, so no scoped run is created")

				// the required scoped check never posted a status ->  must-present blocks the merge (no bypass)
				assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
				user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
			})

			t.Run("status check disabled: the scoped check still gates", func(t *testing.T) {
				// the scoped check gates the merge even when the branch's OWN status check is off
				consumer := createTestRepo(t, "sw-nocheck-consumer", false)
				runner := newMockRunner()
				runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-nocheck-runner", []string{"ubuntu-latest"}, false)

				mergeReq := protectAndOpenPR(t, consumer, "nocheck-pr", false)
				unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})

				// pending scoped check blocks the merge despite the branch's own status check being off
				assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
				user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)

				// the required scoped run succeeds ->  merge allowed
				task := runner.fetchTask(t)
				runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
				assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
				user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
			})
		})

		t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) {
			// A required scoped workflow excluded by a paths filter posts a skipped (success) commit status,
			// so the required check is satisfied and the PR can merge.

			const scopedFilteredPRWorkflow = `name: Scoped Filtered PR
on:
  pull_request:
    paths:
      - src/**
jobs:
  scoped-filtered-job:
    runs-on: ubuntu-latest
    steps:
      - run: echo scoped-filtered
`
			source := createTestRepo(t, "sw-filtered-source", false)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow)
			registerUserScopedSource(t, source, "pr.yaml") // required

			consumer := createTestRepo(t, "sw-filtered-consumer", false)
			// Protect the default branch (its own status check stays off, so only the required scoped check gates the merge).
			user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{
				"rule_name":                  consumer.DefaultBranch,
				"enable_push":                "true",
				"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
			}), http.StatusSeeOther)

			// Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out.
			prFile := &api.CreateFileOptions{
				FileOptions: api.FileOptions{
					BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change",
					Author:    api.Identity{Name: user2.Name, Email: user2.Email},
					Committer: api.Identity{Name: user2.Name, Email: user2.Email},
					Dates:     api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
				},
				ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
			}
			createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile)
			apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
			pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t)
			require.NoError(t, err)

			// Filtered: no scoped run is created, but a skipped commit status is posted on the PR head.
			assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run")
			assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request")

			// The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed.
			assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
			mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
				&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
			user2Session.MakeRequest(t, mergeReq, http.StatusOK)
		})

		t.Run("Settings page required patterns", func(t *testing.T) {
			source := createTestRepo(t, "sw-settings-source", false)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
			createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/manual.yaml", `name: Manual
on: workflow_dispatch
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo
`) // workflow_dispatch posts no status -> the settings page must warn instead of listing contexts
			registerUserScopedSource(t, source) // registered; each phase configures it via the /required endpoint
			pattern := source.FullName() + ": * / *"

			setConfigs := func(t *testing.T, vals url.Values) {
				vals.Set("repo_id", strconv.FormatInt(source.ID, 10))
				user2Session.MakeRequest(t, NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals), http.StatusOK)
			}
			loadSource := func(t *testing.T) *actions_model.ActionScopedWorkflowSource {
				return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
			}
			settingsBody := func(t *testing.T) string {
				return user2Session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/actions/scoped-workflows"), http.StatusOK).Body.String()
			}

			t.Run("renders the saved pattern and display-name default", func(t *testing.T) {
				setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
				body := settingsBody(t)
				assert.Contains(t, body, `name="required_patterns[push.yaml]"`, "patterns textarea uses the field name the parser expects")
				assert.Contains(t, body, pattern, "the saved pattern round-trips into the textarea")
				// the default prefill must use the workflow display name so it matches the status context the run posts (name: Scoped Push)
				assert.Contains(t, body, `data-default-pattern="`+source.FullName()+`: Scoped Push / *"`)
				// the expected-checks preview derives the exact context a run posts (job scoped-job, event push) for live glob matching
				assert.Contains(t, body, `data-context="`+source.FullName()+`: Scoped Push / scoped-job (push)"`)
			})

			t.Run("live pattern kept as history after un-require", func(t *testing.T) {
				setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
				// un-require: the row still submits workflow_ids + its patterns (the hidden textarea), but not required_workflow_ids
				setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
				cfg := loadSource(t).WorkflowConfigs["push.yaml"]
				require.NotNil(t, cfg)
				assert.False(t, cfg.Required, "no longer required")
				assert.Equal(t, []string{pattern}, cfg.Patterns, "pattern retained as history")
				assert.Contains(t, settingsBody(t), pattern, "history pattern still rendered, so re-requiring restores it")
			})

			t.Run("orphan config dropped when un-required", func(t *testing.T) {
				// An orphan entry (gone.yaml: required for a file that no longer exists in the source) has no history worth keeping:
				// un-checking Required must drop it entirely, unlike a live un-required workflow.
				setConfigs(t, url.Values{
					"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml", "gone.yaml"},
					"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
				})
				require.True(t, loadSource(t).IsWorkflowRequired("gone.yaml"), "orphan kept while still required")

				// un-require gone.yaml (its row + patterns are still submitted, as the settings page does); push.yaml stays required
				setConfigs(t, url.Values{
					"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml"},
					"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
				})
				src := loadSource(t)
				assert.Nil(t, src.WorkflowConfigs["gone.yaml"], "orphan dropped after un-require, not kept as history")
				assert.True(t, src.IsWorkflowRequired("push.yaml"), "live required workflow kept")
			})

			t.Run("warns when a workflow posts no status checks", func(t *testing.T) {
				// manual.yaml only runs on workflow_dispatch, which posts no commit status: instead of listing expected checks,
				// its row shows a warning not to mark it required (must-present would block forever).
				body := settingsBody(t)
				assert.Contains(t, body, "posts no status checks", "the no-status-check warning is shown")
				assert.NotContains(t, body, `data-context="`+source.FullName()+`: Manual /`, "a workflow_dispatch-only workflow must list no expected contexts")
			})
		})

		t.Run("Distinct sources same filename", func(t *testing.T) {
			// two DIFFERENT source repos with the same filename run independently
			s1 := createTestRepo(t, "sw-multi-s1", false)
			createRepoWorkflowFile(t, user2, user2Token, s1, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
			s2 := createTestRepo(t, "sw-multi-s2", false)
			createRepoWorkflowFile(t, user2, user2Token, s2, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
			registerUserScopedSource(t, s1)
			registerUserScopedSource(t, s2)

			consumer := createTestRepo(t, "sw-multi-consumer", false)
			createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")

			assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "same-named ci.yaml from two sources run independently")
			unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s1.ID})
			unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s2.ID})
		})

		t.Run("Detection cache invalidates on source push", func(t *testing.T) {
			// The detection parse is cached per (source, default-branch SHA).
			source := createTestRepo(t, "sw-cache-source", false)
			created := createWorkflowFile(t, user2Token, source.OwnerName, source.Name, ".gitea/scoped_workflows/ci.yaml",
				getWorkflowCreateFileOptions(user2, source.DefaultBranch, "create ci", `name: CI
on: pull_request
jobs:
  j:
    runs-on: ubuntu-latest
    steps:
      - run: echo a
`))
			registerUserScopedSource(t, source)

			consumer := createTestRepo(t, "sw-cache-consumer", false)

			// warm the cache at the source's current SHA: the source triggers on pull_request, so a consumer push is no match
			createRepoWorkflowFile(t, user2, user2Token, consumer, "m1.txt", "trigger")
			assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
				"source triggers on pull_request, so a consumer push must not create a scoped run")

			// switch the source's trigger to push on its default branch
			updateReq := NewRequestWithJSON(t, "PUT",
				fmt.Sprintf("/api/v1/repos/%s/%s/contents/.gitea/scoped_workflows/ci.yaml", source.OwnerName, source.Name),
				&api.UpdateFileOptions{
					SHA:         created.Content.SHA,
					FileOptions: api.FileOptions{BranchName: source.DefaultBranch, Message: "switch to push"},
					ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: CI
on: push
jobs:
  j:
    runs-on: ubuntu-latest
    steps:
      - run: echo a
`)),
				}).AddTokenAuth(user2Token)
			MakeRequest(t, updateReq, http.StatusOK)

			// the next consumer push must re-detect against the new SHA (on: push) and create the scoped run
			createRepoWorkflowFile(t, user2, user2Token, consumer, "m2.txt", "trigger")
			assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, Event: "push"}),
				"after the source switches to on: push, the next consumer push creates a scoped run")
		})

		t.Run("Deletion cleans up source registration", func(t *testing.T) {
			source := createTestRepo(t, "sw-delete-source", false)

			addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add", map[string]string{"repo_name": source.Name})
			user2Session.MakeRequest(t, addReq, http.StatusOK)
			unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})

			delReq := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", source.OwnerName, source.Name)).AddTokenAuth(user2Token)
			MakeRequest(t, delReq, http.StatusNoContent)
			unittest.AssertNotExistsBean(t, &actions_model.ActionScopedWorkflowSource{SourceRepoID: source.ID})
		})
	})
}
