Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions internal/scaffold/fullsend-repo/scripts/reconcile-repos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,33 @@ ENROLL_PR_TITLE="chore: connect to fullsend agent pipeline"
UNENROLL_PR_TITLE="chore: disconnect from fullsend agent pipeline"
UPDATE_PR_TITLE="chore: update fullsend shim workflow"

# Shared "Getting started" block appended to both enrollment and update PRs.
Comment thread
shairevivo marked this conversation as resolved.
Resolved
# The update path is the first touchpoint for already-enrolled repos (see #2165),
# so it must document the slash commands too.
GETTING_STARTED_SECTION="## Getting started

Once this PR is merged, interact with fullsend by commenting one of these slash commands. The supported target (issue and/or pull request) is shown for each:

- \`/fs-triage\` (issue or PR) — Invoke the [triage](https://fullsend.sh/docs/agents/triage) agent to categorize, label, and assess an issue.
- \`/fs-code\` (issue only) — Invoke the [code](https://fullsend.sh/docs/agents/code) agent to implement a fix for an issue and open a PR.
- \`/fs-review\` (PR only) — Invoke the [review](https://fullsend.sh/docs/agents/review) agent to review a pull request.
- \`/fs-fix\` (PR only) — Invoke the [fix](https://fullsend.sh/docs/agents/fix) agent to address review feedback on a pull request.
- \`/fs-retro\` (issue or PR) — Invoke the [retro](https://fullsend.sh/docs/agents/retro) agent to analyze completed work and propose improvements.
- \`/fs-prioritize\` (issue or PR) — Invoke the [prioritize](https://fullsend.sh/docs/agents/prioritize) agent to score an issue for project board ranking."

ENROLL_PR_BODY="This PR adds a shim workflow that routes repository events to the fullsend agent dispatch workflow in the \`.fullsend\` config repo.

Once merged, issues, PRs, and comments in this repo will be handled by the fullsend agent pipeline."
Once merged, issues, PRs, and comments in this repo will be handled by the fullsend agent pipeline.

${GETTING_STARTED_SECTION}"
UNENROLL_PR_BODY="This PR removes the fullsend shim workflow. The repo has been set to \`enabled: false\` in the fullsend config.

Comment thread
shairevivo marked this conversation as resolved.
Resolved
Once merged, this repo will no longer dispatch events to the fullsend agent pipeline."
UPDATE_PR_BODY="This PR updates the fullsend shim workflow to match the current template in the \`.fullsend\` config repo.

The shim content has drifted from the template — this brings it back in sync."
The shim content has drifted from the template — this brings it back in sync.

${GETTING_STARTED_SECTION}"

UPDATE_COMMIT_MSG="chore: update fullsend shim workflow

Expand Down
94 changes: 94 additions & 0 deletions internal/scaffold/scaffold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"

Expand Down Expand Up @@ -802,6 +803,99 @@ func TestReconcileReposContent(t *testing.T) {
"reconcile-repos.sh should not parse dispatch mode")
assert.Contains(t, s, "private repos cannot be enrolled",
"reconcile-repos.sh should skip private repos to prevent log exposure")

// The "Getting started" slash-command catalog (#2165) must appear in both the
// enrollment and update PR bodies. The update path is the first touchpoint for
// already-enrolled repos, which is the scenario the original incident hit.
assert.Contains(t, s, "## Getting started",
"reconcile-repos.sh PR bodies should include the Getting started section")
assert.Contains(t, s, `GETTING_STARTED_SECTION`,
"Getting started block should be shared so it appears in both enroll and update PRs")
assert.Contains(t, s, `ENROLL_PR_BODY=`)
assert.Contains(t, s, `UPDATE_PR_BODY=`)
// Both PR bodies interpolate the shared block.
assert.Equal(t, 2, strings.Count(s, `${GETTING_STARTED_SECTION}`),
"shared Getting started block should be appended to both the enroll and update PR bodies")
}

// commandsNotInOnboardingCatalog lists slash commands that dispatch.yml routes
// on but that are deliberately omitted from the user-facing onboarding catalog,
// so the omission is a recorded decision rather than a regex accident. Anything
// routed by dispatch.yml and not listed here must appear in the catalog.
// - /fullsend: backward-compat alias for the /fs-retro form; /fs-retro is the
// primary command documented in the catalog.
var commandsNotInOnboardingCatalog = map[string]bool{
"/fullsend": true,
}

// extractGettingStartedSection returns the body of the GETTING_STARTED_SECTION
// shell assignment in reconcile-repos.sh — the exact block rendered into the
// onboarding PR bodies. Assertions scope to this block rather than the whole
// script so a command name appearing in an unrelated comment or code path cannot
// satisfy the catalog guard.
func extractGettingStartedSection(t *testing.T, scriptStr string) string {
t.Helper()
const marker = `GETTING_STARTED_SECTION="`
start := strings.Index(scriptStr, marker)
require.GreaterOrEqual(t, start, 0,
"expected GETTING_STARTED_SECTION assignment in reconcile-repos.sh")
rest := scriptStr[start+len(marker):]
// The block contains no embedded double quotes, so the next quote closes it.
end := strings.Index(rest, `"`)
require.GreaterOrEqual(t, end, 0,
"GETTING_STARTED_SECTION assignment should be closed with a double quote")
return rest[:end]
}

// TestReconcileReposSlashCommandCatalog guards against the onboarding PR body's
// slash-command catalog drifting from dispatch.yml's routing, in both directions:
// - forward: every command dispatch.yml routes on (except deliberately-omitted
// aliases in commandsNotInOnboardingCatalog) must appear in the catalog, so a
// command added/renamed in dispatch.yml without updating the catalog fails CI.
// - reverse: every command documented in the catalog must actually be routed by
// dispatch.yml, so a command removed from dispatch.yml but left in the
// user-facing catalog also fails CI.
//
// The command pattern permits digits and hyphens so a hyphenated command (e.g.
// /fs-fix-stop) is matched in full rather than truncated to a prefix, and the
// /fullsend alias form is matched explicitly.
func TestReconcileReposSlashCommandCatalog(t *testing.T) {
dispatch, err := FullsendRepoFile(".github/workflows/dispatch.yml")
require.NoError(t, err)
dispatchStr := string(dispatch)
script, err := FullsendRepoFile("scripts/reconcile-repos.sh")
require.NoError(t, err)

catalog := extractGettingStartedSection(t, string(script))

cmdRE := regexp.MustCompile(`/fs-[a-z0-9-]+|/fullsend\b`)
Comment thread
shairevivo marked this conversation as resolved.
Resolved

// Forward: dispatch.yml commands must be documented (unless allow-listed).
dispatchCmds := cmdRE.FindAllString(dispatchStr, -1)
require.NotEmpty(t, dispatchCmds, "expected dispatch.yml to route on /fs-* commands")
seen := map[string]bool{}
for _, cmd := range dispatchCmds {
if seen[cmd] || commandsNotInOnboardingCatalog[cmd] {
continue
}
seen[cmd] = true
assert.Contains(t, catalog, cmd,
"dispatch.yml routes on %s but the onboarding catalog does not document it "+
"(add it to GETTING_STARTED_SECTION, or to commandsNotInOnboardingCatalog if intentional)", cmd)
}

// Reverse: cataloged commands must actually be routed by dispatch.yml.
catalogCmds := cmdRE.FindAllString(catalog, -1)
require.NotEmpty(t, catalogCmds, "expected the onboarding catalog to document /fs-* commands")
seenCatalog := map[string]bool{}
for _, cmd := range catalogCmds {
if seenCatalog[cmd] {
continue
}
seenCatalog[cmd] = true
assert.Contains(t, dispatchStr, cmd,
"onboarding catalog documents %s but dispatch.yml does not route on it", cmd)
}
}

func TestPrioritizeWorkflowContent(t *testing.T) {
Expand Down
Loading