From 63c27e416b7a3f455de7b610343176e351e3f9e1 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 15:45:23 -0400 Subject: [PATCH 01/13] docs: add design spec for triage prerequisites action (#401) Design for a new `prerequisites` triage action that replaces `blocked`. The agent can now express both existing blockers and new issues that need to be created upstream before progress can happen. Includes allowlist configuration for cross-repo issue creation and a degraded path when targets are not authorized. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- .../2026-06-11-triage-prerequisites-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-triage-prerequisites-design.md diff --git a/docs/superpowers/specs/2026-06-11-triage-prerequisites-design.md b/docs/superpowers/specs/2026-06-11-triage-prerequisites-design.md new file mode 100644 index 000000000..899deebf5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-triage-prerequisites-design.md @@ -0,0 +1,147 @@ +# Triage Agent Prerequisites Action + +**Date:** 2026-06-11 +**Issue:** [#401](https://github.com/fullsend-ai/fullsend/issues/401) +**Status:** Draft + +## Problem + +The triage agent can detect that an issue is blocked by existing work elsewhere, but it cannot create the missing tracking issue when no such issue exists yet. A common scenario: triage evaluates a bug in a Tekton task and determines the root cause is a missing feature in an upstream container image defined in a different repo. Today the agent can only say "blocked" and point to an existing issue. If no upstream issue exists, the agent has no way to express "this needs to be filed first." + +This forces humans to manually identify, draft, and file prerequisite issues in other repos before the original issue can make progress. + +## Scope + +This design covers **one** of three decomposition strategies identified during brainstorming: + +| Strategy | Description | This design? | +|---|---|---| +| **Spin out dependency** | Original stays open + `blocked`. Agent creates upstream prerequisite issues. | Yes | +| **Split muddled issue** | Original closed. N independent successor issues replace it. | No (future work) | +| **Parent/child decompose** | Original stays open as parent. N child issues for incremental delivery. | No (future work) | + +## Key discovery: cross-repo issue creation works today + +A GitHub App installation token scoped to one repository can create issues in any public repo on GitHub, including repos in orgs where the app is not installed. GitHub confirmed this as a known behavior (not a vulnerability). This means the triage agent's existing token already supports cross-repo issue creation without any changes to the mint or auth infrastructure. See #402 for the original assumption that cross-installation auth would be needed. + +## Design + +### New `prerequisites` action + +The existing `blocked` action is replaced by `prerequisites`. The triage agent's action set becomes five actions: `sufficient`, `insufficient`, `duplicate`, `question`, `prerequisites`. + +The `prerequisites` action unifies two cases: +- **Existing blockers** the agent found during its search (today's `blocked` behavior) +- **New blockers** that need to be filed as issues before progress can happen + +The triage result schema: + +```json +{ + "action": "prerequisites", + "prerequisites": { + "existing": [ + { "url": "https://github.com/org/repo/issues/42" } + ], + "create": [ + { + "repo": "org/upstream-lib", + "title": "Add support for X", + "body": "Technical description for the upstream audience..." + } + ] + }, + "comment": "This issue requires upstream changes before it can proceed.", + "label_actions": [] +} +``` + +Constraints: +- At least one of `existing` or `create` must be non-empty. +- Both arrays can be populated in the same result (mixed existing + new blockers). +- The `blocked_by` field (singular URL, current schema) is removed. + +### Hard constraint in agent prompt + +> Never emit `sufficient` if unresolved prerequisites exist. Use `prerequisites` instead. + +This mirrors the existing constraint: "Never emit `sufficient` with open questions." + +### Agent prompt guidance for `create` entries + +The agent uses its judgment on issue body content. Sometimes a back-reference to the originating issue is helpful for upstream maintainers; sometimes it leaks internal context. The agent writes the body for the upstream repo's audience, not the source repo's. + +### Allowlist configuration + +A new `create_issues` config field controls which repos and orgs agents are permitted to create issues in. This applies to both triage and retro agents. + +```yaml +create_issues: + allow_targets: + orgs: + - "my-org" + - "upstream-org" + repos: + - "other-org/specific-repo" +``` + +Validation rules: +- If `allow_targets` is absent or empty, prerequisite creation is disabled (safe default). +- A target repo is permitted if its org appears in `orgs` OR the exact `owner/repo` appears in `repos`. +- The source repo (where triage is running) is always implicitly allowed. +- Entries in `repos` must be `owner/name` format. Empty strings are rejected. + +### Install-time defaults + +The admin setup flow populates `create_issues.allow_targets` with sensible defaults: + +- **Org mode:** `allow_targets.orgs` includes the org. `allow_targets.repos` includes `fullsend-ai/fullsend`. +- **Per-repo mode:** `allow_targets.repos` includes the target repo and `fullsend-ai/fullsend`. + +### Post-script behavior + +When the post-script receives `action: "prerequisites"`: + +1. **Process `create` entries:** For each entry, validate `repo` against `create_issues.allow_targets`. If allowed, create the issue using existing `forge.Client.CreateIssue` plumbing. Collect the resulting URL. If disallowed or the API call fails, record the failure. + +2. **Merge URLs:** Combine URLs from successfully created issues with the `existing` array to produce the full blocker list. + +3. **Apply labels:** Remove `ready-to-code` and `needs-info`. Add `blocked` label. (Same as current `blocked` action behavior.) + +4. **Post comment:** Sticky comment (via `fullsend post-comment`) summarizing the prerequisites. Links to all blockers (existing and newly created). For entries that could not be filed (allowlist rejection or API failure), include the agent's draft in a collapsed section so a human can file it manually: + + ```html +
+ Prerequisite: org_a/repo -- Add support for X + + [the full body the agent drafted for the upstream issue] + +
+ ``` + +5. **Partial success:** If some creates succeed and others fail, the issue still gets `blocked` with whatever blockers were established. The comment notes which prerequisites could not be created and why. + +The existing `blocked` action handler in the post-script is removed. `prerequisites` fully replaces it. + +### Re-triage flow + +When a prerequisite issue is resolved and the original issue is re-triaged, the agent discovers blocker URLs from the sticky comment posted by the post-script (which contains links to all prerequisite issues). The existing blocker-checking logic in the agent prompt (Step 2) already inspects linked issues and checks their state. If all prerequisites are resolved, the agent can emit `sufficient` or another appropriate action. No changes needed to the re-triage flow. + +## Changes required + +| Component | File | Change | +|---|---|---| +| Config structs | `internal/config/config.go` | Add `CreateIssues` struct with `AllowTargets` (Orgs `[]string`, Repos `[]string`) to both `OrgConfig` and `PerRepoConfig`. Update constructors with install-time defaults. Add validation. | +| Triage result schema | `internal/scaffold/fullsend-repo/schemas/triage-result.schema.json` | Replace `blocked` with `prerequisites` in action enum. Add `prerequisites` object schema. Remove `blocked_by`. | +| Agent prompt | `internal/scaffold/fullsend-repo/agents/triage.md` | Replace `blocked` action with `prerequisites`. Add hard constraint. Add guidance for `create` entry content. | +| Post-script | `internal/scaffold/fullsend-repo/scripts/post-triage.sh` | Replace `blocked` handler with `prerequisites` handler. Add allowlist validation, issue creation, degraded path with collapsed draft. | +| Pre-script | `internal/scaffold/fullsend-repo/scripts/pre-triage.sh` | No change. `blocked` label stripping stays the same. | +| User docs | `docs/agents/triage.md` | New section documenting `create_issues` config surface: what it does, defaults, when to expand or restrict. | +| Config constructors | `internal/config/config.go` | `NewOrgConfig` and `NewPerRepoConfig` populate `create_issues.allow_targets` defaults. Callers in `internal/cli/admin.go` and `internal/cli/github.go` pass the org/repo context. | + +## Out of scope + +- **Split muddled issues** (close original, create N independent successors) +- **Parent/child decomposition** (original stays open, create N children) +- **Cross-repo issue editing** (GitHub enforces scope on edits, only creation bypasses it) +- **Retro agent integration** (uses the same `create_issues` config, but prompt/post-script changes are separate work) From ba99ae3414216d49f4b46679f1788c2970ec4a7e Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 15:49:37 -0400 Subject: [PATCH 02/13] docs: add implementation plan for triage prerequisites action (#401) Seven-task plan covering config structs, JSON schema, agent prompt, post-script, user docs, and caller updates. TDD approach with exact file paths and code blocks. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- .../plans/2026-06-11-triage-prerequisites.md | 865 ++++++++++++++++++ 1 file changed, 865 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-triage-prerequisites.md diff --git a/docs/superpowers/plans/2026-06-11-triage-prerequisites.md b/docs/superpowers/plans/2026-06-11-triage-prerequisites.md new file mode 100644 index 000000000..777c65fd2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-triage-prerequisites.md @@ -0,0 +1,865 @@ +# Triage Prerequisites Action Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the triage agent's `blocked` action with a `prerequisites` action that can both reference existing blockers and create new upstream issues. + +**Architecture:** Add `CreateIssuesConfig` to the config structs, update the triage result JSON schema, modify the agent prompt, and extend the post-script to create issues and handle the allowlist. The post-script reads `config.yaml` from `$GITHUB_WORKSPACE` (the config repo checkout) via `yq`. + +**Tech Stack:** Go (config structs + tests), JSON Schema, bash (post-script), markdown (agent prompt + docs) + +--- + +### Task 1: Add `CreateIssuesConfig` to config structs + +**Files:** +- Modify: `internal/config/config.go` +- Test: `internal/config/config_test.go` + +- [ ] **Step 1: Write failing tests for the new config types** + +Add to `internal/config/config_test.go`: + +```go +func TestOrgConfig_CreateIssues_ParseYAML(t *testing.T) { + yamlData := ` +version: "1" +dispatch: + platform: github-actions +defaults: + roles: + - fullsend + max_implementation_retries: 2 +agents: [] +repos: {} +create_issues: + allow_targets: + orgs: + - my-org + - upstream-org + repos: + - other-org/specific-repo +` + cfg, err := ParseOrgConfig([]byte(yamlData)) + require.NoError(t, err) + require.NotNil(t, cfg.CreateIssues) + assert.Equal(t, []string{"my-org", "upstream-org"}, cfg.CreateIssues.AllowTargets.Orgs) + assert.Equal(t, []string{"other-org/specific-repo"}, cfg.CreateIssues.AllowTargets.Repos) +} + +func TestOrgConfig_CreateIssues_OmittedWhenEmpty(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + Agents: []AgentEntry{}, + Repos: map[string]RepoConfig{}, + } + data, err := cfg.Marshal() + require.NoError(t, err) + assert.NotContains(t, string(data), "create_issues") +} + +func TestOrgConfig_CreateIssues_Marshal(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + Agents: []AgentEntry{}, + Repos: map[string]RepoConfig{}, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{"my-org"}, + Repos: []string{"fullsend-ai/fullsend"}, + }, + }, + } + data, err := cfg.Marshal() + require.NoError(t, err) + assert.Contains(t, string(data), "create_issues:") + assert.Contains(t, string(data), "my-org") + assert.Contains(t, string(data), "fullsend-ai/fullsend") +} + +func TestOrgConfigValidate_CreateIssues_InvalidRepoFormat(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Repos: []string{"no-slash"}, + }, + }, + } + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "create_issues") +} + +func TestOrgConfigValidate_CreateIssues_EmptyOrg(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{""}, + }, + }, + } + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "create_issues") +} + +func TestOrgConfigValidate_CreateIssues_Valid(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{"my-org"}, + Repos: []string{"other/repo"}, + }, + }, + } + assert.NoError(t, cfg.Validate()) +} + +func TestOrgConfigValidate_CreateIssues_Nil(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + } + assert.NoError(t, cfg.Validate()) +} + +func TestNewOrgConfig_CreateIssuesDefaults(t *testing.T) { + cfg := NewOrgConfig([]string{"repo-a"}, []string{"repo-a"}, []string{"fullsend"}, nil, "", "my-org") + require.NotNil(t, cfg.CreateIssues) + assert.Contains(t, cfg.CreateIssues.AllowTargets.Orgs, "my-org") + assert.Contains(t, cfg.CreateIssues.AllowTargets.Repos, "fullsend-ai/fullsend") +} + +func TestPerRepoConfig_CreateIssues_ParseYAML(t *testing.T) { + yamlData := ` +version: "1" +roles: + - triage +create_issues: + allow_targets: + repos: + - owner/target-repo + - fullsend-ai/fullsend +` + cfg, err := ParsePerRepoConfig([]byte(yamlData)) + require.NoError(t, err) + require.NotNil(t, cfg.CreateIssues) + assert.Equal(t, []string{"owner/target-repo", "fullsend-ai/fullsend"}, cfg.CreateIssues.AllowTargets.Repos) +} + +func TestNewPerRepoConfig_CreateIssuesDefaults(t *testing.T) { + cfg := NewPerRepoConfig(nil, "owner/my-repo") + require.NotNil(t, cfg.CreateIssues) + assert.Contains(t, cfg.CreateIssues.AllowTargets.Repos, "owner/my-repo") + assert.Contains(t, cfg.CreateIssues.AllowTargets.Repos, "fullsend-ai/fullsend") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd internal/config && go test -v -run 'CreateIssues' ./...` +Expected: compilation errors — types `CreateIssuesConfig`, `AllowTargets` not defined, `NewOrgConfig`/`NewPerRepoConfig` wrong arg count. + +- [ ] **Step 3: Add the new types and update struct fields** + +In `internal/config/config.go`, add the new types: + +```go +// AllowTargets defines which orgs and repos agents may create issues in. +type AllowTargets struct { + Orgs []string `yaml:"orgs,omitempty"` + Repos []string `yaml:"repos,omitempty"` +} + +// CreateIssuesConfig controls cross-repo issue creation by agents. +type CreateIssuesConfig struct { + AllowTargets AllowTargets `yaml:"allow_targets"` +} +``` + +Add `CreateIssues` field to `OrgConfig`: + +```go +CreateIssues *CreateIssuesConfig `yaml:"create_issues,omitempty"` +``` + +Add `CreateIssues` field to `PerRepoConfig`: + +```go +CreateIssues *CreateIssuesConfig `yaml:"create_issues,omitempty"` +``` + +- [ ] **Step 4: Update `NewOrgConfig` to accept org name and set defaults** + +Change `NewOrgConfig` signature to add `org string` parameter: + +```go +func NewOrgConfig(allRepos, enabledRepos, roles []string, agents []AgentEntry, inferenceProvider, org string) *OrgConfig { +``` + +Inside the function, after the existing config construction, add: + +```go +if org != "" { + cfg.CreateIssues = &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{org}, + Repos: []string{"fullsend-ai/fullsend"}, + }, + } +} +``` + +- [ ] **Step 5: Update `NewPerRepoConfig` to accept target repo and set defaults** + +Change `NewPerRepoConfig` signature: + +```go +func NewPerRepoConfig(roles []string, targetRepo string) *PerRepoConfig { +``` + +Inside the function, after the existing config construction, add: + +```go +if targetRepo != "" { + cfg.CreateIssues = &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Repos: []string{targetRepo, "fullsend-ai/fullsend"}, + }, + } +} +``` + +- [ ] **Step 6: Add validation for CreateIssues in `OrgConfig.Validate()`** + +Before the `return nil` at the end of `Validate()`: + +```go +if err := validateCreateIssues(c.CreateIssues); err != nil { + return err +} +``` + +Add the helper: + +```go +func validateCreateIssues(cfg *CreateIssuesConfig) error { + if cfg == nil { + return nil + } + for _, org := range cfg.AllowTargets.Orgs { + if org == "" { + return fmt.Errorf("create_issues.allow_targets.orgs contains empty string") + } + } + for _, repo := range cfg.AllowTargets.Repos { + if repo == "" || !strings.Contains(repo, "/") { + return fmt.Errorf("create_issues.allow_targets.repos entry %q must be owner/name format", repo) + } + } + return nil +} +``` + +Add the same `validateCreateIssues` call to `PerRepoConfig.Validate()`. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd internal/config && go test -v ./...` +Expected: all tests pass including new `CreateIssues` tests. + +- [ ] **Step 8: Commit** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit -S -s -m "feat(config): add create_issues allowlist config (#401) + +Add CreateIssuesConfig and AllowTargets types to both OrgConfig and +PerRepoConfig. NewOrgConfig populates defaults with the org and +fullsend-ai/fullsend. NewPerRepoConfig populates with the target repo +and fullsend-ai/fullsend. + +Assisted-by: Claude Opus 4.6 " +``` + +### Task 2: Fix callers of `NewOrgConfig` and `NewPerRepoConfig` + +**Files:** +- Modify: `internal/cli/admin.go` +- Modify: `internal/cli/github.go` +- Modify: `internal/cli/admin_test.go` +- Modify: `internal/cli/github_test.go` +- Modify: `internal/layers/configrepo_test.go` + +Task 1 changed the signatures of `NewOrgConfig` (added `org string`) and `NewPerRepoConfig` (added `targetRepo string`). All callers must be updated. + +- [ ] **Step 1: Find all call sites and update them** + +Update each `NewOrgConfig(...)` call to pass the `org` variable as the final argument. The `org` variable is already in scope at every call site in `admin.go` and `github.go`. + +In `internal/cli/github.go:464`: +```go +orgCfg := config.NewOrgConfig(repoNames, enabledRepos, roles, dummyAgents, inferenceProviderName, org) +``` + +In `internal/cli/github.go:513`: +```go +orgCfg = config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName, org) +``` + +In `internal/cli/admin.go:1174`: +```go +cfg := config.NewOrgConfig(repoNames, enabledRepos, roles, nil, inferenceProviderName, org) +``` + +In `internal/cli/admin.go:1502`: +```go +cfg := config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName, org) +``` + +In `internal/cli/admin.go:1640`: +```go +emptyCfg := config.NewOrgConfig(nil, nil, nil, nil, "", "") +``` + +In `internal/cli/admin.go:1781`: +```go +cfg := config.NewOrgConfig(repoNames, nil, defaultRoles, nil, "", org) +``` + +Update each `NewPerRepoConfig(...)` call to pass `cfg.target` (the `owner/repo` string): + +In `internal/cli/github.go:210`: +```go +perRepoCfg := config.NewPerRepoConfig(roles, cfg.target) +``` + +In `internal/cli/admin.go:647`: +```go +cfg := config.NewPerRepoConfig(roles, target) +``` +(Check the variable name — it may be `cfg.target` or `target` depending on the function scope.) + +Update test call sites — these typically pass `""` for the new parameters since tests don't care about create_issues defaults: + +In `internal/cli/admin_test.go:583`: +```go +return config.NewOrgConfig(repoNames, enabledRepos, []string{"triage"}, nil, "", "") +``` + +In `internal/cli/admin_test.go:1082`, `1123`: +```go +config.NewOrgConfig(..., "") +``` + +In `internal/cli/github_test.go:395`: +```go +cfg := config.NewOrgConfig([]string{"widget"}, []string{"widget"}, []string{"triage"}, nil, "", "") +``` + +In `internal/config/config_test.go`, update existing tests that call `NewOrgConfig` without the org param: + +`TestNewOrgConfig`: add `""` as last arg. +`TestNewOrgConfig_WithInferenceProvider`: change to `NewOrgConfig(nil, nil, nil, nil, "vertex", "")`. +`TestNewOrgConfig_WithoutInferenceProvider`: change to `NewOrgConfig(nil, nil, nil, nil, "", "")`. +`TestNewOrgConfig_KillSwitchDefaultFalse`: change to `NewOrgConfig(nil, nil, []string{"fullsend"}, nil, "", "")`. + +In `internal/config/config_test.go`, update existing tests for `NewPerRepoConfig`: + +`TestNewPerRepoConfig_DefaultRoles`: change to `NewPerRepoConfig(nil, "")`. +`TestNewPerRepoConfig_CustomRoles`: change to `NewPerRepoConfig([]string{"triage", "review"}, "")`. +`TestPerRepoConfig_RoundTrip`: change to `NewPerRepoConfig([]string{...}, "")`. + +In `internal/layers/configrepo_test.go`, update any `NewOrgConfig` / `NewPerRepoConfig` calls similarly. + +- [ ] **Step 2: Run full test suite to verify** + +Run: `make go-test` +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add internal/cli/admin.go internal/cli/github.go internal/cli/admin_test.go internal/cli/github_test.go internal/config/config_test.go internal/layers/configrepo_test.go +git commit -S -s -m "refactor: update NewOrgConfig/NewPerRepoConfig callers for create_issues (#401) + +Pass org name and target repo to config constructors so create_issues +defaults are populated at install time. + +Assisted-by: Claude Opus 4.6 " +``` + +### Task 3: Update triage result JSON schema + +**Files:** +- Modify: `internal/scaffold/fullsend-repo/schemas/triage-result.schema.json` +- Test: `internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh` (if it exists) + +- [ ] **Step 1: Replace `blocked` with `prerequisites` in action enum** + +In `triage-result.schema.json`, change line 12: + +```json +"enum": ["insufficient", "duplicate", "sufficient", "prerequisites", "question"] +``` + +- [ ] **Step 2: Remove the `blocked_by` property** + +Delete lines 33-37 (the `blocked_by` property). + +- [ ] **Step 3: Add the `prerequisites` property definition** + +Add to the `properties` object: + +```json +"prerequisites": { + "type": "object", + "required": ["existing", "create"], + "properties": { + "existing": { + "type": "array", + "items": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "pattern": "^https://github\\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/(issues|pull)/[0-9]+$" + } + }, + "additionalProperties": false + } + }, + "create": { + "type": "array", + "items": { + "type": "object", + "required": ["repo", "title", "body"], + "properties": { + "repo": { + "type": "string", + "pattern": "^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} +``` + +- [ ] **Step 4: Update the conditional validation** + +Replace the `blocked` conditional (the `allOf` entry at lines 55-58): + +```json +{ + "if": { "properties": { "action": { "const": "prerequisites" } }, "required": ["action"] }, + "then": { + "required": ["prerequisites"], + "properties": { + "prerequisites": { + "anyOf": [ + { "properties": { "existing": { "minItems": 1 } } }, + { "properties": { "create": { "minItems": 1 } } } + ] + } + } + } +} +``` + +- [ ] **Step 5: Validate the schema is valid JSON** + +Run: `jq empty internal/scaffold/fullsend-repo/schemas/triage-result.schema.json` +Expected: no output (valid JSON). + +- [ ] **Step 6: Test with sample inputs** + +Create a temp file `/tmp/test-prereq.json`: + +```json +{ + "action": "prerequisites", + "reasoning": "Blocked by upstream work", + "comment": "This needs upstream changes first.", + "prerequisites": { + "existing": [{"url": "https://github.com/org/repo/issues/42"}], + "create": [{"repo": "org/upstream", "title": "Add X", "body": "Need X for downstream."}] + } +} +``` + +Run the schema validator if available: +```bash +fullsend-check-output /tmp/test-prereq.json 2>&1 || echo "Manual validation needed" +``` + +Also test that a `prerequisites` result with both arrays empty is rejected, and that the old `blocked` action is rejected. + +- [ ] **Step 7: Commit** + +```bash +git add internal/scaffold/fullsend-repo/schemas/triage-result.schema.json +git commit -S -s -m "feat(schema): replace blocked with prerequisites action (#401) + +Replace the blocked action and blocked_by field with a prerequisites +action containing existing[] and create[] arrays. At least one array +must be non-empty. + +Assisted-by: Claude Opus 4.6 " +``` + +### Task 4: Update the triage agent prompt + +**Files:** +- Modify: `internal/scaffold/fullsend-repo/agents/triage.md` + +- [ ] **Step 1: Replace the `blocked` action section** + +Replace the "Action: `blocked`" section (lines 182-195) with: + +```markdown +### Action: `prerequisites` + +Progress on this issue depends on work that must happen first — either in this repository or another. Use this action when you identify specific blocking dependencies: existing issues/PRs that must be resolved, or upstream work that needs a tracking issue created. + +**HARD CONSTRAINT:** Never emit `sufficient` if unresolved prerequisites exist. Use `prerequisites` instead. + +The `prerequisites` object contains two arrays: + +- `existing` — issues or PRs that already exist and block this work. Include the full HTML URL. +- `create` — issues that need to be filed in other repos before this work can proceed. Include the target `repo` (owner/name format), a `title`, and a `body`. Write the body for the target repo's audience — include enough technical context for upstream maintainers to understand what is needed. Use your judgment on whether to include a back-reference to the originating issue; sometimes it provides helpful context, sometimes it leaks internal details. + +At least one of the two arrays must have entries. + +```json +{ + "action": "prerequisites", + "reasoning": "Brief explanation of the dependencies and why this issue cannot proceed", + "prerequisites": { + "existing": [ + { "url": "https://github.com/org/repo/issues/99" } + ], + "create": [ + { + "repo": "org/upstream-lib", + "title": "Add support for X", + "body": "Technical description of what is needed and why, written for the upstream repo's maintainers." + } + ] + }, + "comment": "A professional comment explaining the blocking dependencies. Link to existing blockers and describe what new issues need to be created upstream. Be specific about why each dependency must be resolved before this issue can proceed." +} +``` +``` + +- [ ] **Step 2: Update the anti-premature-resolution rule** + +In the "Anti-premature-resolution rule" paragraph (line 125), add after the existing hard constraint: + +```markdown +**Anti-premature-prerequisites rule (HARD CONSTRAINT):** If your assessment identifies unresolved prerequisites — dependencies on work in other repos or unmerged changes that must land first — you MUST use `action: "prerequisites"`. Do NOT emit `action: "sufficient"` when prerequisites exist. The `sufficient` action means there are zero blockers and zero open questions. +``` + +- [ ] **Step 3: Update Step 3 Phase 3 to reference prerequisites** + +In Phase 3 (line 108), update the last bullet: + +```markdown +- **Is progress blocked on other work?** Consider whether the fix depends on an unresolved issue or unmerged PR — in this repo or another. If a developer cannot meaningfully start work until some other issue is resolved, this issue has prerequisites regardless of how clear the problem description is. If the blocking work has no tracking issue yet, you can recommend creating one via the `prerequisites` action's `create` array. +``` + +- [ ] **Step 4: Update Step 2c to reference prerequisites instead of blocked** + +In section 2c (line 66-77), update the heading and text to say "Check existing prerequisites" instead of "Check existing blockers", and reference the `prerequisites` action instead of `blocked`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scaffold/fullsend-repo/agents/triage.md +git commit -S -s -m "feat(triage): replace blocked action with prerequisites in agent prompt (#401) + +The triage agent can now recommend creating upstream issues via the +prerequisites action's create array, in addition to referencing existing +blockers. Adds hard constraint against emitting sufficient when +prerequisites exist. + +Assisted-by: Claude Opus 4.6 " +``` + +### Task 5: Update the post-script to handle `prerequisites` + +**Files:** +- Modify: `internal/scaffold/fullsend-repo/scripts/post-triage.sh` + +- [ ] **Step 1: Replace the `blocked)` case with `prerequisites)`** + +Replace the entire `blocked)` case (lines 122-141) with: + +```bash + prerequisites) + if [[ -z "${COMMENT}" ]]; then + echo "ERROR: action is 'prerequisites' but no comment provided" + exit 1 + fi + + # Read the allowlist from config.yaml. The config repo is checked out + # at $GITHUB_WORKSPACE by the reusable workflow. + CONFIG_FILE="${GITHUB_WORKSPACE}/config.yaml" + if [[ ! -f "${CONFIG_FILE}" ]]; then + # Per-repo mode: config is under .fullsend/ + CONFIG_FILE="${GITHUB_WORKSPACE}/.fullsend/config.yaml" + fi + + ALLOWED_ORGS="" + ALLOWED_REPOS="" + if [[ -f "${CONFIG_FILE}" ]] && command -v yq &>/dev/null; then + ALLOWED_ORGS=$(yq -r '.create_issues.allow_targets.orgs // [] | .[]' "${CONFIG_FILE}" 2>/dev/null || true) + ALLOWED_REPOS=$(yq -r '.create_issues.allow_targets.repos // [] | .[]' "${CONFIG_FILE}" 2>/dev/null || true) + fi + + # The source repo is always implicitly allowed. + SOURCE_ORG="${REPO%%/*}" + + is_target_allowed() { + local target_repo="$1" + local target_org="${target_repo%%/*}" + + # Source repo is always allowed. + if [[ "${target_repo}" == "${REPO}" ]]; then + return 0 + fi + + # Check org allowlist. + if [[ -n "${ALLOWED_ORGS}" ]] && echo "${ALLOWED_ORGS}" | grep -qFx "${target_org}"; then + return 0 + fi + + # Check repo allowlist. + if [[ -n "${ALLOWED_REPOS}" ]] && echo "${ALLOWED_REPOS}" | grep -qFx "${target_repo}"; then + return 0 + fi + + return 1 + } + + # Process create entries: create issues, collect URLs. + CREATE_COUNT=$(jq '.prerequisites.create // [] | length' "${RESULT_FILE}") + CREATED_URLS="" + FAILED_CREATES="" + + for i in $(seq 0 $((CREATE_COUNT - 1))); do + TARGET_REPO=$(jq -r ".prerequisites.create[${i}].repo" "${RESULT_FILE}") + ISSUE_TITLE=$(jq -r ".prerequisites.create[${i}].title" "${RESULT_FILE}") + ISSUE_BODY=$(jq -r ".prerequisites.create[${i}].body" "${RESULT_FILE}") + + if ! is_target_allowed "${TARGET_REPO}"; then + echo "::warning::Skipping issue creation in '${TARGET_REPO}' — not in create_issues.allow_targets" + FAILED_CREATES="${FAILED_CREATES} +
+Prerequisite: ${TARGET_REPO} — ${ISSUE_TITLE} + +${ISSUE_BODY} + +
" + continue + fi + + echo "Creating prerequisite issue in ${TARGET_REPO}..." + CREATED_URL=$(gh issue create --repo "${TARGET_REPO}" --title "${ISSUE_TITLE}" --body "${ISSUE_BODY}" 2>&1) || { + echo "::warning::Failed to create issue in '${TARGET_REPO}': ${CREATED_URL}" + FAILED_CREATES="${FAILED_CREATES} +
+Prerequisite: ${TARGET_REPO} — ${ISSUE_TITLE} + +${ISSUE_BODY} + +
" + continue + } + echo "Created: ${CREATED_URL}" + CREATED_URLS="${CREATED_URLS} ${CREATED_URL}" + done + + # Collect existing URLs. + EXISTING_COUNT=$(jq '.prerequisites.existing // [] | length' "${RESULT_FILE}") + EXISTING_URLS="" + for i in $(seq 0 $((EXISTING_COUNT - 1))); do + URL=$(jq -r ".prerequisites.existing[${i}].url" "${RESULT_FILE}") + EXISTING_URLS="${EXISTING_URLS} ${URL}" + done + + # Merge all blocker URLs for the comment. + ALL_URLS="${EXISTING_URLS} ${CREATED_URLS}" + ALL_URLS=$(echo "${ALL_URLS}" | xargs) # trim whitespace + + if [[ -n "${ALL_URLS}" ]]; then + BLOCKER_LIST="" + for url in ${ALL_URLS}; do + BLOCKER_LIST="${BLOCKER_LIST} +- ${url}" + done + COMMENT="${COMMENT} + +**Blocked by:**${BLOCKER_LIST}" + fi + + if [[ -n "${FAILED_CREATES}" ]]; then + COMMENT="${COMMENT} + +**Could not create automatically** (file manually or update \`create_issues.allow_targets\` in config.yaml): +${FAILED_CREATES}" + fi + + remove_label "ready-to-code" + remove_label "needs-info" + add_label "blocked" + ;; +``` + +- [ ] **Step 2: Verify the script is syntactically valid** + +Run: `bash -n internal/scaffold/fullsend-repo/scripts/post-triage.sh` +Expected: no output (valid syntax). + +- [ ] **Step 3: Commit** + +```bash +git add internal/scaffold/fullsend-repo/scripts/post-triage.sh +git commit -S -s -m "feat(triage): handle prerequisites action in post-script (#401) + +Replace the blocked handler with prerequisites. The post-script reads +the create_issues allowlist from config.yaml, creates permitted upstream +issues via gh, and includes collapsed draft bodies for disallowed or +failed creates so humans can file them manually. + +Assisted-by: Claude Opus 4.6 " +``` + +### Task 6: Update user-facing triage docs + +**Files:** +- Modify: `docs/agents/triage.md` + +- [ ] **Step 1: Update control labels table** + +Replace the `blocked` row: + +```markdown +| `blocked` | The issue depends on prerequisites — existing issues/PRs or newly created upstream issues. The agent identified or created the blockers. | +``` + +- [ ] **Step 2: Add new section on `create_issues` configuration** + +After the "Configuration and extension" heading, add: + +```markdown +### Cross-repo issue creation + +The triage agent can create prerequisite issues in other repositories when it +identifies upstream dependencies that don't have tracking issues yet. This is +controlled by the `create_issues` section in `config.yaml`: + +```yaml +create_issues: + allow_targets: + orgs: + - my-org + repos: + - upstream-org/specific-repo +``` + +**Defaults:** At install time, fullsend populates this with your org (in org mode) +or your repo (in per-repo mode), plus `fullsend-ai/fullsend` as an upstream target. + +**When to expand the allowlist:** If your project depends on libraries or services +in other GitHub orgs and you want the triage agent to automatically file +prerequisite issues there, add those orgs or repos to `allow_targets`. + +**When to restrict the allowlist:** If you don't want agents creating issues +outside your org, remove entries. If `allow_targets` is empty, automatic +prerequisite creation is disabled entirely — the agent will still identify +the dependency and include a draft issue body in its comment for a human to +file manually. + +The source repo (where triage is running) is always implicitly allowed +regardless of the allowlist. +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/agents/triage.md +git commit -S -s -m "docs: document prerequisites action and create_issues config (#401) + +Update triage agent docs to explain the new prerequisites action and the +create_issues.allow_targets configuration surface. + +Assisted-by: Claude Opus 4.6 " +``` + +### Task 7: Run linters and full test suite + +**Files:** +- All modified files from Tasks 1-6 + +- [ ] **Step 1: Run linter** + +Run: `make lint` +Expected: no failures. + +- [ ] **Step 2: Run Go tests** + +Run: `make go-test` +Expected: all tests pass. + +- [ ] **Step 3: Run vet** + +Run: `make go-vet` +Expected: no issues. + +- [ ] **Step 4: Fix any issues found and commit fixes** + +If lint or tests reveal issues, fix them and commit. From 9a35c9155f2206c8ebe1df739a8f4793ef2a5bde Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 15:58:04 -0400 Subject: [PATCH 03/13] feat(config): add create_issues allowlist config (#401) Add CreateIssuesConfig and AllowTargets types to both OrgConfig and PerRepoConfig. NewOrgConfig populates defaults with the org and fullsend-ai/fullsend. NewPerRepoConfig populates with the target repo and fullsend-ai/fullsend. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/config/config.go | 64 ++++++++++-- internal/config/config_test.go | 184 +++++++++++++++++++++++++++++++-- 2 files changed, 235 insertions(+), 13 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 674cd1258..420bd820f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,6 +58,17 @@ type RepoConfig struct { Enabled bool `yaml:"enabled"` } +// AllowTargets defines which orgs and repos agents may create issues in. +type AllowTargets struct { + Orgs []string `yaml:"orgs,omitempty"` + Repos []string `yaml:"repos,omitempty"` +} + +// CreateIssuesConfig controls cross-repo issue creation by agents. +type CreateIssuesConfig struct { + AllowTargets AllowTargets `yaml:"allow_targets"` +} + // OrgConfig is the top-level configuration for a fullsend organization. type OrgConfig struct { Version string `yaml:"version"` @@ -68,6 +79,7 @@ type OrgConfig struct { Agents []AgentEntry `yaml:"agents"` Repos map[string]RepoConfig `yaml:"repos"` AllowedRemoteResources []string `yaml:"allowed_remote_resources,omitempty"` + CreateIssues *CreateIssuesConfig `yaml:"create_issues,omitempty"` } // ValidRoles returns the set of recognized agent roles. @@ -95,7 +107,7 @@ func PerRepoDefaultRoles() []string { } // NewOrgConfig creates a new OrgConfig with sensible defaults. -func NewOrgConfig(allRepos, enabledRepos, roles []string, agents []AgentEntry, inferenceProvider string) *OrgConfig { +func NewOrgConfig(allRepos, enabledRepos, roles []string, agents []AgentEntry, inferenceProvider, org string) *OrgConfig { repos := make(map[string]RepoConfig, len(allRepos)) for _, r := range allRepos { repos[r] = RepoConfig{ @@ -119,6 +131,14 @@ func NewOrgConfig(allRepos, enabledRepos, roles []string, agents []AgentEntry, i if inferenceProvider != "" { cfg.Inference = InferenceConfig{Provider: inferenceProvider} } + if org != "" { + cfg.CreateIssues = &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{org}, + Repos: []string{"fullsend-ai/fullsend"}, + }, + } + } return cfg } @@ -180,6 +200,9 @@ func (c *OrgConfig) Validate() error { if err := validateStatusNotifications(c.Defaults.StatusNotifications); err != nil { return err } + if err := validateCreateIssues(c.CreateIssues); err != nil { + return err + } return nil } @@ -238,9 +261,10 @@ func (c *OrgConfig) DefaultRoles() []string { // PerRepoConfig holds configuration for per-repo installation mode. // Stored in .fullsend/config.yaml within the target repository. type PerRepoConfig struct { - Version string `yaml:"version"` - KillSwitch bool `yaml:"kill_switch,omitempty"` - Roles []string `yaml:"roles,omitempty"` + Version string `yaml:"version"` + KillSwitch bool `yaml:"kill_switch,omitempty"` + Roles []string `yaml:"roles,omitempty"` + CreateIssues *CreateIssuesConfig `yaml:"create_issues,omitempty"` } const perRepoConfigHeader = `# fullsend per-repo configuration @@ -251,14 +275,22 @@ const perRepoConfigHeader = `# fullsend per-repo configuration ` // NewPerRepoConfig creates a new PerRepoConfig with the given roles. -func NewPerRepoConfig(roles []string) *PerRepoConfig { +func NewPerRepoConfig(roles []string, targetRepo string) *PerRepoConfig { if roles == nil { roles = DefaultAgentRoles() } - return &PerRepoConfig{ + cfg := &PerRepoConfig{ Version: "1", Roles: roles, } + if targetRepo != "" { + cfg.CreateIssues = &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Repos: []string{targetRepo, "fullsend-ai/fullsend"}, + }, + } + } + return cfg } // ParsePerRepoConfig parses YAML bytes into a PerRepoConfig. @@ -295,5 +327,25 @@ func (c *PerRepoConfig) Validate() error { } seen[role] = true } + if err := validateCreateIssues(c.CreateIssues); err != nil { + return err + } + return nil +} + +func validateCreateIssues(cfg *CreateIssuesConfig) error { + if cfg == nil { + return nil + } + for _, org := range cfg.AllowTargets.Orgs { + if org == "" { + return fmt.Errorf("create_issues: empty org in allow_targets.orgs") + } + } + for _, repo := range cfg.AllowTargets.Repos { + if !strings.Contains(repo, "/") { + return fmt.Errorf("create_issues: repo %q in allow_targets.repos must contain owner/name", repo) + } + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1731f67ef..831663ea3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,7 +41,7 @@ func TestNewOrgConfig(t *testing.T) { {Role: "fullsend", Name: "test", Slug: "test-slug"}, } - cfg := NewOrgConfig(allRepos, enabledRepos, roles, agents, "") + cfg := NewOrgConfig(allRepos, enabledRepos, roles, agents, "", "") assert.Equal(t, "1", cfg.Version) assert.Equal(t, "github-actions", cfg.Dispatch.Platform) @@ -283,12 +283,12 @@ repos: } func TestNewOrgConfig_WithInferenceProvider(t *testing.T) { - cfg := NewOrgConfig(nil, nil, nil, nil, "vertex") + cfg := NewOrgConfig(nil, nil, nil, nil, "vertex", "") assert.Equal(t, "vertex", cfg.Inference.Provider) } func TestNewOrgConfig_WithoutInferenceProvider(t *testing.T) { - cfg := NewOrgConfig(nil, nil, nil, nil, "") + cfg := NewOrgConfig(nil, nil, nil, nil, "", "") assert.Empty(t, cfg.Inference.Provider) } @@ -445,7 +445,7 @@ func TestOrgConfigValidate_FixRole(t *testing.T) { } func TestNewOrgConfig_KillSwitchDefaultFalse(t *testing.T) { - cfg := NewOrgConfig(nil, nil, []string{"fullsend"}, nil, "") + cfg := NewOrgConfig(nil, nil, []string{"fullsend"}, nil, "", "") assert.False(t, cfg.KillSwitch) } @@ -561,14 +561,14 @@ func TestOrgConfigMarshal_WithDispatchMode(t *testing.T) { } func TestNewPerRepoConfig_DefaultRoles(t *testing.T) { - cfg := NewPerRepoConfig(nil) + cfg := NewPerRepoConfig(nil, "") assert.Equal(t, "1", cfg.Version) assert.Equal(t, DefaultAgentRoles(), cfg.Roles) assert.False(t, cfg.KillSwitch) } func TestNewPerRepoConfig_CustomRoles(t *testing.T) { - cfg := NewPerRepoConfig([]string{"triage", "review"}) + cfg := NewPerRepoConfig([]string{"triage", "review"}, "") assert.Equal(t, []string{"triage", "review"}, cfg.Roles) } @@ -664,7 +664,7 @@ func TestPerRepoConfigMarshal_KillSwitchOmitted(t *testing.T) { } func TestPerRepoConfig_RoundTrip(t *testing.T) { - original := NewPerRepoConfig([]string{"fullsend", "triage", "coder", "review", "fix"}) + original := NewPerRepoConfig([]string{"fullsend", "triage", "coder", "review", "fix"}, "") data, err := original.Marshal() require.NoError(t, err) @@ -879,3 +879,173 @@ func TestOrgConfigMarshal_WithoutStatusNotifications(t *testing.T) { require.NoError(t, err) assert.NotContains(t, string(data), "status_notifications") } + +// --- CreateIssues tests --- + +func TestOrgConfig_CreateIssues_ParseYAML(t *testing.T) { + yamlData := ` +version: "1" +dispatch: + platform: github-actions +defaults: + roles: + - fullsend + max_implementation_retries: 2 +agents: [] +repos: {} +create_issues: + allow_targets: + orgs: + - my-org + - other-org + repos: + - external-org/some-repo +` + cfg, err := ParseOrgConfig([]byte(yamlData)) + require.NoError(t, err) + require.NotNil(t, cfg.CreateIssues) + assert.Equal(t, []string{"my-org", "other-org"}, cfg.CreateIssues.AllowTargets.Orgs) + assert.Equal(t, []string{"external-org/some-repo"}, cfg.CreateIssues.AllowTargets.Repos) +} + +func TestOrgConfig_CreateIssues_OmittedWhenEmpty(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + Agents: []AgentEntry{}, + Repos: map[string]RepoConfig{}, + } + data, err := cfg.Marshal() + require.NoError(t, err) + assert.NotContains(t, string(data), "create_issues") +} + +func TestOrgConfig_CreateIssues_Marshal(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + Agents: []AgentEntry{}, + Repos: map[string]RepoConfig{}, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{"my-org"}, + Repos: []string{"other/repo"}, + }, + }, + } + data, err := cfg.Marshal() + require.NoError(t, err) + assert.Contains(t, string(data), "create_issues:") + assert.Contains(t, string(data), "allow_targets:") + assert.Contains(t, string(data), "my-org") + assert.Contains(t, string(data), "other/repo") +} + +func TestOrgConfigValidate_CreateIssues_InvalidRepoFormat(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Repos: []string{"no-slash-here"}, + }, + }, + } + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no-slash-here") +} + +func TestOrgConfigValidate_CreateIssues_EmptyOrg(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{"valid-org", ""}, + }, + }, + } + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty org") +} + +func TestOrgConfigValidate_CreateIssues_Valid(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Orgs: []string{"my-org"}, + Repos: []string{"other/repo"}, + }, + }, + } + err := cfg.Validate() + assert.NoError(t, err) +} + +func TestOrgConfigValidate_CreateIssues_Nil(t *testing.T) { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + } + err := cfg.Validate() + assert.NoError(t, err) +} + +func TestNewOrgConfig_CreateIssuesDefaults(t *testing.T) { + cfg := NewOrgConfig(nil, nil, []string{"fullsend"}, nil, "", "my-org") + require.NotNil(t, cfg.CreateIssues) + assert.Equal(t, []string{"my-org"}, cfg.CreateIssues.AllowTargets.Orgs) + assert.Equal(t, []string{"fullsend-ai/fullsend"}, cfg.CreateIssues.AllowTargets.Repos) +} + +func TestPerRepoConfig_CreateIssues_ParseYAML(t *testing.T) { + yamlData := ` +version: "1" +roles: + - fullsend + - triage +create_issues: + allow_targets: + repos: + - my-org/my-repo + - fullsend-ai/fullsend +` + cfg, err := ParsePerRepoConfig([]byte(yamlData)) + require.NoError(t, err) + require.NotNil(t, cfg.CreateIssues) + assert.Equal(t, []string{"my-org/my-repo", "fullsend-ai/fullsend"}, cfg.CreateIssues.AllowTargets.Repos) +} + +func TestNewPerRepoConfig_CreateIssuesDefaults(t *testing.T) { + cfg := NewPerRepoConfig(nil, "my-org/my-repo") + require.NotNil(t, cfg.CreateIssues) + assert.Equal(t, []string{"my-org/my-repo", "fullsend-ai/fullsend"}, cfg.CreateIssues.AllowTargets.Repos) +} From d4a394ed94d862f1751afeae4e8c58837192ea7a Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 16:18:40 -0400 Subject: [PATCH 04/13] refactor: update NewOrgConfig/NewPerRepoConfig callers for create_issues (#401) Pass org name and target repo to config constructors so create_issues defaults are populated at install time. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/cli/admin.go | 10 +++++----- internal/cli/admin_test.go | 4 +++- internal/cli/github.go | 6 +++--- internal/cli/github_test.go | 2 +- internal/layers/configrepo_test.go | 1 + 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 0e23ad809..2ae1f7312 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -644,7 +644,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { printer.StepWarn("Using provided WIF provider value — skipping inference provider auto-provisioning") } - cfg := config.NewPerRepoConfig(roles) + cfg := config.NewPerRepoConfig(roles, repoFullName) if err := cfg.Validate(); err != nil { return fmt.Errorf("invalid config: %w", err) } @@ -1171,7 +1171,7 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or } // Build config with empty agents for analysis. - cfg := config.NewOrgConfig(repoNames, enabledRepos, roles, nil, inferenceProviderName) + cfg := config.NewOrgConfig(repoNames, enabledRepos, roles, nil, inferenceProviderName, org) cfg.Dispatch.Mode = "oidc-mint" user, err := client.GetAuthenticatedUser(ctx) @@ -1499,7 +1499,7 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o agents[i] = ac.AgentEntry } - cfg := config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName) + cfg := config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName, org) cfg.Dispatch.Mode = "oidc-mint" user, err := client.GetAuthenticatedUser(ctx) @@ -1637,7 +1637,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, // Build a minimal stack for uninstall. // Only ConfigRepoLayer matters for uninstall since other layers are no-ops. - emptyCfg := config.NewOrgConfig(nil, nil, nil, nil, "") + emptyCfg := config.NewOrgConfig(nil, nil, nil, nil, "", "") stack := layers.NewStack( layers.NewConfigRepoLayer(org, client, emptyCfg, printer, false), layers.NewWorkflowsLayer(org, client, printer, "", version), @@ -1778,7 +1778,7 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o }) } - cfg := config.NewOrgConfig(repoNames, nil, defaultRoles, nil, "") + cfg := config.NewOrgConfig(repoNames, nil, defaultRoles, nil, "", org) user, err := client.GetAuthenticatedUser(ctx) if err != nil { diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 703b6f08c..02aa7fa9c 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -580,7 +580,7 @@ func setupTestConfig(repos map[string]bool) *config.OrgConfig { // Sort to ensure deterministic order despite map iteration being non-deterministic. sort.Strings(repoNames) sort.Strings(enabledRepos) - return config.NewOrgConfig(repoNames, enabledRepos, []string{"triage"}, nil, "") + return config.NewOrgConfig(repoNames, enabledRepos, []string{"triage"}, nil, "", "") } func setupTestClient(org string, cfg *config.OrgConfig, orgRepos []string) *forge.FakeClient { @@ -1085,6 +1085,7 @@ func TestBuildLayerStack_NilEnabledRepos_SkipsDisabledRepos(t *testing.T) { []string{"triage"}, nil, "", + "", ) printer := ui.New(&discardWriter{}) @@ -1126,6 +1127,7 @@ func TestBuildLayerStack_EmptyEnabledRepos_IncludesDisabledRepos(t *testing.T) { []string{"triage"}, nil, "", + "", ) printer := ui.New(&discardWriter{}) diff --git a/internal/cli/github.go b/internal/cli/github.go index ed695b721..7548e5911 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -207,7 +207,7 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui printer.StepInfo("Reusing existing FULLSEND_GCP_WIF_PROVIDER from " + cfg.target) } - perRepoCfg := config.NewPerRepoConfig(roles) + perRepoCfg := config.NewPerRepoConfig(roles, cfg.target) if err := perRepoCfg.Validate(); err != nil { return fmt.Errorf("invalid config: %w", err) } @@ -461,7 +461,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. for i, ac := range agentCreds { dummyAgents[i] = ac.AgentEntry } - orgCfg := config.NewOrgConfig(repoNames, enabledRepos, roles, dummyAgents, inferenceProviderName) + orgCfg := config.NewOrgConfig(repoNames, enabledRepos, roles, dummyAgents, inferenceProviderName, org) orgCfg.Dispatch.Mode = "oidc-mint" user, err := client.GetAuthenticatedUser(ctx) @@ -510,7 +510,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. for i, ac := range agentCreds { agents[i] = ac.AgentEntry } - orgCfg = config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName) + orgCfg = config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName, org) orgCfg.Dispatch.Mode = "oidc-mint" stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendorBinary, vendorFn, dispatcher) diff --git a/internal/cli/github_test.go b/internal/cli/github_test.go index 3761e7477..db7d29db7 100644 --- a/internal/cli/github_test.go +++ b/internal/cli/github_test.go @@ -392,7 +392,7 @@ func TestRunGitHubStatus_BasicReport(t *testing.T) { client.Repos = []forge.Repository{ {Name: ".fullsend", FullName: "acme/.fullsend"}, } - cfg := config.NewOrgConfig([]string{"widget"}, []string{"widget"}, []string{"triage"}, nil, "") + cfg := config.NewOrgConfig([]string{"widget"}, []string{"widget"}, []string{"triage"}, nil, "", "") cfgData, _ := cfg.Marshal() client.FileContents["acme/.fullsend/config.yaml"] = cfgData client.OrgVariables = map[string]bool{"acme/FULLSEND_MINT_URL": true} diff --git a/internal/layers/configrepo_test.go b/internal/layers/configrepo_test.go index ebf807956..3277fa5e7 100644 --- a/internal/layers/configrepo_test.go +++ b/internal/layers/configrepo_test.go @@ -22,6 +22,7 @@ func newTestConfig(t *testing.T) *config.OrgConfig { []string{"coder"}, []config.AgentEntry{{Role: "coder", Name: "Bot", Slug: "bot-slug"}}, "", + "", ) } From e492ac78f23be1cefe473415c318e59c62e5aa80 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 16:24:40 -0400 Subject: [PATCH 05/13] feat(schema): replace blocked with prerequisites action (#401) Replace the blocked action and blocked_by field with a prerequisites action containing existing[] and create[] arrays. At least one array must be non-empty. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- .../schemas/triage-result.schema.json | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/scaffold/fullsend-repo/schemas/triage-result.schema.json b/internal/scaffold/fullsend-repo/schemas/triage-result.schema.json index a80948d30..73616cab7 100644 --- a/internal/scaffold/fullsend-repo/schemas/triage-result.schema.json +++ b/internal/scaffold/fullsend-repo/schemas/triage-result.schema.json @@ -9,7 +9,7 @@ "properties": { "action": { "type": "string", - "enum": ["insufficient", "duplicate", "sufficient", "blocked", "question"] + "enum": ["insufficient", "duplicate", "sufficient", "prerequisites", "question"] }, "reasoning": { "type": "string", @@ -30,10 +30,48 @@ "triage_summary": { "$ref": "#/$defs/triage_summary" }, - "blocked_by": { - "type": "string", - "pattern": "^https://github\\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/(issues|pull)/[0-9]+$", - "description": "HTML URL of the blocking issue or PR (e.g., https://github.com/org/repo/issues/99 or https://github.com/org/repo/pull/55)" + "prerequisites": { + "type": "object", + "required": ["existing", "create"], + "properties": { + "existing": { + "type": "array", + "items": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "pattern": "^https://github\\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/(issues|pull)/[0-9]+$" + } + }, + "additionalProperties": false + } + }, + "create": { + "type": "array", + "items": { + "type": "object", + "required": ["repo", "title", "body"], + "properties": { + "repo": { + "type": "string", + "pattern": "^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false }, "label_actions": { "$ref": "#/$defs/label_actions" @@ -53,8 +91,18 @@ "then": { "required": ["clarity_scores", "triage_summary"] } }, { - "if": { "properties": { "action": { "const": "blocked" } }, "required": ["action"] }, - "then": { "required": ["blocked_by"] } + "if": { "properties": { "action": { "const": "prerequisites" } }, "required": ["action"] }, + "then": { + "required": ["prerequisites"], + "properties": { + "prerequisites": { + "anyOf": [ + { "properties": { "existing": { "minItems": 1 } } }, + { "properties": { "create": { "minItems": 1 } } } + ] + } + } + } } ], "$defs": { From b2055cb18a3b03bbe70aa74c92e12c9355d8d752 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 16:24:41 -0400 Subject: [PATCH 06/13] feat(triage): replace blocked action with prerequisites in agent prompt (#401) The triage agent can now recommend creating upstream issues via the prerequisites action's create array, in addition to referencing existing blockers. Adds hard constraint against emitting sufficient when prerequisites exist. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- .../scaffold/fullsend-repo/agents/triage.md | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/internal/scaffold/fullsend-repo/agents/triage.md b/internal/scaffold/fullsend-repo/agents/triage.md index c71b3c12f..78ccb5ff5 100644 --- a/internal/scaffold/fullsend-repo/agents/triage.md +++ b/internal/scaffold/fullsend-repo/agents/triage.md @@ -63,9 +63,9 @@ gh pr list --repo OTHER-ORG/OTHER-REPO --state open --search "relevant keywords" If a cross-repo search fails or returns an error (e.g., due to access restrictions), note this in your reasoning as an information gap rather than concluding no blocking work exists. -### 2c. Check existing blockers +### 2c. Check existing prerequisites -If the issue already has a `blocked` label, check whether the previously identified blocker (linked in prior triage comments) is still open. Fetch the full context of the blocking issue or PR to understand its current state: +If the issue already has a `prerequisites` label, check whether the previously identified blocker (linked in prior triage comments) is still open. Fetch the full context of the blocking issue or PR to understand its current state: ``` # For blocking issues: @@ -105,7 +105,7 @@ Use this phased approach to evaluate the issue: ### Phase 3 — Hypothesis formation and dependency analysis - Can you form a plausible root cause hypothesis from the available information? - Could a developer start investigating without contacting the reporter? -- **Is progress blocked on other work?** Consider whether the fix depends on an unresolved issue or unmerged PR — in this repo or another. If a developer cannot meaningfully start work until some other issue is resolved, this issue is blocked regardless of how clear the problem description is. +- **Is progress blocked on other work?** Consider whether the fix depends on an unresolved issue or unmerged PR — in this repo or another. If a developer cannot meaningfully start work until some other issue is resolved, this issue has prerequisites regardless of how clear the problem description is. If the blocking work has no tracking issue yet, you can recommend creating one via the `prerequisites` action's `create` array. ### Clarity scoring @@ -124,6 +124,8 @@ Calculate overall clarity: `symptom*0.35 + cause*0.30 + reproduction*0.20 + impa **Anti-premature-resolution rule (HARD CONSTRAINT):** If your assessment identifies ANY open questions or information gaps — regardless of whether they seem minor — you MUST use `action: "insufficient"` and ask a clarifying question. Do NOT emit `action: "sufficient"` with information gaps. The `sufficient` action means there are zero open questions that could affect implementation. When in doubt, ask. +**Anti-premature-prerequisites rule (HARD CONSTRAINT):** If your assessment identifies unresolved prerequisites — dependencies on work in other repos or unmerged changes that must land first — you MUST use `action: "prerequisites"`. Do NOT emit `action: "sufficient"` when prerequisites exist. The `sufficient` action means there are zero blockers and zero open questions. + ## Step 4: Decide and write result Based on your assessment, choose exactly one action and write the result as JSON to `$FULLSEND_OUTPUT_DIR/agent-result.json`. @@ -179,18 +181,36 @@ This issue describes the same problem as an existing open issue. } ``` -### Action: `blocked` +### Action: `prerequisites` + +Progress on this issue depends on work that must happen first — either in this repository or another. Use this action when you identify specific blocking dependencies: existing issues/PRs that must be resolved, or upstream work that needs a tracking issue created. + +**HARD CONSTRAINT:** Never emit `sufficient` if unresolved prerequisites exist. Use `prerequisites` instead. -Progress on this issue is blocked by another issue or PR — either in this repository or a different one. The blocking issue must be resolved before work on this issue can proceed. Do NOT apply `ready-to-code` for blocked issues. +The `prerequisites` object contains two arrays: -Only use `blocked` when you can identify a specific open issue or PR that must be resolved first. If you suspect a dependency but cannot find a concrete blocking issue, use `insufficient` to ask the reporter whether there is a blocking dependency and to provide its URL. +- `existing` — issues or PRs that already exist and block this work. Include the full HTML URL. +- `create` — issues that need to be filed in other repos before this work can proceed. Include the target `repo` (owner/name format), a `title`, and a `body`. Write the body for the target repo's audience — include enough technical context for upstream maintainers to understand what is needed. Use your judgment on whether to include a back-reference to the originating issue; sometimes it provides helpful context, sometimes it leaks internal details. + +At least one of the two arrays must have entries. ```json { - "action": "blocked", - "reasoning": "Brief explanation of why this issue is blocked and what the dependency is", - "blocked_by": "https://github.com/org/repo/issues/99", - "comment": "A professional comment explaining the blocking dependency. Link to the blocking issue or PR and explain why this issue cannot proceed until it is resolved. Be specific about the dependency — what does the blocking issue provide or unblock?" + "action": "prerequisites", + "reasoning": "Brief explanation of the dependencies and why this issue cannot proceed", + "prerequisites": { + "existing": [ + { "url": "https://github.com/org/repo/issues/99" } + ], + "create": [ + { + "repo": "org/upstream-lib", + "title": "Add support for X", + "body": "Technical description of what is needed and why, written for the upstream repo's maintainers." + } + ] + }, + "comment": "A professional comment explaining the blocking dependencies. Link to existing blockers and describe what new issues need to be created upstream. Be specific about why each dependency must be resolved before this issue can proceed." } ``` From c48a83206d6dfa3ae5eba6835ad87cb0fb5235df Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 16:28:21 -0400 Subject: [PATCH 07/13] docs: document prerequisites action and create_issues config (#401) Update triage agent docs to explain the new prerequisites action and the create_issues.allow_targets configuration surface. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- docs/agents/triage.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/agents/triage.md b/docs/agents/triage.md index aa526068a..a14dbb3ce 100644 --- a/docs/agents/triage.md +++ b/docs/agents/triage.md @@ -40,7 +40,7 @@ outcome and the post-script applies the corresponding label. | `ready-to-code` | The issue is fully specified and low-risk (bug, documentation, performance). Triggers the [code agent](code.md). | | `triaged` | The issue is fully specified but is a feature or other category that requires human prioritization before coding. | | `duplicate` | The issue duplicates an existing one. The agent identified the original and the post-script closes the issue. | -| `blocked` | The issue depends on another issue or external condition. The agent identified the blocker. | +| `blocked` | The issue depends on prerequisites — existing issues/PRs or newly created upstream issues. The agent identified or created the blockers. | | `question` | The issue is a support request or question, not an actionable bug or feature. The agent attempted to answer it. | The `issue-labels` skill may also apply contextual labels (e.g., `area/api`, @@ -48,6 +48,37 @@ The `issue-labels` skill may also apply contextual labels (e.g., `area/api`, ## Configuration and extension +### Cross-repo issue creation + +The triage agent can create prerequisite issues in other repositories when it +identifies upstream dependencies that don't have tracking issues yet. This is +controlled by the `create_issues` section in `config.yaml`: + +```yaml +create_issues: + allow_targets: + orgs: + - my-org + repos: + - upstream-org/specific-repo +``` + +**Defaults:** At install time, fullsend populates this with your org (in org mode) +or your repo (in per-repo mode), plus `fullsend-ai/fullsend` as an upstream target. + +**When to expand the allowlist:** If your project depends on libraries or services +in other GitHub orgs and you want the triage agent to automatically file +prerequisite issues there, add those orgs or repos to `allow_targets`. + +**When to restrict the allowlist:** If you don't want agents creating issues +outside your org, remove entries. If `allow_targets` is empty, automatic +prerequisite creation is disabled entirely — the agent will still identify +the dependency and include a draft issue body in its comment for a human to +file manually. + +The source repo (where triage is running) is always implicitly allowed +regardless of the allowlist. + ### Skill: `issue-labels` The triage agent includes a built-in `issue-labels` skill that discovers your From 3a44b0ccfbb6b6a69820378fa3f1c5ede2ddecff Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 16:28:23 -0400 Subject: [PATCH 08/13] feat(triage): handle prerequisites action in post-script (#401) Replace the blocked handler with prerequisites. The post-script reads the create_issues allowlist from config.yaml, creates permitted upstream issues via gh, and includes collapsed draft bodies for disallowed or failed creates so humans can file them manually. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- .../fullsend-repo/scripts/post-triage.sh | 122 ++++++++++++++++-- 1 file changed, 110 insertions(+), 12 deletions(-) diff --git a/internal/scaffold/fullsend-repo/scripts/post-triage.sh b/internal/scaffold/fullsend-repo/scripts/post-triage.sh index f8ae5e965..83e04d2a6 100755 --- a/internal/scaffold/fullsend-repo/scripts/post-triage.sh +++ b/internal/scaffold/fullsend-repo/scripts/post-triage.sh @@ -119,22 +119,120 @@ case "${ACTION}" in add_label "duplicate" ;; - blocked) - # NOTE: There is no automatic mechanism to remove the "blocked" label when - # the blocking issue is resolved. Currently, editing the issue re-triggers - # triage, and the agent checks whether existing blockers are still open - # (Step 2c in triage.md). A scheduled workflow to check blocked issues - # periodically would be a more complete solution. (See review notes.) + prerequisites) if [[ -z "${COMMENT}" ]]; then - echo "ERROR: action is 'blocked' but no comment provided" + echo "ERROR: action is 'prerequisites' but no comment provided" exit 1 fi - BLOCKED_BY=$(jq -r '.blocked_by // empty' "${RESULT_FILE}") - if [[ -z "${BLOCKED_BY}" ]]; then - echo "ERROR: action is 'blocked' but no blocked_by URL provided" - exit 1 + + # Read the allowlist from config.yaml. The config repo is checked out + # at $GITHUB_WORKSPACE by the reusable workflow. + CONFIG_FILE="${GITHUB_WORKSPACE}/config.yaml" + if [[ ! -f "${CONFIG_FILE}" ]]; then + # Per-repo mode: config is under .fullsend/ + CONFIG_FILE="${GITHUB_WORKSPACE}/.fullsend/config.yaml" + fi + + ALLOWED_ORGS="" + ALLOWED_REPOS="" + if [[ -f "${CONFIG_FILE}" ]] && command -v yq &>/dev/null; then + ALLOWED_ORGS=$(yq -r '.create_issues.allow_targets.orgs // [] | .[]' "${CONFIG_FILE}" 2>/dev/null || true) + ALLOWED_REPOS=$(yq -r '.create_issues.allow_targets.repos // [] | .[]' "${CONFIG_FILE}" 2>/dev/null || true) + fi + + # The source repo is always implicitly allowed. + SOURCE_ORG="${REPO%%/*}" + + is_target_allowed() { + local target_repo="$1" + local target_org="${target_repo%%/*}" + + # Source repo is always allowed. + if [[ "${target_repo}" == "${REPO}" ]]; then + return 0 + fi + + # Check org allowlist. + if [[ -n "${ALLOWED_ORGS}" ]] && echo "${ALLOWED_ORGS}" | grep -qFx "${target_org}"; then + return 0 + fi + + # Check repo allowlist. + if [[ -n "${ALLOWED_REPOS}" ]] && echo "${ALLOWED_REPOS}" | grep -qFx "${target_repo}"; then + return 0 + fi + + return 1 + } + + # Process create entries: create issues, collect URLs. + CREATE_COUNT=$(jq '.prerequisites.create // [] | length' "${RESULT_FILE}") + CREATED_URLS="" + FAILED_CREATES="" + + for i in $(seq 0 $((CREATE_COUNT - 1))); do + TARGET_REPO=$(jq -r ".prerequisites.create[${i}].repo" "${RESULT_FILE}") + ISSUE_TITLE=$(jq -r ".prerequisites.create[${i}].title" "${RESULT_FILE}") + ISSUE_BODY=$(jq -r ".prerequisites.create[${i}].body" "${RESULT_FILE}") + + if ! is_target_allowed "${TARGET_REPO}"; then + echo "::warning::Skipping issue creation in '${TARGET_REPO}' — not in create_issues.allow_targets" + FAILED_CREATES="${FAILED_CREATES} +
+Prerequisite: ${TARGET_REPO} — ${ISSUE_TITLE} + +${ISSUE_BODY} + +
" + continue + fi + + echo "Creating prerequisite issue in ${TARGET_REPO}..." + CREATED_URL=$(gh issue create --repo "${TARGET_REPO}" --title "${ISSUE_TITLE}" --body "${ISSUE_BODY}" 2>&1) || { + echo "::warning::Failed to create issue in '${TARGET_REPO}': ${CREATED_URL}" + FAILED_CREATES="${FAILED_CREATES} +
+Prerequisite: ${TARGET_REPO} — ${ISSUE_TITLE} + +${ISSUE_BODY} + +
" + continue + } + echo "Created: ${CREATED_URL}" + CREATED_URLS="${CREATED_URLS} ${CREATED_URL}" + done + + # Collect existing URLs. + EXISTING_COUNT=$(jq '.prerequisites.existing // [] | length' "${RESULT_FILE}") + EXISTING_URLS="" + for i in $(seq 0 $((EXISTING_COUNT - 1))); do + URL=$(jq -r ".prerequisites.existing[${i}].url" "${RESULT_FILE}") + EXISTING_URLS="${EXISTING_URLS} ${URL}" + done + + # Merge all blocker URLs for the comment. + ALL_URLS="${EXISTING_URLS} ${CREATED_URLS}" + ALL_URLS=$(echo "${ALL_URLS}" | xargs) # trim whitespace + + if [[ -n "${ALL_URLS}" ]]; then + BLOCKER_LIST="" + for url in ${ALL_URLS}; do + BLOCKER_LIST="${BLOCKER_LIST} +- ${url}" + done + COMMENT="${COMMENT} + +**Blocked by:**${BLOCKER_LIST}" fi - echo "Blocked by: ${BLOCKED_BY}" + + if [[ -n "${FAILED_CREATES}" ]]; then + COMMENT="${COMMENT} + +**Could not create automatically** (file manually or update \`create_issues.allow_targets\` in config.yaml): +${FAILED_CREATES}" + fi + remove_label "ready-to-code" remove_label "needs-info" add_label "blocked" From 6f79d87ac8d265e77d9550674acd8bb2ead0df96 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 16:34:25 -0400 Subject: [PATCH 09/13] fix(triage): correct label name in agent prompt and remove dead code (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent prompt referenced a nonexistent `prerequisites` label when checking for prior blockers — the post-script actually applies the `blocked` label. Also removed unused SOURCE_ORG variable from post-triage.sh. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/scaffold/fullsend-repo/agents/triage.md | 2 +- internal/scaffold/fullsend-repo/scripts/post-triage.sh | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/scaffold/fullsend-repo/agents/triage.md b/internal/scaffold/fullsend-repo/agents/triage.md index 78ccb5ff5..71a8305aa 100644 --- a/internal/scaffold/fullsend-repo/agents/triage.md +++ b/internal/scaffold/fullsend-repo/agents/triage.md @@ -65,7 +65,7 @@ If a cross-repo search fails or returns an error (e.g., due to access restrictio ### 2c. Check existing prerequisites -If the issue already has a `prerequisites` label, check whether the previously identified blocker (linked in prior triage comments) is still open. Fetch the full context of the blocking issue or PR to understand its current state: +If the issue already has a `blocked` label, check whether the previously identified blocker (linked in prior triage comments) is still open. Fetch the full context of the blocking issue or PR to understand its current state: ``` # For blocking issues: diff --git a/internal/scaffold/fullsend-repo/scripts/post-triage.sh b/internal/scaffold/fullsend-repo/scripts/post-triage.sh index 83e04d2a6..281180c9b 100755 --- a/internal/scaffold/fullsend-repo/scripts/post-triage.sh +++ b/internal/scaffold/fullsend-repo/scripts/post-triage.sh @@ -141,8 +141,6 @@ case "${ACTION}" in fi # The source repo is always implicitly allowed. - SOURCE_ORG="${REPO%%/*}" - is_target_allowed() { local target_repo="$1" local target_org="${target_repo%%/*}" From 080368cfe2302f08c8508e754aa55d5a8da18d77 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 11 Jun 2026 17:21:00 -0400 Subject: [PATCH 10/13] fix(triage): update post-triage tests for prerequisites action (#401) Replace the four blocked-action test cases with five prerequisites-action test cases that exercise the new schema (existing[], create[], allowlist validation). Set up GITHUB_WORKSPACE with a config.yaml fixture and add a mock gh issue-create handler that returns a fake URL. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- .../fullsend-repo/scripts/post-triage-test.sh | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/internal/scaffold/fullsend-repo/scripts/post-triage-test.sh b/internal/scaffold/fullsend-repo/scripts/post-triage-test.sh index c8b4eb29e..1cf26237e 100755 --- a/internal/scaffold/fullsend-repo/scripts/post-triage-test.sh +++ b/internal/scaffold/fullsend-repo/scripts/post-triage-test.sh @@ -27,6 +27,12 @@ if [[ "\$1" == "api" ]] && [[ "\$2" == *"/labels" ]] && [[ "\$*" == *"--paginate printf '%s\n' "area/api" "area/cli" "priority/high" "component/parser" exit 0 fi +# For issue create, return a fake URL on stdout so callers can capture it. +if [[ "\$1" == "issue" ]] && [[ "\$2" == "create" ]]; then + echo "gh \$*" >> "${GH_LOG}" + echo "https://github.com/mock-org/mock-repo/issues/999" + exit 0 +fi echo "gh \$*" >> "${GH_LOG}" MOCKEOF chmod +x "${MOCK_BIN}/gh" @@ -53,6 +59,22 @@ export PATH="${MOCK_BIN}:${PATH}" export GITHUB_ISSUE_URL="https://github.com/test-org/test-repo/issues/42" export GH_TOKEN="fake-token" +# prerequisites handler reads config.yaml from GITHUB_WORKSPACE. +# Create a minimal workspace with an allowlist so the test can exercise +# both the allowed and disallowed paths. +WORKSPACE="${TMPDIR}/workspace" +mkdir -p "${WORKSPACE}" +cat > "${WORKSPACE}/config.yaml" < Date: Thu, 11 Jun 2026 21:13:46 -0400 Subject: [PATCH 11/13] fix(triage): update schema validation tests for prerequisites action (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace blocked-action test cases with prerequisites-action equivalents and update the expected property list (blocked_by → prerequisites). Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- .../scripts/validate-output-schema-test.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh b/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh index 6c43fe044..2a7fee2ed 100755 --- a/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh +++ b/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh @@ -70,12 +70,12 @@ run_test "valid-question" \ '{"action":"question","reasoning":"this is a support question","comment":"Based on the docs, Python 4 is not supported. Would you like to open a feature request?"}' \ "true" -run_test "valid-blocked-issue" \ - '{"action":"blocked","reasoning":"upstream dependency","blocked_by":"https://github.com/org/repo/issues/99","comment":"Blocked on upstream."}' \ +run_test "valid-prerequisites-existing" \ + '{"action":"prerequisites","reasoning":"upstream dependency","prerequisites":{"existing":[{"url":"https://github.com/org/repo/issues/99"}],"create":[]},"comment":"Blocked on upstream."}' \ "true" -run_test "valid-blocked-pr" \ - '{"action":"blocked","reasoning":"waiting on PR","blocked_by":"https://github.com/org/repo/pull/55","comment":"Blocked on a PR."}' \ +run_test "valid-prerequisites-create" \ + '{"action":"prerequisites","reasoning":"needs upstream issue","prerequisites":{"existing":[],"create":[{"repo":"org/upstream","title":"Add X","body":"Need X."}]},"comment":"Blocked on upstream."}' \ "true" # --- Conditional requirement failures --- @@ -288,7 +288,7 @@ run_test_output "additional-properties-shows-allowed" \ run_test_output "additional-properties-lists-known-keys" \ '{"action":"sufficient","reasoning":"ok","clarity_scores":{"symptom":0.9,"cause":0.8,"reproduction":0.9,"impact":0.7,"overall":0.85},"triage_summary":{"title":"Bug","severity":"high","category":"bug","problem":"crash","root_cause_hypothesis":"null ptr","reproduction_steps":["step 1"],"impact":"all users","recommended_fix":"fix","proposed_test_case":"test"},"comment":"Done.","injected_field":"malicious"}' \ "false" \ - "action, blocked_by, clarity_scores, comment, duplicate_of, label_actions, reasoning, triage_summary" + "action, clarity_scores, comment, duplicate_of, label_actions, prerequisites, reasoning, triage_summary" run_test_output "valid-output-no-allowed-line" \ '{"action":"insufficient","reasoning":"missing repro","clarity_scores":{"symptom":0.6,"cause":0.3,"reproduction":0.1,"impact":0.5,"overall":0.39},"comment":"Can you share repro steps?"}' \ From e57f10a73ecf1ceb5259b768618aed4cdcec7771 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Fri, 12 Jun 2026 12:03:09 -0400 Subject: [PATCH 12/13] fix(triage): address review feedback on prerequisites action (#401) - Replace stale blocked-* schema validation tests with prerequisites equivalents (missing field, both arrays empty, malformed URL) - Fix validateCreateIssues to reject malformed repo formats like "/", "/repo", "owner/" - Align triage.md section 2c terminology from "blocker" to "prerequisite" consistently - Update bugfix-workflow.md and architecture.md to document upstream issue creation capability - Emit ::warning:: when yq is unavailable so silent degradation of cross-repo issue creation is diagnosable Signed-off-by: Ralph Bean Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- docs/architecture.md | 2 +- docs/guides/user/bugfix-workflow.md | 2 +- internal/config/config.go | 3 ++- internal/config/config_test.go | 22 +++++++++++++++++++ .../scaffold/fullsend-repo/agents/triage.md | 12 +++++----- .../fullsend-repo/scripts/post-triage.sh | 3 +++ .../scripts/validate-output-schema-test.sh | 12 ++++++---- 7 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 872bc2c79..2a012161d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -235,7 +235,7 @@ ADR 0002: [Building block 3](ADRs/0002-initial-fullsend-design.md#3-label-state- ### 4. triage agent runtime -Runs triage from issue `title`/`body` + GitHub-native attachments only; each run starts with **`duplicate`** and other reset labels cleared; duplicate detection, blocking dependency detection (cross-repo), readiness, reproducibility, test handoff; can close as duplicate again if still a match, or label **`blocked`** when progress depends on another open issue or PR. +Runs triage from issue `title`/`body` + GitHub-native attachments only; each run starts with **`duplicate`** and other reset labels cleared; duplicate detection, prerequisite detection (cross-repo), readiness, reproducibility, test handoff; can close as duplicate again if still a match, label **`blocked`** when progress depends on another open issue or PR, or create upstream prerequisite issues when no tracking issue exists (controlled by `create_issues.allow_targets` config). ADR 0002: [Building block 4](ADRs/0002-initial-fullsend-design.md#4-triage-agent-runtime). ### 5. Duplicate / similarity search diff --git a/docs/guides/user/bugfix-workflow.md b/docs/guides/user/bugfix-workflow.md index b5ec7594e..6124121f0 100644 --- a/docs/guides/user/bugfix-workflow.md +++ b/docs/guides/user/bugfix-workflow.md @@ -102,7 +102,7 @@ Every push to a PR in the review stage triggers a new review round. This means ` The triage agent: 1. **Checks for duplicates.** Searches existing issues by title, body, and metadata. If it finds a match with high confidence, it labels `duplicate`, posts a comment linking the canonical issue, and closes this one. -2. **Checks for blocking dependencies.** Searches for open issues or PRs (in this repo or upstream) that must be resolved before work can start. If a blocker is found, it labels `blocked` and posts a comment linking to the blocking issue or PR. On re-triage, it checks whether existing blockers have been resolved. +2. **Checks for blocking dependencies.** Searches for open issues or PRs (in this repo or upstream) that must be resolved before work can start. If a prerequisite is found, it labels `blocked` and posts a comment linking to it. When no upstream tracking issue exists, the triage agent can also create one in the upstream repo (controlled by `create_issues.allow_targets` in config). On re-triage, it checks whether existing prerequisites have been resolved. 3. **Checks information sufficiency.** If the issue body is missing steps to reproduce, expected behavior, or other critical details, it labels `needs-info` and posts a comment explaining what's missing. 4. **Produces a test artifact.** When possible, writes a failing test case aligned with the repo's test framework. 5. **Hands off.** Labels `ready-to-code` with a summary comment. diff --git a/internal/config/config.go b/internal/config/config.go index 420bd820f..b14505927 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -343,7 +343,8 @@ func validateCreateIssues(cfg *CreateIssuesConfig) error { } } for _, repo := range cfg.AllowTargets.Repos { - if !strings.Contains(repo, "/") { + parts := strings.SplitN(repo, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return fmt.Errorf("create_issues: repo %q in allow_targets.repos must contain owner/name", repo) } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 831663ea3..3e5a1f8bd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -968,6 +968,28 @@ func TestOrgConfigValidate_CreateIssues_InvalidRepoFormat(t *testing.T) { assert.Contains(t, err.Error(), "no-slash-here") } +func TestOrgConfigValidate_CreateIssues_MalformedRepoFormat(t *testing.T) { + malformed := []string{"/", "/repo", "owner/", "//"} + for _, repo := range malformed { + cfg := &OrgConfig{ + Version: "1", + Dispatch: DispatchConfig{Platform: "github-actions"}, + Defaults: RepoDefaults{ + Roles: []string{"fullsend"}, + MaxImplementationRetries: 2, + }, + CreateIssues: &CreateIssuesConfig{ + AllowTargets: AllowTargets{ + Repos: []string{repo}, + }, + }, + } + err := cfg.Validate() + assert.Error(t, err, "expected error for repo %q", repo) + assert.Contains(t, err.Error(), "owner/name", "expected owner/name message for repo %q", repo) + } +} + func TestOrgConfigValidate_CreateIssues_EmptyOrg(t *testing.T) { cfg := &OrgConfig{ Version: "1", diff --git a/internal/scaffold/fullsend-repo/agents/triage.md b/internal/scaffold/fullsend-repo/agents/triage.md index 71a8305aa..5312b2af9 100644 --- a/internal/scaffold/fullsend-repo/agents/triage.md +++ b/internal/scaffold/fullsend-repo/agents/triage.md @@ -65,16 +65,16 @@ If a cross-repo search fails or returns an error (e.g., due to access restrictio ### 2c. Check existing prerequisites -If the issue already has a `blocked` label, check whether the previously identified blocker (linked in prior triage comments) is still open. Fetch the full context of the blocking issue or PR to understand its current state: +If the issue already has a `blocked` label, check whether the previously identified prerequisites (linked in prior triage comments) are still open. Fetch the full context of each prerequisite issue or PR to understand its current state: ``` -# For blocking issues: -gh issue view BLOCKING_URL --json state,title,body,comments,labels -# For blocking PRs: -gh pr view BLOCKING_URL --json state,title,body,comments,labels,mergedAt +# For prerequisite issues: +gh issue view PREREQUISITE_URL --json state,title,body,comments,labels +# For prerequisite PRs: +gh pr view PREREQUISITE_URL --json state,title,body,comments,labels,mergedAt ``` -Use `gh issue view` for `/issues/` URLs and `gh pr view` for `/pull/` URLs. Review the blocker's state, recent comments, and labels to determine whether the dependency has been resolved, is making progress, or remains stalled. If the blocker has been closed or merged, the block may be resolved — proceed with a fresh assessment. +Use `gh issue view` for `/issues/` URLs and `gh pr view` for `/pull/` URLs. Review the prerequisite's state, recent comments, and labels to determine whether the dependency has been resolved, is making progress, or remains stalled. If the prerequisite has been closed or merged, the dependency may be resolved — proceed with a fresh assessment. ### 2d. Review prior triage analysis diff --git a/internal/scaffold/fullsend-repo/scripts/post-triage.sh b/internal/scaffold/fullsend-repo/scripts/post-triage.sh index 281180c9b..7077ddca1 100755 --- a/internal/scaffold/fullsend-repo/scripts/post-triage.sh +++ b/internal/scaffold/fullsend-repo/scripts/post-triage.sh @@ -135,6 +135,9 @@ case "${ACTION}" in ALLOWED_ORGS="" ALLOWED_REPOS="" + if [[ -f "${CONFIG_FILE}" ]] && ! command -v yq &>/dev/null; then + echo "::warning::yq not found — cannot read create_issues.allow_targets from config; cross-repo issue creation disabled" + fi if [[ -f "${CONFIG_FILE}" ]] && command -v yq &>/dev/null; then ALLOWED_ORGS=$(yq -r '.create_issues.allow_targets.orgs // [] | .[]' "${CONFIG_FILE}" 2>/dev/null || true) ALLOWED_REPOS=$(yq -r '.create_issues.allow_targets.repos // [] | .[]' "${CONFIG_FILE}" 2>/dev/null || true) diff --git a/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh b/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh index 2a7fee2ed..44bd813ac 100755 --- a/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh +++ b/internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh @@ -92,12 +92,16 @@ run_test "sufficient-missing-triage-summary" \ '{"action":"sufficient","reasoning":"ok","clarity_scores":{"symptom":0.9,"cause":0.8,"reproduction":0.9,"impact":0.7,"overall":0.85},"comment":"Done."}' \ "false" -run_test "blocked-missing-blocked-by" \ - '{"action":"blocked","reasoning":"upstream dependency","comment":"Blocked."}' \ +run_test "prerequisites-missing-prerequisites-field" \ + '{"action":"prerequisites","reasoning":"upstream dependency","comment":"Blocked."}' \ "false" -run_test "blocked-malformed-url" \ - '{"action":"blocked","reasoning":"upstream dependency","blocked_by":"not-a-url","comment":"Blocked."}' \ +run_test "prerequisites-both-arrays-empty" \ + '{"action":"prerequisites","reasoning":"upstream dependency","prerequisites":{"existing":[],"create":[]},"comment":"Blocked."}' \ + "false" + +run_test "prerequisites-malformed-url-in-existing" \ + '{"action":"prerequisites","reasoning":"upstream dependency","prerequisites":{"existing":[{"url":"not-a-url"}],"create":[]},"comment":"Blocked."}' \ "false" # --- FULLSEND_OUTPUT_FILE override --- From a24ffd178b51c23b01d97ce7b9b902ae253cdc5d Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Tue, 16 Jun 2026 14:53:06 -0400 Subject: [PATCH 13/13] style: gofmt config.go after merge Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/config/config.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fca262841..276f3f802 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -265,9 +265,9 @@ func (c *OrgConfig) DefaultRoles() []string { // PerRepoConfig holds configuration for per-repo installation mode. // Stored in .fullsend/config.yaml within the target repository. type PerRepoConfig struct { - Version string `yaml:"version"` - KillSwitch bool `yaml:"kill_switch,omitempty"` - Roles []string `yaml:"roles,omitempty"` + Version string `yaml:"version"` + KillSwitch bool `yaml:"kill_switch,omitempty"` + Roles []string `yaml:"roles,omitempty"` CreateIssues *CreateIssuesConfig `yaml:"create_issues,omitempty"` }