From 1e0dea85376e3c14e819a7e7b49f3e52d8de351b Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 31 May 2026 12:46:21 +0300 Subject: [PATCH 1/3] feat(dispatch): synchronous workflow_call event dispatch (ADR 41) Replace per-org dispatch marker scan and gh workflow run with static workflow_call jobs to upstream reusable workflows. Remove thin stage workflows from scaffold; add workflow_call to prioritize.yml. Port per-stage concurrency into reusable workflows. Per-org dispatch grants OIDC on stage jobs, maps fix to coder role, and passes GCP secrets through shim secrets: inherit. Per-repo reusable-dispatch adds prioritize handler. Update e2e to poll fullsend.yaml on enrolled repos. Co-authored-by: Cursor Signed-off-by: Barak Korren --- .github/workflows/reusable-code.yml | 8 +- .github/workflows/reusable-dispatch.yml | 22 +- .github/workflows/reusable-fix.yml | 16 +- .github/workflows/reusable-retro.yml | 8 +- .github/workflows/reusable-review.yml | 8 +- .github/workflows/reusable-triage.yml | 8 +- .pre-commit-config.yaml | 4 + e2e/admin/admin_test.go | 52 ++- internal/layers/workflows.go | 6 +- internal/layers/workflows_test.go | 30 +- .../fullsend-repo/.github/workflows/code.yml | 40 --- .../.github/workflows/dispatch.yml | 300 ++++++++++-------- .../fullsend-repo/.github/workflows/fix.yml | 64 ---- .../.github/workflows/prioritize.yml | 44 ++- .../fullsend-repo/.github/workflows/retro.yml | 38 --- .../.github/workflows/review.yml | 39 --- .../.github/workflows/triage.yml | 38 --- .../templates/shim-workflow-call.yaml | 1 + internal/scaffold/scaffold.go | 2 +- internal/scaffold/scaffold_test.go | 209 ++++-------- 20 files changed, 361 insertions(+), 576 deletions(-) delete mode 100644 internal/scaffold/fullsend-repo/.github/workflows/code.yml delete mode 100644 internal/scaffold/fullsend-repo/.github/workflows/fix.yml delete mode 100644 internal/scaffold/fullsend-repo/.github/workflows/retro.yml delete mode 100644 internal/scaffold/fullsend-repo/.github/workflows/review.yml delete mode 100644 internal/scaffold/fullsend-repo/.github/workflows/triage.yml diff --git a/.github/workflows/reusable-code.yml b/.github/workflows/reusable-code.yml index 722b93769..daf3ddd56 100644 --- a/.github/workflows/reusable-code.yml +++ b/.github/workflows/reusable-code.yml @@ -1,7 +1,11 @@ -# Reusable code agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable code agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). name: Code Agent +concurrency: + group: fullsend-code-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-dispatch.yml b/.github/workflows/reusable-dispatch.yml index 2bf2eea5b..162f930ee 100644 --- a/.github/workflows/reusable-dispatch.yml +++ b/.github/workflows/reusable-dispatch.yml @@ -1,7 +1,7 @@ # Reusable dispatch workflow for per-repo installation mode. # Routes events to the appropriate stage reusable workflow via conditional -# workflow_call jobs. This is the per-repo equivalent of the per-org -# dispatch.yml + thin caller pair. +# workflow_call jobs. Same direct-dispatch pattern as per-org dispatch.yml +# (ADR 0041). # # Flow: shim (per-repo) → reusable-dispatch.yml → reusable-{stage}.yml # Nesting: 3 levels of workflow_call (within GitHub's 4-level limit) @@ -49,7 +49,7 @@ jobs: contents: read pull-requests: read outputs: - stage: ${{ steps.role-check.outputs.skipped == 'true' && '' || steps.route.outputs.stage }} + stage: ${{ steps.role-check.outputs.skipped != 'true' && steps.route.outputs.stage || '' }} trigger_source: ${{ steps.route.outputs.trigger_source }} event_payload: ${{ steps.payload.outputs.event_payload }} steps: @@ -413,3 +413,19 @@ jobs: FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + + prioritize: + name: Prioritize + needs: route + if: needs.route.outputs.stage == 'prioritize' + uses: ./.fullsend/.github/workflows/prioritize.yml + permissions: + contents: read + id-token: write + with: + event_type: ${{ github.event_name }} + source_repo: ${{ github.repository }} + event_payload: ${{ needs.route.outputs.event_payload }} + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} diff --git a/.github/workflows/reusable-fix.yml b/.github/workflows/reusable-fix.yml index 018087927..fb1e8f2f8 100644 --- a/.github/workflows/reusable-fix.yml +++ b/.github/workflows/reusable-fix.yml @@ -1,7 +1,19 @@ -# Reusable fix agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable fix agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). name: Fix Agent +# Single concurrency group per PR. A human /fix cancels any running fix so the +# human's instruction takes immediate effect. Bot-triggered runs also cancel +# previous bot runs on the same PR. +concurrency: + group: >- + fullsend-fix-${{ inputs.source_repo }}-${{ + fromJSON(inputs.event_payload).pull_request.number + || fromJSON(inputs.event_payload).issue.number + || inputs.pr_number + }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-retro.yml b/.github/workflows/reusable-retro.yml index 747fac7c0..59076191a 100644 --- a/.github/workflows/reusable-retro.yml +++ b/.github/workflows/reusable-retro.yml @@ -1,7 +1,11 @@ -# Reusable retro agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable retro agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). name: Retro Agent +concurrency: + group: fullsend-retro-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-review.yml b/.github/workflows/reusable-review.yml index e0e433734..4e5a19d6d 100644 --- a/.github/workflows/reusable-review.yml +++ b/.github/workflows/reusable-review.yml @@ -1,7 +1,11 @@ -# Reusable review agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable review agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). name: Review Agent +concurrency: + group: fullsend-review-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-triage.yml b/.github/workflows/reusable-triage.yml index fbf0a0f3c..62f083062 100644 --- a/.github/workflows/reusable-triage.yml +++ b/.github/workflows/reusable-triage.yml @@ -1,7 +1,11 @@ -# Reusable triage agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable triage agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). name: Triage Agent +concurrency: + group: fullsend-triage-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3450c947c..5ec47de89 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -68,6 +68,10 @@ repos: - property "workflow_repository" is not defined - -ignore - SC2016 + - -ignore + - 'could not read reusable workflow file for "\./\.github/workflows/prioritize\.yml"' + - -ignore + - 'could not read reusable workflow file for "\./\.fullsend/\.github/workflows/prioritize\.yml"' - repo: local hooks: diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index b9d39f76f..03d17f460 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -167,10 +167,6 @@ func TestAdminInstallUninstall(t *testing.T) { // scripts/, env/) and upstream-only dirs (.github/actions/, .github/scripts/) are // provided at runtime via sparse checkout in reusable workflows. for _, path := range []string{ - ".github/workflows/triage.yml", - ".github/workflows/code.yml", - ".github/workflows/review.yml", - ".github/workflows/fix.yml", ".github/workflows/dispatch.yml", ".github/workflows/repo-maintenance.yml", ".github/workflows/prioritize.yml", @@ -254,6 +250,11 @@ func runTriageDispatchSmokeTest(t *testing.T, env *e2eEnv) { t.Helper() ctx := context.Background() + // Capture a lower-bound timestamp *before* creating the issue. + // The shim/dispatch workflows can start very quickly, and we only want to + // filter out runs from earlier test phases. + issueCreatedAt := time.Now() + // File a test issue to trigger the shim workflow. issueTitle := fmt.Sprintf("e2e-triage-test-%s", env.runID) issueBody := `## Bug Report @@ -295,17 +296,14 @@ Files over 64KB save fine if they contain only ASCII characters.` } }) - // Wait for the triage workflow to be dispatched in .fullsend. - // The shim fires on issues:opened and dispatches to triage.yml. - // The shim typically fires within ~5s of the issue being created, - // so 12 attempts at 5s intervals (60s total) is generous. - // Filter by CreatedAt to avoid false positives from previous runs. - issueCreatedAt := time.Now() - t.Log("Waiting for triage workflow to be dispatched...") - var triageRun *forge.WorkflowRun + // Wait for the enrolled-repo shim (fullsend.yaml). Cross-repo workflow_call + // runs appear on the caller repo, not as separate dispatch.yml runs in .fullsend. + // Chain: fullsend.yaml → .fullsend/dispatch.yml → reusable-triage (sync). + t.Log("Waiting for fullsend shim workflow to run...") + var shimRun *forge.WorkflowRun for attempt := 0; attempt < 12; attempt++ { time.Sleep(5 * time.Second) - runs, listErr := env.client.ListWorkflowRuns(ctx, env.org, forge.ConfigRepoName, "triage.yml") + runs, listErr := env.client.ListWorkflowRuns(ctx, env.org, testRepo, "fullsend.yaml") if listErr != nil { t.Logf("Attempt %d: error listing workflow runs: %v", attempt+1, listErr) continue @@ -322,24 +320,24 @@ Files over 64KB save fine if they contain only ASCII characters.` } t.Logf("Attempt %d: found run %d (status: %s, conclusion: %s, created: %s)", attempt+1, run.ID, run.Status, run.Conclusion, run.CreatedAt) r := run // avoid loop variable capture - triageRun = &r + shimRun = &r break } - if triageRun != nil { + if shimRun != nil { break } - t.Logf("Attempt %d: no triage workflow runs found yet", attempt+1) + t.Logf("Attempt %d: no fullsend shim workflow runs found yet", attempt+1) } - require.NotNil(t, triageRun, "triage workflow should have been dispatched in .fullsend repo") + require.NotNil(t, shimRun, "fullsend shim workflow should have run in enrolled repo") // Wait for the workflow run to complete (up to 12 minutes: 10-minute agent // timeout + sandbox setup overhead). - t.Logf("Waiting for triage workflow run %d to complete...", triageRun.ID) + t.Logf("Waiting for fullsend shim workflow run %d to complete...", shimRun.ID) var finalRun *forge.WorkflowRun deadline := time.Now().Add(12 * time.Minute) for time.Now().Before(deadline) { time.Sleep(15 * time.Second) - run, getErr := env.client.GetWorkflowRun(ctx, env.org, forge.ConfigRepoName, triageRun.ID) + run, getErr := env.client.GetWorkflowRun(ctx, env.org, testRepo, shimRun.ID) if getErr != nil { t.Logf("Error polling workflow run: %v", getErr) continue @@ -350,18 +348,18 @@ Files over 64KB save fine if they contain only ASCII characters.` break } } - require.NotNil(t, finalRun, "triage workflow run should have completed within deadline") + require.NotNil(t, finalRun, "fullsend shim workflow run should have completed within deadline") // If the run failed, save logs and artifacts for debugging. if finalRun.Conclusion != "success" { - runURL := fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d", env.org, forge.ConfigRepoName, finalRun.ID) - fmt.Fprintf(os.Stderr, "::notice::Triage workflow run %d failed (conclusion: %s). Downloading debug artifacts. Run URL: %s\n", finalRun.ID, finalRun.Conclusion, runURL) + runURL := fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d", env.org, testRepo, finalRun.ID) + fmt.Fprintf(os.Stderr, "::notice::Fullsend shim run %d failed (conclusion: %s). Downloading debug artifacts. Run URL: %s\n", finalRun.ID, finalRun.Conclusion, runURL) - debugDir := filepath.Join(env.screenshotDir, fmt.Sprintf("triage-run-%d", finalRun.ID)) + debugDir := filepath.Join(env.screenshotDir, fmt.Sprintf("fullsend-run-%d", finalRun.ID)) _ = os.MkdirAll(debugDir, 0o755) // Save workflow logs. - logs, logErr := env.client.GetWorkflowRunLogs(ctx, env.org, forge.ConfigRepoName, finalRun.ID) + logs, logErr := env.client.GetWorkflowRunLogs(ctx, env.org, testRepo, finalRun.ID) if logErr != nil { t.Logf("Could not fetch run logs: %v", logErr) } else { @@ -369,15 +367,15 @@ Files over 64KB save fine if they contain only ASCII characters.` if writeErr := os.WriteFile(logPath, []byte(logs), 0o644); writeErr != nil { t.Logf("Could not write logs to %s: %v", logPath, writeErr) } else { - fmt.Fprintf(os.Stderr, "::notice file=%s::Triage run %d workflow logs saved\n", logPath, finalRun.ID) + fmt.Fprintf(os.Stderr, "::notice file=%s::Fullsend shim run %d workflow logs saved\n", logPath, finalRun.ID) } t.Logf("Workflow run logs:\n%s", logs) } // Download run artifacts (transcripts, etc). - downloadRunArtifacts(ctx, env.token, env.org, forge.ConfigRepoName, finalRun.ID, debugDir, t) + downloadRunArtifacts(ctx, env.token, env.org, testRepo, finalRun.ID, debugDir, t) - t.Fatalf("Triage workflow run %d concluded with %q, expected success. Debug artifacts saved to %s", finalRun.ID, finalRun.Conclusion, debugDir) + t.Fatalf("Fullsend shim workflow run %d concluded with %q, expected success. Debug artifacts saved to %s", finalRun.ID, finalRun.Conclusion, debugDir) } // Verify the triage agent posted a comment on the issue. diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index 1ffc9a2db..2c35ba2c6 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -29,9 +29,9 @@ func init() { } // WorkflowsLayer manages workflow files and CODEOWNERS in the .fullsend -// config repo. It writes the thin caller workflows, composite actions, -// and a CODEOWNERS file that grants the installing user ownership of all -// config-repo contents. +// config repo. It writes dispatch.yml, prioritize.yml, repo-maintenance.yml, +// composite actions, and a CODEOWNERS file that grants the installing user +// ownership of all config-repo contents. type WorkflowsLayer struct { org string client forge.Client diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index de354cb8b..5c471f3aa 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -46,36 +46,38 @@ func TestWorkflowsLayer_Install_WritesAllFiles(t *testing.T) { paths[f.Path] = string(f.Content) } - assert.Contains(t, paths, ".github/workflows/triage.yml") - assert.Contains(t, paths, ".github/workflows/code.yml") - assert.Contains(t, paths, ".github/workflows/review.yml") - assert.Contains(t, paths, ".github/workflows/fix.yml") + assert.Contains(t, paths, ".github/workflows/dispatch.yml") + assert.Contains(t, paths, ".github/workflows/prioritize.yml") assert.Contains(t, paths, ".github/workflows/repo-maintenance.yml") + assert.NotContains(t, paths, ".github/workflows/triage.yml") + assert.NotContains(t, paths, ".github/workflows/code.yml") // CODEOWNERS is included in the same batch. assert.Contains(t, paths, "CODEOWNERS") assert.Contains(t, paths["CODEOWNERS"], "admin-user") } -func TestWorkflowsLayer_Install_TriageWorkflowContent(t *testing.T) { +func TestWorkflowsLayer_Install_DispatchWorkflowContent(t *testing.T) { client := forge.NewFakeClient() layer, _ := newWorkflowsLayer(t, client) err := layer.Install(context.Background()) require.NoError(t, err) - var triageContent string + var dispatchContent string for _, f := range client.CommittedFiles[0].Files { - if f.Path == ".github/workflows/triage.yml" { - triageContent = string(f.Content) + if f.Path == ".github/workflows/dispatch.yml" { + dispatchContent = string(f.Content) break } } - require.NotEmpty(t, triageContent, "triage.yml should have been written") + require.NotEmpty(t, dispatchContent, "dispatch.yml should have been written") - expected, err := scaffold.FullsendRepoFile(".github/workflows/triage.yml") + expected, err := scaffold.FullsendRepoFile(".github/workflows/dispatch.yml") require.NoError(t, err) - assert.Equal(t, string(expected), triageContent) + assert.Equal(t, string(expected), dispatchContent) + assert.Contains(t, dispatchContent, "reusable-triage.yml@v0") + assert.NotContains(t, dispatchContent, "gh workflow run") } func TestWorkflowsLayer_Install_RepoMaintenanceContent(t *testing.T) { @@ -125,7 +127,7 @@ func TestWorkflowsLayer_Install_ExecutableModes(t *testing.T) { modes[f.Path] = f.Mode } - assert.Equal(t, "100644", modes[".github/workflows/triage.yml"]) + assert.Equal(t, "100644", modes[".github/workflows/dispatch.yml"]) assert.Equal(t, "100644", modes["customized/agents/.gitkeep"]) assert.Equal(t, "100644", modes["AGENTS.md"]) @@ -187,7 +189,7 @@ func TestWorkflowsLayer_Analyze_NonePresent(t *testing.T) { func TestWorkflowsLayer_Analyze_Partial(t *testing.T) { client := &forge.FakeClient{ FileContents: map[string][]byte{ - "test-org/.fullsend/.github/workflows/triage.yml": []byte("triage workflow"), + "test-org/.fullsend/.github/workflows/dispatch.yml": []byte("dispatch workflow"), }, } layer, _ := newWorkflowsLayer(t, client) @@ -199,7 +201,7 @@ func TestWorkflowsLayer_Analyze_Partial(t *testing.T) { assert.Equal(t, StatusDegraded, report.Status) // Details should list what exists joined := strings.Join(report.Details, " ") - assert.Contains(t, joined, "triage.yml") + assert.Contains(t, joined, "dispatch.yml") // WouldFix should list what's missing assert.NotEmpty(t, report.WouldFix) fixJoined := strings.Join(report.WouldFix, " ") diff --git a/internal/scaffold/fullsend-repo/.github/workflows/code.yml b/internal/scaffold/fullsend-repo/.github/workflows/code.yml deleted file mode 100644 index 19637f8f0..000000000 --- a/internal/scaffold/fullsend-repo/.github/workflows/code.yml +++ /dev/null @@ -1,40 +0,0 @@ -# fullsend-stage: code -name: Code - -permissions: - actions: write - contents: write - id-token: write - issues: write - packages: read - pull-requests: write - -on: - workflow_dispatch: - inputs: - event_type: - required: true - type: string - source_repo: - required: true - type: string - event_payload: - required: true - type: string - -concurrency: - group: fullsend-code-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - -jobs: - code: - uses: fullsend-ai/fullsend/.github/workflows/reusable-code.yml@v0 - with: - event_type: ${{ inputs.event_type }} - source_repo: ${{ inputs.source_repo }} - event_payload: ${{ inputs.event_payload }} - mint_url: ${{ vars.FULLSEND_MINT_URL }} - gcp_region: ${{ vars.FULLSEND_GCP_REGION }} - secrets: - FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} - FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml b/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml index a2c7164cc..538463626 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml @@ -1,8 +1,10 @@ -# lint-workflow-size: max-lines=390 +# lint-workflow-size: max-lines=420 # Dispatcher workflow that routes events to agent workflows based on stage. # Routing logic determines the stage from event context — the shim only -# forwards the raw event. Adding a new stage requires only a case branch -# here; zero changes to enrolled repos. +# forwards the raw event. Adding a new stage requires a case branch here and +# a matching workflow_call job below; zero changes to enrolled repos. +# +# Flow: shim → dispatch.yml → reusable-{stage}.yml (synchronous workflow_call) name: Dispatch on: @@ -12,17 +14,34 @@ on: description: 'The event action (github.event.action) forwarded by the shim' required: true type: string + secrets: + FULLSEND_GCP_WIF_PROVIDER: + required: true + FULLSEND_GCP_PROJECT_ID: + required: true permissions: {} jobs: - dispatch: + route: + name: Route runs-on: ubuntu-latest permissions: - actions: write contents: read - id-token: write + pull-requests: read + outputs: + stage: ${{ steps.role-check.outputs.skipped != 'true' && steps.route.outputs.stage || '' }} + trigger_source: ${{ steps.route.outputs.trigger_source }} + event_payload: ${{ steps.payload.outputs.event_payload }} steps: + - name: Checkout config repository + uses: actions/checkout@v6 + with: + repository: ${{ job.workflow_repository }} + persist-credentials: false + sparse-checkout: config.yaml + sparse-checkout-cone-mode: false + - name: Determine stage id: route env: @@ -49,7 +68,6 @@ jobs: STAGE="" TRIGGER_SOURCE="" - # Helper: check author_association is authorized (OWNER, MEMBER, COLLABORATOR) is_authorized() { case "${COMMENT_AUTHOR_ASSOC}" in OWNER|MEMBER|COLLABORATOR) return 0 ;; @@ -57,13 +75,10 @@ jobs: esac } - # Helper: check if user is the PR/issue author is_issue_author() { [[ "${COMMENT_USER_LOGIN}" == "${ISSUE_USER_LOGIN}" ]] } - # Helper: check if a label is present in a comma-separated list. - # Usage: has_label [label_csv] (defaults to ISSUE_LABELS) has_label() { local needle="$1" local csv="${2:-${ISSUE_LABELS}}" @@ -74,7 +89,6 @@ jobs: return 1 } - # Extract the first word of the comment as the command COMMAND="" if [[ -n "${COMMENT_BODY:-}" ]]; then COMMAND="$(printf '%s\n' "${COMMENT_BODY}" | head -1 | awk '{print $1}')" @@ -120,8 +134,6 @@ jobs: fi ;; *) - # Non-command issue_comment: auto-triage on needs-info issues - # when commenter is a non-bot with association != NONE or is issue author if has_label "needs-info" && ! has_label "feature"; then if [[ "${COMMENT_USER_TYPE}" != "Bot" ]]; then if [[ "${COMMENT_AUTHOR_ASSOC}" != "NONE" ]] || is_issue_author; then @@ -160,16 +172,9 @@ jobs: if [[ "${EVENT_ACTION}" == "submitted" && "${REVIEW_STATE}" == "changes_requested" ]]; then REVIEW_BOT="${ORG_NAME}-review[bot]" if [[ "${REVIEW_USER_LOGIN}" == "${REVIEW_BOT}" ]]; then - # Block fork PRs — fail closed if we cannot determine if [[ -n "${PR_HEAD_REPO}" && -n "${PR_BASE_REPO}" ]]; then if [[ "${PR_HEAD_REPO}" == "${PR_BASE_REPO}" ]]; then - # Check no-fix label (use PR_LABELS — issue.labels is empty - # on pull_request_review events) if ! has_label "fullsend-no-fix" "${PR_LABELS}"; then - # Human PRs require the fullsend-fix label to auto-trigger - # the fix agent. The /fs-fix slash command intentionally - # bypasses this gate — authorized users can always trigger - # fix manually regardless of labels. if [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]] || has_label "fullsend-fix" "${PR_LABELS}"; then STAGE="fix" TRIGGER_SOURCE="${REVIEW_USER_LOGIN}" @@ -192,45 +197,6 @@ jobs: echo "stage=${STAGE}" >> "${GITHUB_OUTPUT}" echo "trigger_source=${TRIGGER_SOURCE}" >> "${GITHUB_OUTPUT}" - - name: Mint dispatch token via OIDC - if: steps.route.outputs.stage != '' - id: oidc-mint - env: - MINT_URL: ${{ vars.FULLSEND_MINT_URL }} - run: | - set -euo pipefail - if [[ -z "$MINT_URL" ]]; then - echo "::error::FULLSEND_MINT_URL org variable is not set" - exit 1 - fi - echo "::add-mask::$MINT_URL" - OIDC_TOKEN=$(curl -sSf --retry 3 --retry-delay 2 --retry-all-errors \ - -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=fullsend-mint" | jq -r '.value') - if [[ -z "$OIDC_TOKEN" || "$OIDC_TOKEN" == "null" ]]; then - echo "::error::Failed to obtain OIDC token" - exit 1 - fi - echo "::add-mask::$OIDC_TOKEN" - TOKEN=$(curl -sSf --retry 5 --retry-delay 5 --retry-all-errors \ - -H "Authorization: Bearer $OIDC_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"role":"fullsend","repos":[".fullsend"]}' \ - "${MINT_URL}/v1/token" | jq -r '.token') - if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then - echo "::error::Token mint returned no token" - exit 1 - fi - echo "::add-mask::$TOKEN" - echo "token=$TOKEN" >> "$GITHUB_OUTPUT" - - - name: Checkout repository - if: steps.route.outputs.stage != '' - uses: actions/checkout@v6 - with: - repository: ${{ job.workflow_repository }} - token: ${{ steps.oidc-mint.outputs.token }} - - name: Validate routed stage if: steps.route.outputs.stage != '' env: @@ -271,7 +237,7 @@ jobs: set -euo pipefail STAGE_ROLE="$STAGE" case "$STAGE" in - code) STAGE_ROLE="coder" ;; + code|fix) STAGE_ROLE="coder" ;; retro|prioritize) STAGE_ROLE="fullsend" ;; esac @@ -285,105 +251,157 @@ jobs: - name: Block fork PRs for fix stage if: steps.route.outputs.stage == 'fix' && steps.role-check.outputs.skipped != 'true' && github.event.issue.pull_request env: - GH_TOKEN: ${{ steps.oidc-mint.outputs.token }} + GH_TOKEN: ${{ github.token }} SOURCE_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number }} run: | set -euo pipefail - HEAD_REPO=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER" --jq '.head.repo.full_name' 2>/dev/null || true) - BASE_REPO=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER" --jq '.base.repo.full_name' 2>/dev/null || true) - if [[ -z "$HEAD_REPO" || -z "$BASE_REPO" ]]; then - echo "::error::Cannot determine fork status — blocking fix agent (fail-closed)" + REPOS=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER" \ + --jq '[.head.repo.full_name, .base.repo.full_name] | @tsv' 2>/dev/null) || { + echo "::error::Could not determine PR repos — blocking fix for safety" exit 1 - fi + } + HEAD_REPO=$(printf '%s' "$REPOS" | cut -f1) + BASE_REPO=$(printf '%s' "$REPOS" | cut -f2) if [[ "$HEAD_REPO" != "$BASE_REPO" ]]; then echo "::error::Fork PR detected (head=$HEAD_REPO, base=$BASE_REPO) — fix agent blocked" exit 1 fi - - name: Find and trigger agent workflows for stage + - name: Build event payload + id: payload if: steps.route.outputs.stage != '' && steps.role-check.outputs.skipped != 'true' - env: - GH_TOKEN: ${{ steps.oidc-mint.outputs.token }} - STAGE: ${{ steps.route.outputs.stage }} - EVENT_TYPE: ${{ github.event_name }} - SOURCE_REPO: ${{ github.repository }} - TRIGGER_SOURCE: ${{ steps.route.outputs.trigger_source }} - DISPATCH_REPO: ${{ job.workflow_repository }} run: | set -euo pipefail - - # Build a minimal event payload with only the fields agent workflows need. - # Read from $GITHUB_EVENT_PATH (on-disk JSON) to avoid expression-context - # injection risks from toJSON(github.event). EVENT_PAYLOAD=$(jq -c '{ issue: (.issue // null | if . then {number, html_url} else null end), pull_request: (.pull_request // null | if . then {number, html_url, head: {ref: .head.ref, repo: {full_name: .head.repo.full_name}}, base: {ref: .base.ref, repo: {full_name: .base.repo.full_name}}} else null end), comment: (.comment // null | if . then {body: .body[:4096]} else null end) - }' "$GITHUB_EVENT_PATH") - - echo "Scanning for workflows with stage: $STAGE" - - dispatched=0 - dispatched_workflows=() - scanned=0 - skipped=0 - - for workflow in .github/workflows/*.yml .github/workflows/*.yaml; do - [[ -f "$workflow" ]] || continue - - scanned=$((scanned + 1)) - workflow_name=$(basename "$workflow") - - if [[ "$workflow_name" == "dispatch.yml" || "$workflow_name" == "dispatch.yaml" ]]; then - echo "Skipped $workflow_name (self-dispatch guard)" - skipped=$((skipped + 1)) - continue - fi - - workflow_stage=$(grep -E '^# fullsend-stage:' "$workflow" | head -1 | sed -n 's/^# fullsend-stage: *\([a-z][a-z0-9_-]*\).*/\1/p' || true) - - if [[ -z "$workflow_stage" ]]; then - echo "Skipped $workflow_name (no stage marker)" - skipped=$((skipped + 1)) - continue - fi - - if [[ "$workflow_stage" != "$STAGE" ]]; then - echo "Skipped $workflow_name (stage=$workflow_stage, wanted=$STAGE)" - skipped=$((skipped + 1)) - continue - fi - - echo "Triggering $workflow_name for stage $STAGE" - - DISPATCH_ARGS=( - --repo "$DISPATCH_REPO" - -f event_type="$EVENT_TYPE" - -f source_repo="$SOURCE_REPO" - -f event_payload="$EVENT_PAYLOAD" - ) - - if [[ -n "${TRIGGER_SOURCE:-}" ]] && [[ "$STAGE" == "fix" ]]; then - DISPATCH_ARGS+=(-f trigger_source="$TRIGGER_SOURCE") - fi - - if ! output=$(gh workflow run "$workflow_name" "${DISPATCH_ARGS[@]}" 2>&1); then - echo "::error::Failed to dispatch $workflow_name: $output" - exit 1 - fi - echo "::notice::Dispatched ${workflow_name} → ${output}" - dispatched=$((dispatched + 1)) - dispatched_workflows+=("$workflow_name") - done - - echo "Scanned $scanned workflow(s), skipped $skipped, dispatched $dispatched" - - if [[ $dispatched -eq 0 ]]; then - echo "::error::No workflows found for stage: $STAGE" + }' "$GITHUB_EVENT_PATH") || { + echo "::error::Failed to extract event payload from GITHUB_EVENT_PATH" + exit 1 + } + if [[ -z "${EVENT_PAYLOAD}" || "${EVENT_PAYLOAD}" == "null" ]]; then + echo "::error::Event payload is empty after extraction" exit 1 fi - - echo "Successfully dispatched $dispatched workflow(s) for stage $STAGE: ${dispatched_workflows[*]}" + DELIM="PAYLOAD_$(openssl rand -hex 8)" + { + echo "event_payload<<${DELIM}" + echo "${EVENT_PAYLOAD}" + echo "${DELIM}" + } >> "${GITHUB_OUTPUT}" + + triage: + name: Triage + needs: route + if: needs.route.outputs.stage == 'triage' + permissions: + contents: read + id-token: write + uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0 + with: + event_type: ${{ github.event_name }} + source_repo: ${{ github.repository }} + event_payload: ${{ needs.route.outputs.event_payload }} + install_mode: per-org + mint_url: ${{ vars.FULLSEND_MINT_URL }} + gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + + code: + name: Code + needs: route + if: needs.route.outputs.stage == 'code' + permissions: + contents: read + id-token: write + uses: fullsend-ai/fullsend/.github/workflows/reusable-code.yml@v0 + with: + event_type: ${{ github.event_name }} + source_repo: ${{ github.repository }} + event_payload: ${{ needs.route.outputs.event_payload }} + install_mode: per-org + mint_url: ${{ vars.FULLSEND_MINT_URL }} + gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + + review: + name: Review + needs: route + if: needs.route.outputs.stage == 'review' + permissions: + contents: read + id-token: write + uses: fullsend-ai/fullsend/.github/workflows/reusable-review.yml@v0 + with: + event_type: ${{ github.event_name }} + source_repo: ${{ github.repository }} + event_payload: ${{ needs.route.outputs.event_payload }} + install_mode: per-org + mint_url: ${{ vars.FULLSEND_MINT_URL }} + gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + + fix: + name: Fix + needs: route + if: needs.route.outputs.stage == 'fix' + permissions: + contents: read + id-token: write + uses: fullsend-ai/fullsend/.github/workflows/reusable-fix.yml@v0 + with: + event_type: ${{ github.event_name }} + source_repo: ${{ github.repository }} + event_payload: ${{ needs.route.outputs.event_payload }} + trigger_source: ${{ needs.route.outputs.trigger_source }} + install_mode: per-org + mint_url: ${{ vars.FULLSEND_MINT_URL }} + gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + + retro: + name: Retro + needs: route + if: needs.route.outputs.stage == 'retro' + permissions: + contents: read + id-token: write + uses: fullsend-ai/fullsend/.github/workflows/reusable-retro.yml@v0 + with: + event_type: ${{ github.event_name }} + source_repo: ${{ github.repository }} + event_payload: ${{ needs.route.outputs.event_payload }} + install_mode: per-org + mint_url: ${{ vars.FULLSEND_MINT_URL }} + gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + + prioritize: + name: Prioritize + needs: route + if: needs.route.outputs.stage == 'prioritize' + uses: ./.github/workflows/prioritize.yml + permissions: + contents: read + id-token: write + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + with: + event_type: ${{ github.event_name }} + source_repo: ${{ github.repository }} + event_payload: ${{ needs.route.outputs.event_payload }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/fix.yml b/internal/scaffold/fullsend-repo/.github/workflows/fix.yml deleted file mode 100644 index 6a6dd5766..000000000 --- a/internal/scaffold/fullsend-repo/.github/workflows/fix.yml +++ /dev/null @@ -1,64 +0,0 @@ -# fullsend-stage: fix -# lint-workflow-size: max-lines=310 -name: Fix - -permissions: - actions: write - contents: write - id-token: write - issues: write - packages: read - pull-requests: write - -on: - workflow_dispatch: - inputs: - event_type: - required: true - type: string - source_repo: - required: true - type: string - event_payload: - required: true - type: string - trigger_source: - required: true - type: string - description: 'GitHub username that triggered the fix; bot accounts end with [bot]' - pr_number: - required: false - type: string - description: "PR number (workflow_dispatch manual fallback)" - instruction: - required: false - type: string - description: "Human instruction (workflow_dispatch manual fallback)" - -# Single concurrency group per PR. A human /fix cancels any running fix -# (bot or human) so the human's instruction takes immediate effect. -# Bot-triggered runs also cancel previous bot runs on the same PR. -concurrency: - group: >- - fullsend-fix-${{ inputs.source_repo }}-${{ - fromJSON(inputs.event_payload).pull_request.number - || fromJSON(inputs.event_payload).issue.number - || inputs.pr_number - }} - cancel-in-progress: true - -jobs: - fix: - uses: fullsend-ai/fullsend/.github/workflows/reusable-fix.yml@v0 - with: - event_type: ${{ inputs.event_type }} - source_repo: ${{ inputs.source_repo }} - event_payload: ${{ inputs.event_payload }} - trigger_source: ${{ inputs.trigger_source }} - pr_number: ${{ inputs.pr_number || '' }} - instruction: ${{ inputs.instruction || '' }} - mint_url: ${{ vars.FULLSEND_MINT_URL }} - gcp_region: ${{ vars.FULLSEND_GCP_REGION }} - secrets: - FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} - FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml b/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml index 6fde95181..bf96e864f 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml @@ -1,7 +1,22 @@ -# fullsend-stage: prioritize name: Prioritize on: + workflow_call: + inputs: + event_type: + required: true + type: string + source_repo: + required: true + type: string + event_payload: + required: true + type: string + secrets: + FULLSEND_GCP_WIF_PROVIDER: + required: true + FULLSEND_GCP_PROJECT_ID: + required: true workflow_dispatch: inputs: event_type: @@ -40,20 +55,31 @@ jobs: ref: v0 path: .defaults sparse-checkout: | - internal/scaffold/fullsend-repo/ + internal/scaffold/fullsend-repo/agents/ + internal/scaffold/fullsend-repo/skills/ + internal/scaffold/fullsend-repo/schemas/ + internal/scaffold/fullsend-repo/harness/ + internal/scaffold/fullsend-repo/plugins/ + internal/scaffold/fullsend-repo/policies/ + internal/scaffold/fullsend-repo/scripts/ + internal/scaffold/fullsend-repo/env/ + .github/scripts/ - name: Prepare workspace (upstream defaults + org overrides) run: | set -euo pipefail - SRC=".defaults/internal/scaffold/fullsend-repo" - LAYERED_DIRS="agents skills schemas harness plugins policies scripts env" - for dir in ${LAYERED_DIRS}; do - if [[ -d "${SRC}/${dir}" ]]; then + for dir in agents skills schemas harness plugins policies scripts env; do + src=".defaults/internal/scaffold/fullsend-repo/${dir}" + if [[ -d "${src}" ]]; then mkdir -p "${dir}" - cp -r "${SRC}/${dir}/." "${dir}/" + cp -r "${src}/." "${dir}/" fi done - for dir in ${LAYERED_DIRS}; do + if [[ -d ".defaults/.github/scripts" ]]; then + mkdir -p .github/scripts + cp -r ".defaults/.github/scripts/." .github/scripts/ + fi + for dir in agents skills schemas harness plugins policies scripts env; do if [[ -d "customized/${dir}" ]]; then find "customized/${dir}" -type f ! -name '.gitkeep' -print0 \ | while IFS= read -r -d '' f; do @@ -63,8 +89,6 @@ jobs: done fi done - mkdir -p .github/scripts - cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh rm -rf .defaults - name: Validate enrollment and extract repo metadata diff --git a/internal/scaffold/fullsend-repo/.github/workflows/retro.yml b/internal/scaffold/fullsend-repo/.github/workflows/retro.yml deleted file mode 100644 index ffdc88e02..000000000 --- a/internal/scaffold/fullsend-repo/.github/workflows/retro.yml +++ /dev/null @@ -1,38 +0,0 @@ -# fullsend-stage: retro -name: Retro - -permissions: - actions: write - contents: read - id-token: write - issues: write - -on: - workflow_dispatch: - inputs: - event_type: - required: true - type: string - source_repo: - required: true - type: string - event_payload: - required: true - type: string - -concurrency: - group: fullsend-retro-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - -jobs: - retro: - uses: fullsend-ai/fullsend/.github/workflows/reusable-retro.yml@v0 - with: - event_type: ${{ inputs.event_type }} - source_repo: ${{ inputs.source_repo }} - event_payload: ${{ inputs.event_payload }} - mint_url: ${{ vars.FULLSEND_MINT_URL }} - gcp_region: ${{ vars.FULLSEND_GCP_REGION }} - secrets: - FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} - FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml deleted file mode 100644 index e097af443..000000000 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ /dev/null @@ -1,39 +0,0 @@ -# fullsend-stage: review -name: Review - -permissions: - actions: write - contents: read - id-token: write - issues: write - pull-requests: write - -on: - workflow_dispatch: - inputs: - event_type: - required: true - type: string - source_repo: - required: true - type: string - event_payload: - required: true - type: string - -concurrency: - group: fullsend-review-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - -jobs: - review: - uses: fullsend-ai/fullsend/.github/workflows/reusable-review.yml@v0 - with: - event_type: ${{ inputs.event_type }} - source_repo: ${{ inputs.source_repo }} - event_payload: ${{ inputs.event_payload }} - mint_url: ${{ vars.FULLSEND_MINT_URL }} - gcp_region: ${{ vars.FULLSEND_GCP_REGION }} - secrets: - FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} - FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml deleted file mode 100644 index a81b3d154..000000000 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ /dev/null @@ -1,38 +0,0 @@ -# fullsend-stage: triage -name: Triage - -permissions: - actions: write - contents: read - id-token: write - issues: write - -on: - workflow_dispatch: - inputs: - event_type: - required: true - type: string - source_repo: - required: true - type: string - event_payload: - required: true - type: string - -concurrency: - group: fullsend-triage-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - -jobs: - triage: - uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0 - with: - event_type: ${{ inputs.event_type }} - source_repo: ${{ inputs.source_repo }} - event_payload: ${{ inputs.event_payload }} - mint_url: ${{ vars.FULLSEND_MINT_URL }} - gcp_region: ${{ vars.FULLSEND_GCP_REGION }} - secrets: - FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} - FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} diff --git a/internal/scaffold/fullsend-repo/templates/shim-workflow-call.yaml b/internal/scaffold/fullsend-repo/templates/shim-workflow-call.yaml index 4ddfb66c3..57d6c42e9 100644 --- a/internal/scaffold/fullsend-repo/templates/shim-workflow-call.yaml +++ b/internal/scaffold/fullsend-repo/templates/shim-workflow-call.yaml @@ -42,6 +42,7 @@ jobs: github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot' uses: __ORG__/.fullsend/.github/workflows/dispatch.yml@main + secrets: inherit with: event_action: ${{ github.event.action }} diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 4d35374b2..81da64b4b 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -11,7 +11,7 @@ import ( var content embed.FS // FullsendRepoFile returns the content of a file from the fullsend-repo scaffold. -// The path is relative to the fullsend-repo root (e.g., ".github/workflows/triage.yml"). +// The path is relative to the fullsend-repo root (e.g., ".github/workflows/dispatch.yml"). func FullsendRepoFile(path string) ([]byte, error) { return content.ReadFile("fullsend-repo/" + path) } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 2bf45dbe8..1fffc8176 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -52,10 +52,6 @@ func TestFileModeMatchesFilesystem(t *testing.T) { func TestFullsendRepoFilesExist(t *testing.T) { expected := []string{ ".github/workflows/dispatch.yml", - ".github/workflows/triage.yml", - ".github/workflows/code.yml", - ".github/workflows/review.yml", - ".github/workflows/fix.yml", ".github/workflows/repo-maintenance.yml", ".github/actions/setup-gcp/action.yml", ".github/actions/validate-enrollment/action.yml", @@ -115,7 +111,7 @@ func TestShimWorkflowCallTemplateContent(t *testing.T) { assert.Contains(t, s, "event_action:") assert.Contains(t, s, "id-token: write") assert.Contains(t, s, "__ORG__/.fullsend/.github/workflows/dispatch.yml@main") - // Dispatch concurrency group (no cancel — thin callers handle per-stage cancellation) + // Dispatch concurrency group (no cancel — reusable workflows handle per-stage cancellation) assert.Contains(t, s, "fullsend-dispatch-${{") assert.Contains(t, s, "cancel-in-progress: false") // Event triggers @@ -222,41 +218,43 @@ func TestDispatchWorkflowContent(t *testing.T) { // Kill switch and role check assert.Contains(t, s, "kill_switch") assert.Contains(t, s, "defaults.roles") - // Stage output + // Stage output and synchronous workflow_call dispatch assert.Contains(t, s, "steps.route.outputs.stage") assert.Contains(t, s, "trigger_source") - // Fan-out (unchanged) - assert.Contains(t, s, "# fullsend-stage:") - assert.Contains(t, s, "gh workflow run") + assert.Contains(t, s, "needs.route.outputs.stage == 'triage'") + assert.Contains(t, s, "reusable-triage.yml@v0") + assert.Contains(t, s, "reusable-code.yml@v0") + assert.Contains(t, s, "reusable-review.yml@v0") + assert.Contains(t, s, "reusable-fix.yml@v0") + assert.Contains(t, s, "reusable-retro.yml@v0") + assert.Contains(t, s, "./.github/workflows/prioritize.yml") + assert.Contains(t, s, "install_mode: per-org") assert.Contains(t, s, "permissions: {}") - assert.Contains(t, s, "permissions:") - assert.Contains(t, s, "actions: write") - assert.Contains(t, s, "contents: read") - assert.Contains(t, s, "id-token: write") + assert.Contains(t, s, "sparse-checkout: config.yaml") + assert.Contains(t, s, "repository: ${{ job.workflow_repository }}") + assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER") + assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID") assert.Contains(t, s, "set -euo pipefail") - assert.Contains(t, s, "dispatched=0") - assert.Contains(t, s, "No workflows found for stage") - assert.Contains(t, s, "|| true") assert.Contains(t, s, "Invalid stage name") assert.Contains(t, s, `^[a-z][a-z0-9_-]*$`) - assert.Contains(t, s, "dispatch.yml") - assert.Contains(t, s, "self-dispatch guard") - assert.Contains(t, s, "Scanned") - assert.Contains(t, s, "skipped") - // Verify OIDC mint is the sole token path assert.Contains(t, s, "FULLSEND_MINT_URL") - assert.Contains(t, s, "oidc-mint") - assert.Contains(t, s, "/v1/token") - assert.Contains(t, s, "fullsend-mint") - assert.Contains(t, s, "job.workflow_repository") - // Verify both OIDC token and minted token are masked - assert.Contains(t, s, "::add-mask::$OIDC_TOKEN") - assert.Contains(t, s, "::add-mask::$TOKEN") + assert.NotContains(t, s, "# fullsend-stage:") + assert.NotContains(t, s, "gh workflow run") + assert.NotContains(t, s, "oidc-mint") + assert.NotContains(t, s, "/v1/token") assert.NotContains(t, s, "create-github-app-token") assert.NotContains(t, s, "FULLSEND_FULLSEND_APP_PRIVATE_KEY") assert.NotContains(t, s, "FULLSEND_FULLSEND_CLIENT_ID") } +func repoRootWorkflow(t *testing.T, name string) string { + t.Helper() + path := filepath.Join("..", "..", ".github", "workflows", name) + content, err := os.ReadFile(path) + require.NoError(t, err, "reading %s", path) + return string(content) +} + func TestWalkFullsendRepo(t *testing.T) { var paths []string err := WalkFullsendRepo(func(path string, content []byte) error { @@ -340,141 +338,58 @@ func TestWalkFullsendRepoAllIncludesEverything(t *testing.T) { } } -func TestTriageWorkflowContent(t *testing.T) { - content, err := FullsendRepoFile(".github/workflows/triage.yml") - require.NoError(t, err) - s := string(content) - assert.Contains(t, s, "# fullsend-stage: triage") - assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "event_type") - assert.Contains(t, s, "source_repo") - assert.Contains(t, s, "event_payload") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0") - assert.Contains(t, s, "FULLSEND_MINT_URL") - assert.NotContains(t, s, "secrets: inherit") - assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") - assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") +func TestReusableTriageWorkflowConcurrency(t *testing.T) { + s := repoRootWorkflow(t, "reusable-triage.yml") assert.Contains(t, s, "concurrency:") - assert.Contains(t, s, "fullsend-triage-") + assert.Contains(t, s, "fullsend-triage-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }}") assert.Contains(t, s, "cancel-in-progress: true") - // Permissions required by the reusable workflow - assert.Contains(t, s, "permissions:") - assert.Contains(t, s, "actions: write") - assert.Contains(t, s, "id-token: write") - assert.Contains(t, s, "issues: write") - assert.Contains(t, s, "contents: read") } -func TestCodeAgentContent(t *testing.T) { - content, err := FullsendRepoFile("agents/code.md") - require.NoError(t, err) - s := string(content) - assert.Contains(t, s, "code") - assert.Contains(t, s, "disallowedTools") - assert.Contains(t, s, "code-implementation") +func TestReusableCodeWorkflowConcurrency(t *testing.T) { + s := repoRootWorkflow(t, "reusable-code.yml") + assert.Contains(t, s, "fullsend-code-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }}") + assert.Contains(t, s, "cancel-in-progress: true") } -func TestCodeImplementationSkillAPIContractGuidance(t *testing.T) { - content, err := FullsendRepoFile("skills/code-implementation/SKILL.md") - require.NoError(t, err) - s := string(content) - assert.Contains(t, s, "Verify API contracts per code path") - assert.Contains(t, s, "or changes a parameter sent to an external API") +func TestReusableReviewWorkflowConcurrency(t *testing.T) { + s := repoRootWorkflow(t, "reusable-review.yml") + assert.Contains(t, s, "fullsend-review-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}") + assert.Contains(t, s, "cancel-in-progress: true") } -func TestCodeWorkflowContent(t *testing.T) { - content, err := FullsendRepoFile(".github/workflows/code.yml") - require.NoError(t, err) - s := string(content) - assert.Contains(t, s, "# fullsend-stage: code") - assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-code.yml@v0") - assert.Contains(t, s, "FULLSEND_MINT_URL") - assert.NotContains(t, s, "secrets: inherit") - assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") - assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") - assert.NotContains(t, s, "GCP_WIF_SA_EMAIL") - assert.Contains(t, s, "concurrency:") - assert.Contains(t, s, "fullsend-code-") +func TestReusableFixWorkflowConcurrency(t *testing.T) { + s := repoRootWorkflow(t, "reusable-fix.yml") + assert.Contains(t, s, "fullsend-fix-${{ inputs.source_repo }}-${{") + assert.Contains(t, s, "inputs.pr_number") assert.Contains(t, s, "cancel-in-progress: true") - // Permissions required by the reusable workflow - assert.Contains(t, s, "permissions:") - assert.Contains(t, s, "actions: write") - assert.Contains(t, s, "contents: write") - assert.Contains(t, s, "id-token: write") - assert.Contains(t, s, "issues: write") - assert.Contains(t, s, "packages: read") - assert.Contains(t, s, "pull-requests: write") } -func TestReviewWorkflowContent(t *testing.T) { - content, err := FullsendRepoFile(".github/workflows/review.yml") - require.NoError(t, err) - s := string(content) - assert.Contains(t, s, "# fullsend-stage: review") - assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-review.yml@v0") - assert.Contains(t, s, "FULLSEND_MINT_URL") - assert.NotContains(t, s, "secrets: inherit") - assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") - assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") - assert.Contains(t, s, "concurrency:") - assert.Contains(t, s, "fullsend-review-") +func TestReusableRetroWorkflowConcurrency(t *testing.T) { + s := repoRootWorkflow(t, "reusable-retro.yml") + assert.Contains(t, s, "fullsend-retro-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}") assert.Contains(t, s, "cancel-in-progress: true") - // Permissions required by the reusable workflow - assert.Contains(t, s, "permissions:") - assert.Contains(t, s, "actions: write") - assert.Contains(t, s, "contents: read") - assert.Contains(t, s, "id-token: write") - assert.Contains(t, s, "issues: write") - assert.Contains(t, s, "pull-requests: write") } -func TestFixWorkflowContent(t *testing.T) { - content, err := FullsendRepoFile(".github/workflows/fix.yml") - require.NoError(t, err) - s := string(content) - assert.Contains(t, s, "# fullsend-stage: fix") - assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "trigger_source") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-fix.yml@v0") - assert.Contains(t, s, "FULLSEND_MINT_URL") - assert.NotContains(t, s, "secrets: inherit") - assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") - assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") - assert.Contains(t, s, "concurrency:") - assert.Contains(t, s, "fullsend-fix-") - assert.Contains(t, s, "cancel-in-progress: true") - // Permissions required by the reusable workflow - assert.Contains(t, s, "permissions:") - assert.Contains(t, s, "actions: write") - assert.Contains(t, s, "contents: write") - assert.Contains(t, s, "id-token: write") - assert.Contains(t, s, "issues: write") - assert.Contains(t, s, "packages: read") - assert.Contains(t, s, "pull-requests: write") +func TestThinStageWorkflowsRemovedFromScaffold(t *testing.T) { + for _, path := range []string{ + ".github/workflows/triage.yml", + ".github/workflows/code.yml", + ".github/workflows/review.yml", + ".github/workflows/fix.yml", + ".github/workflows/retro.yml", + } { + _, err := FullsendRepoFile(path) + assert.Error(t, err, "expected %s to be removed from scaffold", path) + } } -func TestRetroWorkflowContent(t *testing.T) { - content, err := FullsendRepoFile(".github/workflows/retro.yml") +func TestCodeAgentContent(t *testing.T) { + content, err := FullsendRepoFile("agents/code.md") require.NoError(t, err) s := string(content) - assert.Contains(t, s, "# fullsend-stage: retro") - assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-retro.yml@v0") - assert.Contains(t, s, "FULLSEND_MINT_URL") - assert.NotContains(t, s, "secrets: inherit") - assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") - assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") - assert.Contains(t, s, "concurrency:") - assert.Contains(t, s, "fullsend-retro-") - assert.Contains(t, s, "cancel-in-progress: true") - // Permissions required by the reusable workflow - assert.Contains(t, s, "permissions:") - assert.Contains(t, s, "actions: write") - assert.Contains(t, s, "contents: read") - assert.Contains(t, s, "id-token: write") - assert.Contains(t, s, "issues: write") + assert.Contains(t, s, "code") + assert.Contains(t, s, "disallowedTools") + assert.Contains(t, s, "code-implementation") } func TestSetupGcpActionContent(t *testing.T) { @@ -702,15 +617,13 @@ func TestPrioritizeWorkflowContent(t *testing.T) { content, err := FullsendRepoFile(".github/workflows/prioritize.yml") require.NoError(t, err) s := string(content) - assert.Contains(t, s, "# fullsend-stage: prioritize") + assert.Contains(t, s, "workflow_call:") assert.Contains(t, s, "workflow_dispatch") assert.Contains(t, s, "event_type") assert.Contains(t, s, "source_repo") assert.Contains(t, s, "event_payload") assert.Contains(t, s, "FULLSEND_PROJECT_NUMBER") assert.Contains(t, s, "setup-agent-env.sh") - assert.Contains(t, s, `cp "${SRC}/.github/scripts/setup-agent-env.sh"`) - assert.NotContains(t, s, ".defaults/.github/scripts") assert.Contains(t, s, "agent: prioritize") assert.Contains(t, s, "concurrency:") assert.Contains(t, s, "fullsend-prioritize") From 2b0cc380deb73d8dfea1bee1cf795d56c6656cf4 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 31 May 2026 12:46:28 +0300 Subject: [PATCH 2/3] docs: align skills and architecture with ADR 41 dispatch Update architecture.md and agent skills for synchronous workflow_call: finding-agent-runs and retro-analysis trace runs on the enrolled-repo shim (not separate .fullsend dispatch runs). Restore code-implementation API contract checklist dropped during rebase. Co-authored-by: Cursor Signed-off-by: Barak Korren --- docs/architecture.md | 19 ++---- .../skills/finding-agent-runs/SKILL.md | 61 ++++++++----------- .../skills/retro-analysis/SKILL.md | 51 +++++++--------- 3 files changed, 53 insertions(+), 78 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6014b6dd6..598f16993 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,12 +39,11 @@ Infrastructure platform choice and configuration are specified in the adopting o - Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)). - Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)). -- Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager, not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)). +- Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; dispatch routes events to upstream reusable workflows via synchronous `workflow_call` jobs. Each reusable workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager, not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md), [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)). - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). - Repo maintenance: a workflow in `.fullsend` (`.github/workflows/repo-maintenance.yml`) reconciles enrollment shims in target repos when `config.yaml` changes or on manual dispatch. The CLI's `EnrollmentLayer.Install()` dispatches this workflow via `workflow_dispatch` and monitors it for completion, then reports any enrollment PRs created in target repos. - Installer scaffold: the `WorkflowsLayer` deploys content from an embedded scaffold (`internal/scaffold/`), keeping deployable files as real files under version control rather than Go string constants. -- Reusable workflows: agent workflows in `.fullsend` are thin callers (~40-70 lines) that delegate infrastructure logic to upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) via `workflow_call`. Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md)). -- Event-driven stage dispatch: eliminate `workflow_dispatch` + `gh workflow run` fan-out from `dispatch.yml` in favor of synchronous `workflow_call` so the dispatched run stays linked to the caller ([ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)). +- Reusable workflows: `dispatch.yml` in `.fullsend` calls upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) directly via `workflow_call` jobs (no per-stage thin callers). Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md), [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)). **Open questions:** @@ -131,10 +130,6 @@ The mechanism that assigns work to agents and prevents conflicts. Responsible fo The existing design principle is that [the repo is the coordinator](problems/agent-architecture.md#interaction-model-the-repo-as-coordinator) — branch protection, CODEOWNERS, status checks, and GitHub events provide coordination without a central orchestrator. The agent dispatch and coordination layer may be nothing more than the glue that connects GitHub webhooks to agent infrastructure. Or it may need to be more. -**Decided:** - -- Event-driven stage dispatch runs synchronously via `workflow_call` to preserve run correlation in the GitHub Actions UI (see [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)). - **Open questions:** - Is GitHub's event system sufficient, or do we need additional coordination logic (e.g. to prevent two code agents from picking up the same issue)? @@ -180,7 +175,6 @@ Observability is a cross-cutting concern that touches every other component. Eac **Decided:** - JSONL reasoning trace exposure: raw JSONL conversation transcripts are extracted from sandboxes and stored with owner-scoped access. Credential scanning acts as an invariant check on [ADR 0017](ADRs/0017-credential-isolation-for-sandboxed-agents.md)'s isolation model. Agents handling data from protected sources beyond the target repo can opt in to JSONL suppression via configuration ([ADR 0021](ADRs/0021-jsonl-reasoning-trace-exposure.md)). -- Event-driven stage dispatch remains traceable end-to-end in the GitHub Actions UI by using synchronous `workflow_call` dispatch (see [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)). **Open questions:** @@ -518,14 +512,13 @@ GitHub event ──► SHIM WORKFLOW (fullsend.yml in enrolled repo) ╔═══════════════════════════════════════════════════════════════╗ ║ DISPATCH WORKFLOW (.fullsend repo, dispatch.yml) ║ ║ ║ - ║ Mints OIDC token → Cloud Function (token mint) → scoped ║ - ║ GitHub App installation token per agent role. ║ - ║ Dispatches per-role agent workflows (code.yml, triage.yml). ║ + ║ Routes to stage; synchronous workflow_call to upstream ║ + ║ reusable-{stage}.yml (or prioritize.yml in .fullsend). ║ ╚═══════════════════════════════════════════════════════════════╝ │ ▼ ╔═══════════════════════════════════════════════════════════════╗ - ║ AGENT WORKFLOW (.fullsend repo, e.g. code.yml) ║ + ║ AGENT WORKFLOW (fullsend-ai/fullsend reusable-*.yml) ║ ║ ║ ║ Validates source repo is enrolled in config.yaml. ║ ║ Uses scoped GitHub App tokens: ║ @@ -607,7 +600,7 @@ GitHub event ──► SHIM WORKFLOW (fullsend.yml in enrolled repo) | Abstract layer | MVP technology | ADR | |---|---|---| -| Dispatcher | Shim workflow (`fullsend.yml`) in enrolled repo → `workflow_call` to `.fullsend/dispatch.yml` → OIDC mint → per-role agent workflows (thin callers → upstream reusable workflows) | [ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md), [ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md) | +| Dispatcher | Shim workflow (`fullsend.yml`) in enrolled repo → `workflow_call` to `.fullsend/dispatch.yml` → synchronous `workflow_call` to upstream reusable workflows | [ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md), [ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md), [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md) | | Agent runner | GitHub Actions job → `fullsend run` CLI (via `fullsend-ai/fullsend@v0` composite action) | | | Harness store | YAML files in `.fullsend/harness/` (e.g. `code.yaml`, `triage.yaml`) | | | Sandbox | OpenShell with per-agent L7 network policies (endpoint + binary restrictions) | | diff --git a/internal/scaffold/fullsend-repo/skills/finding-agent-runs/SKILL.md b/internal/scaffold/fullsend-repo/skills/finding-agent-runs/SKILL.md index cbbdf7c41..c6f801470 100644 --- a/internal/scaffold/fullsend-repo/skills/finding-agent-runs/SKILL.md +++ b/internal/scaffold/fullsend-repo/skills/finding-agent-runs/SKILL.md @@ -13,13 +13,15 @@ Given an issue or PR, find the fullsend agent workflow runs using `gh` CLI. ## Setup ```bash -ORG=$(echo "${REPO_FULL_NAME:-$(gh repo view --json owner -q .owner.login)}" | cut -d/ -f1) -DISPATCH_REPO="${ORG}/.fullsend" +SOURCE_REPO="${REPO_FULL_NAME:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +ORG=$(echo "${SOURCE_REPO}" | cut -d/ -f1) +CONFIG_REPO="${ORG}/.fullsend" ``` -The shim workflow (`fullsend.yaml`) runs in the source repo on `main`. It -dispatches to `${DISPATCH_REPO}` which runs the agent workflows -(`triage.yml`, `code.yml`, `review.yml`, `retro.yml`). +Per-org installs use synchronous `workflow_call`: the enrolled-repo shim +(`fullsend.yaml`) calls `${CONFIG_REPO}` `dispatch.yml`, which runs agent +stages as jobs in the **same** Actions run on `SOURCE_REPO`. There are no +separate `dispatch.yml` runs in `.fullsend` to look up. ## Issue → Agent Runs @@ -28,15 +30,15 @@ dispatches to `${DISPATCH_REPO}` which runs the agent workflows Triage dispatches from `issue_comment` events (the `/fs-triage` command): ```bash -gh run list --workflow=fullsend.yaml \ +gh run list --repo "${SOURCE_REPO}" --workflow=fullsend.yaml \ --json databaseId,status,conclusion,event,createdAt \ -q '.[] | select(.event == "issue_comment")' ``` -Match by timestamp against the `/fs-triage` comment (`gh issue view --json comments`), then confirm `dispatch-triage` succeeded: +Match by timestamp against the `/fs-triage` comment (`gh issue view --json comments`), then confirm the **Triage** stage job succeeded: ```bash -gh run view --json jobs \ +gh run view --repo "${SOURCE_REPO}" --json jobs \ -q '.jobs[] | "\(.name) \(.status)/\(.conclusion)"' ``` @@ -45,47 +47,35 @@ gh run view --json jobs \ Code dispatches from `issues` events when `ready-to-code` is applied: ```bash -gh run list --workflow=fullsend.yaml \ +gh run list --repo "${SOURCE_REPO}" --workflow=fullsend.yaml \ --json databaseId,status,conclusion,event,createdAt \ -q '.[] | select(.event == "issues")' ``` -Confirm `dispatch-code completed/success` in the jobs list. - -### Find the actual agent run - -Match by timestamp in the dispatch repo (runs start within seconds): - -```bash -gh run list --repo "${DISPATCH_REPO}" --workflow=triage.yml --limit 5 \ - --json databaseId,status,conclusion,createdAt - -gh run list --repo "${DISPATCH_REPO}" --workflow=code.yml --limit 5 \ - --json databaseId,status,conclusion,createdAt -``` +Confirm the **Code** job completed successfully in that run's job list. ## PR → Agent Runs ### Code agent run The PR branch follows `agent/{issue}-{slug}`. Extract the issue number and -use the issue recipe above to find the code dispatch. +use the issue recipe above to find the code dispatch run on `SOURCE_REPO`. ### Review dispatch Review dispatches from `pull_request_target` events. Match by `headBranch`: ```bash -gh run list --workflow=fullsend.yaml \ +gh run list --repo "${SOURCE_REPO}" --workflow=fullsend.yaml \ --json databaseId,status,conclusion,event,headBranch,createdAt \ -q '.[] | select(.event == "pull_request_target")' ``` -Confirm `dispatch-review completed/success`, then find the run: +Confirm the **Review** job completed successfully: ```bash -gh run list --repo "${DISPATCH_REPO}" --workflow=review.yml --limit 5 \ - --json databaseId,status,conclusion,createdAt +gh run view --repo "${SOURCE_REPO}" --json jobs \ + -q '.jobs[] | "\(.name) \(.status)/\(.conclusion)"' ``` ### Retro dispatch @@ -94,29 +84,26 @@ Retro dispatches from `pull_request_target` (on PR close) and from `issue_comment` events (the `/fs-retro` command): ```bash -gh run list --workflow=fullsend.yaml \ +gh run list --repo "${SOURCE_REPO}" --workflow=fullsend.yaml \ --json databaseId,status,conclusion,event,createdAt \ -q '.[] | select(.event == "pull_request_target" or .event == "issue_comment")' ``` -Find the actual retro agent run: - -```bash -gh run list --repo "${DISPATCH_REPO}" --workflow=retro.yml --limit 5 \ - --json databaseId,status,conclusion,createdAt -``` +Confirm the **Retro** job completed successfully in the same run. ## Reference ### Logs and artifacts +Use the shim run ID on `SOURCE_REPO` (not `${CONFIG_REPO}`): + ```bash # Search logs for errors -gh run view --repo "${DISPATCH_REPO}" --log 2>&1 \ +gh run view --repo "${SOURCE_REPO}" --log 2>&1 \ | grep -i "error\|fail\|exit code" -# Download session artifact -gh run download --repo "${DISPATCH_REPO}" +# Download session artifact (uploaded by the stage job in this run) +gh run download --repo "${SOURCE_REPO}" ``` ### Common failure signatures diff --git a/internal/scaffold/fullsend-repo/skills/retro-analysis/SKILL.md b/internal/scaffold/fullsend-repo/skills/retro-analysis/SKILL.md index da1f54943..35234d9f6 100644 --- a/internal/scaffold/fullsend-repo/skills/retro-analysis/SKILL.md +++ b/internal/scaffold/fullsend-repo/skills/retro-analysis/SKILL.md @@ -15,67 +15,62 @@ Given the originating PR or issue, reconstruct what agents ran and in what order ### Setup ```bash -ORG=$(echo "$REPO_FULL_NAME" | cut -d/ -f1) -DISPATCH_REPO="${ORG}/.fullsend" +SOURCE_REPO="${REPO_FULL_NAME:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +ORG=$(echo "${SOURCE_REPO}" | cut -d/ -f1) +CONFIG_REPO="${ORG}/.fullsend" ``` +Per-org installs run shim → `dispatch.yml` → stage jobs synchronously in one +Actions run on `SOURCE_REPO`. Stage names in the job list include **Route**, +**Triage**, **Code**, **Review**, **Fix**, **Retro**, and **Prioritize**. + ### From an issue -1. Find triage dispatches (triggered by `/fs-triage` command or `needs-info` label responses): +1. Find shim runs on the source repo (triage/code triggered by issue events or `/fs-triage`): ```bash -gh run list --repo "$REPO_FULL_NAME" --workflow=fullsend.yaml \ +gh run list --repo "$SOURCE_REPO" --workflow=fullsend.yaml \ --json databaseId,status,conclusion,event,createdAt \ -q '.[] | select(.event == "issue_comment" or .event == "issues")' ``` -2. Find the corresponding agent runs in the dispatch repo: - -```bash -gh run list --repo "$DISPATCH_REPO" --workflow=triage.yml --limit 10 \ - --json databaseId,status,conclusion,createdAt -``` - -3. If the issue reached `ready-to-code`, find code dispatches: +2. Inspect stage jobs inside that run (not separate runs in `.fullsend`): ```bash -gh run list --repo "$DISPATCH_REPO" --workflow=code.yml --limit 10 \ - --json databaseId,status,conclusion,createdAt +gh run view --repo "$SOURCE_REPO" --json jobs \ + -q '.jobs[] | "\(.name) \(.status)/\(.conclusion) \(.startedAt)"' ``` ### From a PR -1. The PR branch follows `agent/{issue}-{slug}`. Extract the issue number to trace the full history. +1. The PR branch follows `agent/{issue}-{slug}`. Extract the issue number to trace the full history on `SOURCE_REPO`. -2. Find review dispatches: +2. Find shim runs for review/fix/retro (match by `headBranch` or timestamp): ```bash -gh run list --repo "$DISPATCH_REPO" --workflow=review.yml --limit 10 \ - --json databaseId,status,conclusion,createdAt -``` - -3. Find fix dispatches (if review requested changes): - -```bash -gh run list --repo "$DISPATCH_REPO" --workflow=fix.yml --limit 10 \ - --json databaseId,status,conclusion,createdAt +gh run list --repo "$SOURCE_REPO" --workflow=fullsend.yaml \ + --json databaseId,status,conclusion,event,headBranch,createdAt \ + -q '.[] | select(.event == "pull_request_target" or .event == "issue_comment")' ``` ### Reading agent logs and artifacts ```bash # View job outcomes -gh run view --repo "$DISPATCH_REPO" --json jobs \ +gh run view --repo "$SOURCE_REPO" --json jobs \ -q '.jobs[] | "\(.name) \(.status)/\(.conclusion)"' # Search logs for errors -gh run view --repo "$DISPATCH_REPO" --log 2>&1 \ +gh run view --repo "$SOURCE_REPO" --log 2>&1 \ | grep -i "error\|fail\|exit code" # Download session artifacts (JSONL traces) -gh run download --repo "$DISPATCH_REPO" +gh run download --repo "$SOURCE_REPO" ``` +Use `${CONFIG_REPO}` only when reading harness/agent definitions under +`.fullsend` (files), not for workflow run IDs. + ## Exploration strategy You have a large amount of context to cover. Use subagents to avoid overflowing your main context window. From e44b70f5471f9471f6925162477dd659ccb25c6f Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 31 May 2026 12:46:50 +0300 Subject: [PATCH 3/3] fix(cli): sync inference org credentials on repo enrollment Provision GCP secrets and region at org scope during install and refresh selected-repository access on admin enable/disable, matching FULLSEND_MINT_URL visibility for workflow_call dispatch from enrolled repos. Co-authored-by: Cursor Signed-off-by: Barak Korren --- internal/cli/admin.go | 69 +++++--- internal/cli/admin_test.go | 34 +++- internal/layers/inference.go | 253 ++++++++++++++++++++++++++---- internal/layers/inference_test.go | 87 +++++++--- 4 files changed, 370 insertions(+), 73 deletions(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 033ef8332..4186f38ef 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1681,7 +1681,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, layers.NewConfigRepoLayer(org, client, emptyCfg, printer, false), layers.NewWorkflowsLayer(org, client, printer, ""), layers.NewSecretsLayer(org, client, nil, printer), - layers.NewInferenceLayer(org, client, nil, printer), + layers.NewInferenceLayer(org, client, nil, nil, printer), dispatchLayer, layers.NewEnrollmentLayer(org, client, nil, nil, printer), ) @@ -1848,7 +1848,7 @@ func buildLayerStack( layers.NewWorkflowsLayer(org, client, printer, user), layers.NewVendorBinaryLayer(org, forge.ConfigRepoName, client, printer, vendorBinary, vendorFn), layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(), - layers.NewInferenceLayer(org, client, inferenceProvider, printer), + layers.NewInferenceLayer(org, client, inferenceProvider, enrolledRepoIDs, printer), dispatchLayer, layers.NewEnrollmentLayer(org, client, enabledRepos, disabledRepos, printer), ) @@ -2256,12 +2256,9 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe } } - // Sync org variable visibility so enrolled repos can read dispatch - // variables like FULLSEND_MINT_URL. Runs even when changed == 0 to - // reconcile a previously failed best-effort sync on re-run. - if cfg.Dispatch.Mode == "oidc-mint" { - syncOrgVariableVisibility(ctx, client, printer, org, cfg, allOrgRepos) - } + // Sync org secret/variable visibility for enrolled repos. Runs even when + // changed == 0 to reconcile a previously failed best-effort sync on re-run. + syncEnrolledRepoCredentialVisibility(ctx, client, printer, org, cfg, allOrgRepos) if changed == 0 { return nil @@ -2281,17 +2278,11 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe // dispatch layer, derived from the gcf provisioner to stay in sync automatically. var dispatchOrgVariableNames = gcf.NewProvisioner(gcf.Config{}, nil).OrgVariableNames() -// syncOrgVariableVisibility updates the "selected" repository list for each -// dispatch org variable so that all currently enrolled repos (plus the config -// repo) can read them. This is best-effort: failures are logged as warnings -// but do not fail the enable command, because the repo-maintenance workflow -// can reconcile this later. -func syncOrgVariableVisibility(ctx context.Context, client forge.Client, printer *ui.Printer, org string, cfg *config.OrgConfig, allOrgRepos []forge.Repository) { - // Collect IDs for all enabled repos. +// enrolledRepoIDsForConfig returns GitHub repo IDs for all enabled repos plus +// the .fullsend config repo (which needs access to org-scoped credentials). +func enrolledRepoIDsForConfig(cfg *config.OrgConfig, allOrgRepos []forge.Repository) []int64 { enrolledRepoIDs := collectEnrolledRepoIDs(allOrgRepos, cfg.EnabledRepos()) - // Ensure the config repo (.fullsend) is included — it needs access - // to dispatch variables for its own workflows. seen := make(map[int64]bool, len(enrolledRepoIDs)) for _, id := range enrolledRepoIDs { seen[id] = true @@ -2302,6 +2293,28 @@ func syncOrgVariableVisibility(ctx context.Context, client forge.Client, printer break } } + return enrolledRepoIDs +} + +// syncEnrolledRepoCredentialVisibility updates org-level dispatch variables and +// inference secrets/variables so only currently enrolled repos (plus .fullsend) +// can read them. Best-effort: failures are warnings, not command errors. +func syncEnrolledRepoCredentialVisibility(ctx context.Context, client forge.Client, printer *ui.Printer, org string, cfg *config.OrgConfig, allOrgRepos []forge.Repository) { + if cfg.Dispatch.Mode == "oidc-mint" { + syncOrgVariableVisibility(ctx, client, printer, org, cfg, allOrgRepos) + } + if cfg.Inference.Provider != "" { + syncInferenceCredentialVisibility(ctx, client, printer, org, cfg, allOrgRepos) + } +} + +// syncOrgVariableVisibility updates the "selected" repository list for each +// dispatch org variable so that all currently enrolled repos (plus the config +// repo) can read them. This is best-effort: failures are logged as warnings +// but do not fail the enable command, because the repo-maintenance workflow +// can reconcile this later. +func syncOrgVariableVisibility(ctx context.Context, client forge.Client, printer *ui.Printer, org string, cfg *config.OrgConfig, allOrgRepos []forge.Repository) { + enrolledRepoIDs := enrolledRepoIDsForConfig(cfg, allOrgRepos) for _, varName := range dispatchOrgVariableNames { exists, checkErr := client.OrgVariableExists(ctx, org, varName) @@ -2323,6 +2336,14 @@ func syncOrgVariableVisibility(ctx context.Context, client forge.Client, printer } } +// syncInferenceCredentialVisibility updates org-level inference secret and +// variable visibility for enrolled repos. Best-effort like dispatch sync. +func syncInferenceCredentialVisibility(ctx context.Context, client forge.Client, printer *ui.Printer, org string, cfg *config.OrgConfig, allOrgRepos []forge.Repository) { + repoIDs := enrolledRepoIDsForConfig(cfg, allOrgRepos) + layer := layers.NewInferenceLayer(org, client, nil, nil, printer) + layer.SyncEnrolledRepoAccess(ctx, repoIDs) +} + // runDisableRepos disables the specified repositories from fullsend enrollment. func runDisableRepos(ctx context.Context, client forge.Client, printer *ui.Printer, org string, repos []string, all bool, yolo bool) error { printer.Banner(Version()) @@ -2416,14 +2437,12 @@ func runDisableRepos(ctx context.Context, client forge.Client, printer *ui.Print return err } - // Sync org variable visibility to revoke access for disabled repos. - if cfg.Dispatch.Mode == "oidc-mint" { - allOrgRepos, listErr := client.ListOrgRepos(ctx, org) - if listErr != nil { - printer.StepWarn(fmt.Sprintf("could not list org repos for variable sync: %v", listErr)) - } else { - syncOrgVariableVisibility(ctx, client, printer, org, cfg, allOrgRepos) - } + // Sync org secret/variable visibility to revoke access for disabled repos. + allOrgRepos, listErr := client.ListOrgRepos(ctx, org) + if listErr != nil { + printer.StepWarn(fmt.Sprintf("could not list org repos for credential sync: %v", listErr)) + } else { + syncEnrolledRepoCredentialVisibility(ctx, client, printer, org, cfg, allOrgRepos) } printer.Blank() diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index a35771e56..f387cb749 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -810,6 +810,38 @@ func TestRunEnableRepos_VariableSyncErrorDoesNotBlockEnable(t *testing.T) { require.NoError(t, err, "enable should succeed even when variable sync fails") } +func TestRunEnableRepos_SyncsInferenceOrgSecretsWhenConfigured(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + "api": true, + }) + cfg.Dispatch.Mode = "oidc-mint" + cfg.Inference.Provider = "vertex" + + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + client.Repos[0].ID = 1 + client.Repos[1].ID = 10 + client.Repos[2].ID = 20 + client.OrgSecrets = map[string]bool{ + "testorg/FULLSEND_GCP_WIF_PROVIDER": true, + "testorg/FULLSEND_GCP_PROJECT_ID": true, + } + client.OrgVariables = map[string]bool{"testorg/FULLSEND_GCP_REGION": true} + client.VariableValues["testorg/.fullsend/FULLSEND_GCP_REGION"] = "global" + client.VariablesExist["testorg/.fullsend/FULLSEND_GCP_REGION"] = true + + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.NoError(t, err) + + // Enabled repos: web-app + api + .fullsend + wantIDs := []int64{10, 20, 1} + assert.Equal(t, wantIDs, client.OrgSecretRepoIDs["testorg/FULLSEND_GCP_WIF_PROVIDER"]) + assert.Equal(t, wantIDs, client.OrgSecretRepoIDs["testorg/FULLSEND_GCP_PROJECT_ID"]) + assert.Equal(t, wantIDs, client.OrgVariableRepoIDs["testorg/FULLSEND_GCP_REGION"]) +} + func TestRunEnableRepos_SkipsVariableSyncWhenVariableNotExists(t *testing.T) { // When the org variable doesn't exist yet (mint not provisioned), // sync should skip gracefully. @@ -1197,7 +1229,7 @@ func TestCheckInstallScopes_SyncWithLayers(t *testing.T) { layers.NewConfigRepoLayer("test-org", nil, emptyCfg, ui.New(&discardWriter{}), false), layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), ""), layers.NewSecretsLayer("test-org", nil, nil, ui.New(&discardWriter{})), - layers.NewInferenceLayer("test-org", nil, nil, ui.New(&discardWriter{})), + layers.NewInferenceLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})), layers.NewOIDCDispatchLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})), layers.NewEnrollmentLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})), layers.NewVendorBinaryLayer("test-org", ".fullsend", nil, ui.New(&discardWriter{}), false, nil), diff --git a/internal/layers/inference.go b/internal/layers/inference.go index 6ac8c4e75..1ea3f7d12 100644 --- a/internal/layers/inference.go +++ b/internal/layers/inference.go @@ -3,29 +3,36 @@ package layers import ( "context" "fmt" + "sort" + "strings" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/inference" + "github.com/fullsend-ai/fullsend/internal/inference/vertex" "github.com/fullsend-ai/fullsend/internal/ui" ) -// InferenceLayer manages inference provider credentials in the .fullsend repo. +// InferenceLayer manages inference provider credentials. GCP secrets and region +// are stored on the .fullsend repo and duplicated at org scope so enrolled repos +// can pass them through workflow_call dispatch (caller secret context). type InferenceLayer struct { - org string - client forge.Client - provider inference.Provider - ui *ui.Printer + org string + client forge.Client + provider inference.Provider + enrolledRepoIDs []int64 + ui *ui.Printer } var _ Layer = (*InferenceLayer)(nil) // NewInferenceLayer creates a new InferenceLayer. -func NewInferenceLayer(org string, client forge.Client, provider inference.Provider, printer *ui.Printer) *InferenceLayer { +func NewInferenceLayer(org string, client forge.Client, provider inference.Provider, enrolledRepoIDs []int64, printer *ui.Printer) *InferenceLayer { return &InferenceLayer{ - org: org, - client: client, - provider: provider, - ui: printer, + org: org, + client: client, + provider: provider, + enrolledRepoIDs: enrolledRepoIDs, + ui: printer, } } @@ -38,16 +45,17 @@ func (l *InferenceLayer) Name() string { func (l *InferenceLayer) RequiredScopes(op Operation) []string { switch op { case OpInstall, OpAnalyze: + if l.provider != nil { + return []string{"repo", "admin:org"} + } return []string{"repo"} default: return nil } } -// Install provisions inference credentials and stores them as repo secrets. -// Secrets are written unconditionally because the GitHub Secrets API does not -// expose values, so we cannot detect stale entries. The API PUT is an upsert, -// making repeated writes safe. +// Install provisions inference credentials and stores them as repo secrets on +// .fullsend and org secrets/variables visible to enrolled repos. func (l *InferenceLayer) Install(ctx context.Context) error { if l.provider == nil { l.ui.StepInfo("no inference provider configured, skipping") @@ -62,37 +70,209 @@ func (l *InferenceLayer) Install(ctx context.Context) error { return fmt.Errorf("provisioning %s: %w", l.provider.Name(), err) } - for name, value := range secrets { - l.ui.StepStart(fmt.Sprintf("storing %s", name)) + repoIDs, err := l.inferenceRepoIDs(ctx) + if err != nil { + return err + } + + secretNames := sortedStringMapKeys(secrets) + for _, name := range secretNames { + value := secrets[name] + l.ui.StepStart(fmt.Sprintf("storing %s on .fullsend", name)) if err := l.client.CreateRepoSecret(ctx, l.org, forge.ConfigRepoName, name, value); err != nil { l.ui.StepFail(fmt.Sprintf("failed to store %s", name)) return fmt.Errorf("creating secret %s: %w", name, err) } - l.ui.StepDone(fmt.Sprintf("stored %s", name)) + l.ui.StepDone(fmt.Sprintf("stored %s on .fullsend", name)) + + l.ui.StepStart(fmt.Sprintf("storing org secret %s", name)) + if err := l.client.CreateOrgSecret(ctx, l.org, name, value, repoIDs); err != nil { + l.ui.StepFail(fmt.Sprintf("failed to store org secret %s", name)) + return fmt.Errorf("creating org secret %s: %w", name, err) + } + l.ui.StepDone(fmt.Sprintf("stored org secret %s", name)) } l.ui.StepDone(fmt.Sprintf("%s credentials provisioned", l.provider.Name())) - // Store non-secret variables (e.g. region). Always run — variables are - // cheap to set and idempotent, and may be added after initial install. - for name, value := range l.provider.Variables() { - l.ui.StepStart(fmt.Sprintf("setting variable %s", name)) + variables := l.provider.Variables() + varNames := sortedStringMapKeys(variables) + for _, name := range varNames { + value := variables[name] + l.ui.StepStart(fmt.Sprintf("storing org variable %s", name)) + if err := l.client.CreateOrUpdateOrgVariable(ctx, l.org, name, value, repoIDs); err != nil { + l.ui.StepFail(fmt.Sprintf("failed to store org variable %s", name)) + return fmt.Errorf("creating org variable %s: %w", name, err) + } + l.ui.StepDone(fmt.Sprintf("stored org variable %s", name)) + + l.ui.StepStart(fmt.Sprintf("setting variable %s on .fullsend", name)) if err := l.client.CreateOrUpdateRepoVariable(ctx, l.org, forge.ConfigRepoName, name, value); err != nil { l.ui.StepFail(fmt.Sprintf("failed to set variable %s", name)) return fmt.Errorf("setting variable %s: %w", name, err) } - l.ui.StepDone(fmt.Sprintf("set variable %s", name)) + l.ui.StepDone(fmt.Sprintf("set variable %s on .fullsend", name)) + } + + dotRepos := l.dotPrefixedRepos(ctx, repoIDs) + for _, repo := range dotRepos { + for _, name := range varNames { + value := variables[name] + l.ui.StepStart(fmt.Sprintf("storing repo variable %s on %s", name, repo.Name)) + if err := l.client.CreateOrUpdateRepoVariable(ctx, l.org, repo.Name, name, value); err != nil { + l.ui.StepFail(fmt.Sprintf("failed to store repo variable %s on %s", name, repo.Name)) + return fmt.Errorf("creating repo variable %s on %s: %w", name, repo.Name, err) + } + l.ui.StepDone(fmt.Sprintf("stored repo variable %s on %s", name, repo.Name)) + } } return nil } +// SyncEnrolledRepoAccess updates org-level inference secret and variable visibility +// for the given enrolled repository IDs. It is best-effort: callers typically log +// warnings and continue when individual updates fail (e.g. admin enable/disable). +func (l *InferenceLayer) SyncEnrolledRepoAccess(ctx context.Context, repoIDs []int64) { + repoIDs, err := l.inferenceRepoIDsFromList(ctx, repoIDs) + if err != nil { + l.ui.StepWarn("could not resolve inference repo access list: " + err.Error()) + return + } + + for _, name := range inferenceOrgSecretNames(l.provider) { + exists, checkErr := l.client.OrgSecretExists(ctx, l.org, name) + if checkErr != nil { + l.ui.StepWarn(fmt.Sprintf("could not check org secret %s: %v", name, checkErr)) + continue + } + if !exists { + continue + } + + l.ui.StepStart(fmt.Sprintf("Updating %s visibility for enrolled repos", name)) + if setErr := l.client.SetOrgSecretRepos(ctx, l.org, name, repoIDs); setErr != nil { + l.ui.StepWarn(fmt.Sprintf("failed to update %s visibility: %v", name, setErr)) + } else { + l.ui.StepDone(fmt.Sprintf("Updated %s visibility (%d repos)", name, len(repoIDs))) + } + } + + for _, name := range inferenceOrgVariableNames(l.provider) { + exists, checkErr := l.client.OrgVariableExists(ctx, l.org, name) + if checkErr != nil { + l.ui.StepWarn(fmt.Sprintf("could not check org variable %s: %v", name, checkErr)) + continue + } + if !exists { + continue + } + + l.ui.StepStart(fmt.Sprintf("Updating %s visibility for enrolled repos", name)) + if setErr := l.client.SetOrgVariableRepos(ctx, l.org, name, repoIDs); setErr != nil { + l.ui.StepWarn(fmt.Sprintf("failed to update %s visibility: %v", name, setErr)) + } else { + l.ui.StepDone(fmt.Sprintf("Updated %s visibility (%d repos)", name, len(repoIDs))) + } + } + + l.syncDotPrefixedRepoVariables(ctx, repoIDs) +} + +func inferenceOrgSecretNames(provider inference.Provider) []string { + if provider != nil { + return provider.SecretNames() + } + return []string{vertex.SecretWIFProvider, vertex.SecretProjectID} +} + +func inferenceOrgVariableNames(provider inference.Provider) []string { + if provider != nil { + return sortedStringMapKeys(provider.Variables()) + } + return []string{vertex.VariableRegion} +} + +// syncDotPrefixedRepoVariables copies inference org variables onto dot-prefixed +// enrolled repos that cannot read org variables (GitHub platform limitation). +func (l *InferenceLayer) syncDotPrefixedRepoVariables(ctx context.Context, repoIDs []int64) { + dotRepos := l.dotPrefixedRepos(ctx, repoIDs) + if len(dotRepos) == 0 { + return + } + + for _, name := range inferenceOrgVariableNames(l.provider) { + value, ok, err := l.client.GetRepoVariable(ctx, l.org, forge.ConfigRepoName, name) + if err != nil { + l.ui.StepWarn(fmt.Sprintf("could not read %s from .fullsend: %v", name, err)) + continue + } + if !ok { + continue + } + + for _, repo := range dotRepos { + l.ui.StepStart(fmt.Sprintf("storing repo variable %s on %s", name, repo.Name)) + if err := l.client.CreateOrUpdateRepoVariable(ctx, l.org, repo.Name, name, value); err != nil { + l.ui.StepWarn(fmt.Sprintf("failed to store repo variable %s on %s: %v", name, repo.Name, err)) + } else { + l.ui.StepDone(fmt.Sprintf("stored repo variable %s on %s", name, repo.Name)) + } + } + } +} + +func (l *InferenceLayer) inferenceRepoIDs(ctx context.Context) ([]int64, error) { + return l.inferenceRepoIDsFromList(ctx, l.enrolledRepoIDs) +} + +func (l *InferenceLayer) inferenceRepoIDsFromList(ctx context.Context, repoIDs []int64) ([]int64, error) { + return appendConfigRepoID(ctx, l.client, l.org, repoIDs) +} + +// appendConfigRepoID ensures the .fullsend config repo is included in repo ID lists. +func appendConfigRepoID(ctx context.Context, client forge.Client, org string, repoIDs []int64) ([]int64, error) { + repoIDs = append([]int64(nil), repoIDs...) + configRepo, err := client.GetRepo(ctx, org, forge.ConfigRepoName) + if err == nil && configRepo != nil { + seen := make(map[int64]bool, len(repoIDs)) + for _, id := range repoIDs { + seen[id] = true + } + if !seen[configRepo.ID] { + repoIDs = append(repoIDs, configRepo.ID) + } + } + return repoIDs, nil +} + +func (l *InferenceLayer) dotPrefixedRepos(ctx context.Context, repoIDs []int64) []forge.Repository { + allRepos, err := l.client.ListOrgRepos(ctx, l.org) + if err != nil { + l.ui.StepWarn("could not list org repos to detect dot-prefixed names: " + err.Error()) + return nil + } + + idSet := make(map[int64]bool, len(repoIDs)) + for _, id := range repoIDs { + idSet[id] = true + } + + var result []forge.Repository + for _, r := range allRepos { + if idSet[r.ID] && strings.HasPrefix(r.Name, ".") { + result = append(result, r) + } + } + return result +} + // Uninstall is a no-op. Secrets are removed when the .fullsend repo is deleted. func (l *InferenceLayer) Uninstall(_ context.Context) error { return nil } -// Analyze checks whether inference credentials exist in the .fullsend repo. +// Analyze checks whether inference credentials exist in .fullsend and at org scope. func (l *InferenceLayer) Analyze(ctx context.Context) (*LayerReport, error) { report := &LayerReport{Name: l.Name()} @@ -106,24 +286,31 @@ func (l *InferenceLayer) Analyze(ctx context.Context) (*LayerReport, error) { var present, missing []string for _, name := range secretNames { - exists, err := l.client.RepoSecretExists(ctx, l.org, forge.ConfigRepoName, name) + repoExists, err := l.client.RepoSecretExists(ctx, l.org, forge.ConfigRepoName, name) if err != nil { return nil, fmt.Errorf("checking secret %s: %w", name, err) } - if exists { + orgExists, err := l.client.OrgSecretExists(ctx, l.org, name) + if err != nil { + return nil, fmt.Errorf("checking org secret %s: %w", name, err) + } + if repoExists && orgExists { present = append(present, name) } else { missing = append(missing, name) } } - // Check variables (e.g. region). for name := range l.provider.Variables() { - exists, err := l.client.RepoVariableExists(ctx, l.org, forge.ConfigRepoName, name) + repoExists, err := l.client.RepoVariableExists(ctx, l.org, forge.ConfigRepoName, name) if err != nil { return nil, fmt.Errorf("checking variable %s: %w", name, err) } - if exists { + orgExists, err := l.client.OrgVariableExists(ctx, l.org, name) + if err != nil { + return nil, fmt.Errorf("checking org variable %s: %w", name, err) + } + if repoExists && orgExists { present = append(present, name) } else { missing = append(missing, name) @@ -153,3 +340,13 @@ func (l *InferenceLayer) Analyze(ctx context.Context) (*LayerReport, error) { return report, nil } + +// sortedStringMapKeys returns sorted keys from a string map. +func sortedStringMapKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/layers/inference_test.go b/internal/layers/inference_test.go index dd14ed5fa..06c792a33 100644 --- a/internal/layers/inference_test.go +++ b/internal/layers/inference_test.go @@ -28,11 +28,11 @@ func (f *fakeProvider) SecretNames() []string { func (f *fakeProvider) Provision(_ context.Context) (map[string]string, error) { return f.secrets, f.err } func (f *fakeProvider) Variables() map[string]string { return f.variables } -func newInferenceLayer(t *testing.T, client *forge.FakeClient, provider inference.Provider) (*InferenceLayer, *bytes.Buffer) { +func newInferenceLayer(t *testing.T, client *forge.FakeClient, provider inference.Provider, enrolledRepoIDs []int64) (*InferenceLayer, *bytes.Buffer) { t.Helper() var buf bytes.Buffer printer := ui.New(&buf) - layer := NewInferenceLayer("test-org", client, provider, printer) + layer := NewInferenceLayer("test-org", client, provider, enrolledRepoIDs, printer) return layer, &buf } @@ -51,19 +51,21 @@ func vertexProvider() *fakeProvider { } func TestInferenceLayer_Name(t *testing.T) { - layer, _ := newInferenceLayer(t, &forge.FakeClient{}, nil) + layer, _ := newInferenceLayer(t, &forge.FakeClient{}, nil, nil) assert.Equal(t, "inference", layer.Name()) } func TestInferenceLayer_Install_StoresSecrets(t *testing.T) { client := forge.NewFakeClient() + client.Repos = []forge.Repository{{ID: 42, Name: "test-repo"}} provider := vertexProvider() - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, []int64{42}) err := layer.Install(context.Background()) require.NoError(t, err) require.Len(t, client.CreatedSecrets, 2) + require.Len(t, client.CreatedOrgSecrets, 2) secretMap := make(map[string]string) for _, s := range client.CreatedSecrets { @@ -75,15 +77,27 @@ func TestInferenceLayer_Install_StoresSecrets(t *testing.T) { assert.Equal(t, "projects/123/locations/global/workloadIdentityPools/pool/providers/gh", secretMap["FULLSEND_GCP_WIF_PROVIDER"]) assert.Equal(t, "my-project", secretMap["FULLSEND_GCP_PROJECT_ID"]) - // Variables should also have been set. + orgSecretMap := make(map[string]string) + for _, s := range client.CreatedOrgSecrets { + assert.Equal(t, "test-org", s.Org) + assert.Contains(t, s.RepoIDs, int64(42)) + orgSecretMap[s.Name] = s.Value + } + assert.Equal(t, secretMap["FULLSEND_GCP_WIF_PROVIDER"], orgSecretMap["FULLSEND_GCP_WIF_PROVIDER"]) + assert.Equal(t, secretMap["FULLSEND_GCP_PROJECT_ID"], orgSecretMap["FULLSEND_GCP_PROJECT_ID"]) + require.Len(t, client.Variables, 1) assert.Equal(t, "FULLSEND_GCP_REGION", client.Variables[0].Name) assert.Equal(t, "global", client.Variables[0].Value) + + require.Len(t, client.CreatedOrgVariables, 1) + assert.Equal(t, "FULLSEND_GCP_REGION", client.CreatedOrgVariables[0].Name) + assert.Equal(t, "global", client.CreatedOrgVariables[0].Value) } func TestInferenceLayer_Install_NilProvider(t *testing.T) { client := forge.NewFakeClient() - layer, _ := newInferenceLayer(t, client, nil) + layer, _ := newInferenceLayer(t, client, nil, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -96,7 +110,7 @@ func TestInferenceLayer_Install_ProvisionError(t *testing.T) { provider := vertexProvider() provider.err = errors.New("gcp auth failed") provider.secrets = nil - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) err := layer.Install(context.Background()) require.Error(t, err) @@ -107,7 +121,7 @@ func TestInferenceLayer_Install_SecretWriteError(t *testing.T) { client := forge.NewFakeClient() client.Errors["CreateRepoSecret"] = errors.New("permission denied") provider := vertexProvider() - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) err := layer.Install(context.Background()) require.Error(t, err) @@ -121,7 +135,7 @@ func TestInferenceLayer_Install_ProvisionErrorWithExistingSecrets(t *testing.T) provider := vertexProvider() provider.err = errors.New("gcp auth failed") provider.secrets = nil - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) err := layer.Install(context.Background()) require.Error(t, err) @@ -134,7 +148,7 @@ func TestInferenceLayer_Install_OverwritesExistingSecrets(t *testing.T) { client.Secrets["test-org/.fullsend/FULLSEND_GCP_WIF_PROVIDER"] = true client.Secrets["test-org/.fullsend/FULLSEND_GCP_PROJECT_ID"] = true provider := vertexProvider() - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -157,7 +171,7 @@ func TestInferenceLayer_Install_OverwritesExistingSecrets(t *testing.T) { func TestInferenceLayer_Uninstall_Noop(t *testing.T) { client := forge.NewFakeClient() provider := vertexProvider() - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) err := layer.Uninstall(context.Background()) require.NoError(t, err) @@ -168,9 +182,14 @@ func TestInferenceLayer_Analyze_AllPresent(t *testing.T) { client := forge.NewFakeClient() client.Secrets["test-org/.fullsend/FULLSEND_GCP_WIF_PROVIDER"] = true client.Secrets["test-org/.fullsend/FULLSEND_GCP_PROJECT_ID"] = true + client.OrgSecrets = map[string]bool{ + "test-org/FULLSEND_GCP_WIF_PROVIDER": true, + "test-org/FULLSEND_GCP_PROJECT_ID": true, + } client.VariablesExist["test-org/.fullsend/FULLSEND_GCP_REGION"] = true + client.OrgVariables = map[string]bool{"test-org/FULLSEND_GCP_REGION": true} provider := vertexProvider() - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) @@ -184,7 +203,7 @@ func TestInferenceLayer_Analyze_AllPresent(t *testing.T) { func TestInferenceLayer_Analyze_NonePresent(t *testing.T) { client := forge.NewFakeClient() provider := vertexProvider() - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) @@ -196,21 +215,23 @@ func TestInferenceLayer_Analyze_NonePresent(t *testing.T) { func TestInferenceLayer_Analyze_Partial(t *testing.T) { client := forge.NewFakeClient() client.Secrets["test-org/.fullsend/FULLSEND_GCP_PROJECT_ID"] = true - // FULLSEND_GCP_WIF_PROVIDER missing + client.OrgSecrets = map[string]bool{"test-org/FULLSEND_GCP_PROJECT_ID": true} + // FULLSEND_GCP_WIF_PROVIDER missing; region missing at org scope provider := vertexProvider() - layer, _ := newInferenceLayer(t, client, provider) + layer, _ := newInferenceLayer(t, client, provider, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusDegraded, report.Status) - assert.NotEmpty(t, report.Details) - assert.NotEmpty(t, report.WouldFix) + assert.Contains(t, report.Details, "FULLSEND_GCP_PROJECT_ID exists") + assert.Contains(t, report.WouldFix, "create missing FULLSEND_GCP_WIF_PROVIDER") + assert.Contains(t, report.WouldFix, "create missing FULLSEND_GCP_REGION") } func TestInferenceLayer_Analyze_NilProvider(t *testing.T) { client := forge.NewFakeClient() - layer, _ := newInferenceLayer(t, client, nil) + layer, _ := newInferenceLayer(t, client, nil, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) @@ -219,9 +240,37 @@ func TestInferenceLayer_Analyze_NilProvider(t *testing.T) { assert.Contains(t, report.Details[0], "no inference provider configured") } +func TestInferenceLayer_SyncEnrolledRepoAccess_UpdatesOrgSecretsAndVariables(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{ + {ID: 1, Name: forge.ConfigRepoName, FullName: "test-org/" + forge.ConfigRepoName}, + {ID: 42, Name: "test-repo", FullName: "test-org/test-repo"}, + } + client.OrgSecrets = map[string]bool{ + "test-org/FULLSEND_GCP_WIF_PROVIDER": true, + "test-org/FULLSEND_GCP_PROJECT_ID": true, + } + client.OrgVariables = map[string]bool{"test-org/FULLSEND_GCP_REGION": true} + client.VariableValues["test-org/.fullsend/FULLSEND_GCP_REGION"] = "global" + client.VariablesExist["test-org/.fullsend/FULLSEND_GCP_REGION"] = true + + layer, _ := newInferenceLayer(t, client, vertexProvider(), nil) + layer.SyncEnrolledRepoAccess(context.Background(), []int64{42}) + + require.Len(t, client.OrgSecretRepoIDs, 2) + assert.Equal(t, []int64{42, 1}, client.OrgSecretRepoIDs["test-org/FULLSEND_GCP_WIF_PROVIDER"]) + assert.Equal(t, []int64{42, 1}, client.OrgSecretRepoIDs["test-org/FULLSEND_GCP_PROJECT_ID"]) + require.Len(t, client.OrgVariableRepoIDs, 1) + assert.Equal(t, []int64{42, 1}, client.OrgVariableRepoIDs["test-org/FULLSEND_GCP_REGION"]) +} + func TestInferenceLayer_RequiredScopes(t *testing.T) { - layer, _ := newInferenceLayer(t, &forge.FakeClient{}, nil) + layer, _ := newInferenceLayer(t, &forge.FakeClient{}, nil, nil) assert.Equal(t, []string{"repo"}, layer.RequiredScopes(OpInstall)) assert.Equal(t, []string{"repo"}, layer.RequiredScopes(OpAnalyze)) assert.Nil(t, layer.RequiredScopes(OpUninstall)) + + providerLayer, _ := newInferenceLayer(t, &forge.FakeClient{}, vertexProvider(), nil) + assert.Equal(t, []string{"repo", "admin:org"}, providerLayer.RequiredScopes(OpInstall)) + assert.Equal(t, []string{"repo", "admin:org"}, providerLayer.RequiredScopes(OpAnalyze)) }