Skip to content

Commit 1f23a4e

Browse files
ggallenclaude
andcommitted
fix: load per-repo config.yaml in fullsend run and reusable workflows
Per-repo installations store config.yaml in .fullsend/ but the CLI only parsed OrgConfig format. Add OrgConfigFromPerRepo adapter and teach tryLoadInstallConfig/requireInstallConfig to fall back to PerRepoConfig parsing using structural discrimination (isPerRepoYAML). Update all six reusable workflows to layer workspace files under .fullsend/ when install_mode is per-repo, and pass fullsend-dir to the action invocation. Closes #2970 Signed-off-by: Greg Allen <greg@fullsend.ai> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Greg Allen <gallen@redhat.com>
1 parent 6cfeae9 commit 1f23a4e

11 files changed

Lines changed: 335 additions & 39 deletions

File tree

.github/workflows/reusable-code.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,14 @@ jobs:
9393
fi
9494
SRC=".defaults/internal/scaffold/fullsend-repo"
9595
LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
96+
DEST=""
97+
if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
98+
DEST=".fullsend/"
99+
fi
96100
for dir in ${LAYERED_DIRS}; do
97101
if [[ -d "${SRC}/${dir}" ]]; then
98-
mkdir -p "${dir}"
99-
cp -r "${SRC}/${dir}/." "${dir}/"
102+
mkdir -p "${DEST}${dir}"
103+
cp -r "${SRC}/${dir}/." "${DEST}${dir}/"
100104
fi
101105
done
102106
CUSTOM_BASE="customized"
@@ -108,8 +112,8 @@ jobs:
108112
find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
109113
| while IFS= read -r -d '' f; do
110114
rel="${f#"${CUSTOM_BASE}"/}"
111-
mkdir -p "$(dirname "${rel}")"
112-
cp "${f}" "${rel}"
115+
mkdir -p "$(dirname "${DEST}${rel}")"
116+
cp "${f}" "${DEST}${rel}"
113117
done
114118
fi
115119
done
@@ -195,6 +199,7 @@ jobs:
195199
with:
196200
agent: code
197201
version: ${{ inputs.fullsend_version }}
202+
fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }}
198203
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
199204
status-repo: ${{ inputs.source_repo }}
200205
status-number: ${{ fromJSON(inputs.event_payload).issue.number }}

.github/workflows/reusable-fix.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,14 @@ jobs:
109109
fi
110110
SRC=".defaults/internal/scaffold/fullsend-repo"
111111
LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
112+
DEST=""
113+
if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
114+
DEST=".fullsend/"
115+
fi
112116
for dir in ${LAYERED_DIRS}; do
113117
if [[ -d "${SRC}/${dir}" ]]; then
114-
mkdir -p "${dir}"
115-
cp -r "${SRC}/${dir}/." "${dir}/"
118+
mkdir -p "${DEST}${dir}"
119+
cp -r "${SRC}/${dir}/." "${DEST}${dir}/"
116120
fi
117121
done
118122
CUSTOM_BASE="customized"
@@ -124,8 +128,8 @@ jobs:
124128
find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
125129
| while IFS= read -r -d '' f; do
126130
rel="${f#"${CUSTOM_BASE}"/}"
127-
mkdir -p "$(dirname "${rel}")"
128-
cp "${f}" "${rel}"
131+
mkdir -p "$(dirname "${DEST}${rel}")"
132+
cp "${f}" "${DEST}${rel}"
129133
done
130134
fi
131135
done
@@ -396,6 +400,7 @@ jobs:
396400
with:
397401
agent: fix
398402
version: ${{ inputs.fullsend_version }}
403+
fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }}
399404
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
400405
status-repo: ${{ inputs.source_repo }}
401406
status-number: ${{ steps.context.outputs.pr_number }}

.github/workflows/reusable-prioritize.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,14 @@ jobs:
9494
fi
9595
SRC=".defaults/internal/scaffold/fullsend-repo"
9696
LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
97+
DEST=""
98+
if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
99+
DEST=".fullsend/"
100+
fi
97101
for dir in ${LAYERED_DIRS}; do
98102
if [[ -d "${SRC}/${dir}" ]]; then
99-
mkdir -p "${dir}"
100-
cp -r "${SRC}/${dir}/." "${dir}/"
103+
mkdir -p "${DEST}${dir}"
104+
cp -r "${SRC}/${dir}/." "${DEST}${dir}/"
101105
fi
102106
done
103107
CUSTOM_BASE="customized"
@@ -109,8 +113,8 @@ jobs:
109113
find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
110114
| while IFS= read -r -d '' f; do
111115
rel="${f#"${CUSTOM_BASE}"/}"
112-
mkdir -p "$(dirname "${rel}")"
113-
cp "${f}" "${rel}"
116+
mkdir -p "$(dirname "${DEST}${rel}")"
117+
cp "${f}" "${DEST}${rel}"
114118
done
115119
fi
116120
done
@@ -151,4 +155,5 @@ jobs:
151155
with:
152156
agent: prioritize
153157
version: ${{ inputs.fullsend_version }}
158+
fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }}
154159
mint-url: ${{ inputs.mint_url }}

.github/workflows/reusable-retro.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,14 @@ jobs:
9090
fi
9191
SRC=".defaults/internal/scaffold/fullsend-repo"
9292
LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
93+
DEST=""
94+
if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
95+
DEST=".fullsend/"
96+
fi
9397
for dir in ${LAYERED_DIRS}; do
9498
if [[ -d "${SRC}/${dir}" ]]; then
95-
mkdir -p "${dir}"
96-
cp -r "${SRC}/${dir}/." "${dir}/"
99+
mkdir -p "${DEST}${dir}"
100+
cp -r "${SRC}/${dir}/." "${DEST}${dir}/"
97101
fi
98102
done
99103
CUSTOM_BASE="customized"
@@ -105,8 +109,8 @@ jobs:
105109
find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
106110
| while IFS= read -r -d '' f; do
107111
rel="${f#"${CUSTOM_BASE}"/}"
108-
mkdir -p "$(dirname "${rel}")"
109-
cp "${f}" "${rel}"
112+
mkdir -p "$(dirname "${DEST}${rel}")"
113+
cp "${f}" "${DEST}${rel}"
110114
done
111115
fi
112116
done
@@ -162,6 +166,7 @@ jobs:
162166
with:
163167
agent: retro
164168
version: ${{ inputs.fullsend_version }}
169+
fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }}
165170
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
166171
status-repo: ${{ inputs.source_repo }}
167172
status-number: ${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}

.github/workflows/reusable-review.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,14 @@ jobs:
9191
fi
9292
SRC=".defaults/internal/scaffold/fullsend-repo"
9393
LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
94+
DEST=""
95+
if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
96+
DEST=".fullsend/"
97+
fi
9498
for dir in ${LAYERED_DIRS}; do
9599
if [[ -d "${SRC}/${dir}" ]]; then
96-
mkdir -p "${dir}"
97-
cp -r "${SRC}/${dir}/." "${dir}/"
100+
mkdir -p "${DEST}${dir}"
101+
cp -r "${SRC}/${dir}/." "${DEST}${dir}/"
98102
fi
99103
done
100104
CUSTOM_BASE="customized"
@@ -106,8 +110,8 @@ jobs:
106110
find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
107111
| while IFS= read -r -d '' f; do
108112
rel="${f#"${CUSTOM_BASE}"/}"
109-
mkdir -p "$(dirname "${rel}")"
110-
cp "${f}" "${rel}"
113+
mkdir -p "$(dirname "${DEST}${rel}")"
114+
cp "${f}" "${DEST}${rel}"
111115
done
112116
fi
113117
done
@@ -176,6 +180,7 @@ jobs:
176180
PRIOR_REVIEW_PROVENANCE: ${{ steps.prior-review.outputs.prior_review_provenance }}
177181
with:
178182
agent: review
183+
fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }}
179184
version: ${{ inputs.fullsend_version }}
180185
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
181186
status-repo: ${{ inputs.source_repo }}

.github/workflows/reusable-triage.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,14 @@ jobs:
9191
fi
9292
SRC=".defaults/internal/scaffold/fullsend-repo"
9393
LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
94+
DEST=""
95+
if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
96+
DEST=".fullsend/"
97+
fi
9498
for dir in ${LAYERED_DIRS}; do
9599
if [[ -d "${SRC}/${dir}" ]]; then
96-
mkdir -p "${dir}"
97-
cp -r "${SRC}/${dir}/." "${dir}/"
100+
mkdir -p "${DEST}${dir}"
101+
cp -r "${SRC}/${dir}/." "${DEST}${dir}/"
98102
fi
99103
done
100104
CUSTOM_BASE="customized"
@@ -106,8 +110,8 @@ jobs:
106110
find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
107111
| while IFS= read -r -d '' f; do
108112
rel="${f#"${CUSTOM_BASE}"/}"
109-
mkdir -p "$(dirname "${rel}")"
110-
cp "${f}" "${rel}"
113+
mkdir -p "$(dirname "${DEST}${rel}")"
114+
cp "${f}" "${DEST}${rel}"
111115
done
112116
fi
113117
done
@@ -161,6 +165,7 @@ jobs:
161165
with:
162166
agent: triage
163167
version: ${{ inputs.fullsend_version }}
168+
fullsend-dir: ${{ inputs.install_mode == 'per-repo' && '.fullsend' || '' }}
164169
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
165170
status-repo: ${{ inputs.source_repo }}
166171
status-number: ${{ fromJSON(inputs.event_payload).issue.number }}

internal/cli/lock_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,7 @@ func TestRunLock_URLRefsNoOrgConfigError(t *testing.T) {
10791079
printer := ui.New(os.Stdout)
10801080
err := runLock(context.Background(), "noconfig", dir, "", false, resolveFlags{}, printer)
10811081
require.Error(t, err)
1082-
assert.Contains(t, err.Error(), "URL-referenced resources require an org-level config.yaml")
1082+
assert.Contains(t, err.Error(), "URL-referenced resources require a config.yaml")
10831083
assert.Contains(t, err.Error(), "allowed_remote_resources")
10841084
}
10851085

internal/cli/orgconfig.go

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"fmt"
55
"os"
66

7+
"gopkg.in/yaml.v3"
8+
79
"github.com/fullsend-ai/fullsend/internal/config"
810
"github.com/fullsend-ai/fullsend/internal/ui"
911
)
@@ -17,17 +19,46 @@ func configAgentNames(agents []config.AgentEntry) []string {
1719
return names
1820
}
1921

20-
// tryLoadOrgConfig attempts to load org config from the given path.
21-
// Returns nil without error when the file is absent (best-effort).
22-
// Logs warnings via printer for non-ENOENT read errors and parse errors.
23-
func tryLoadOrgConfig(path string, printer *ui.Printer) *config.OrgConfig {
22+
// isPerRepoYAML probes raw YAML for structural markers that distinguish
23+
// PerRepoConfig from OrgConfig. PerRepoConfig has a top-level "roles"
24+
// key; OrgConfig has "dispatch" and/or "repos". Both parsers use plain
25+
// yaml.Unmarshal which silently ignores unknown keys, so we must inspect
26+
// the raw keys to choose the correct parser.
27+
func isPerRepoYAML(data []byte) bool {
28+
var probe map[string]interface{}
29+
if err := yaml.Unmarshal(data, &probe); err != nil {
30+
return false
31+
}
32+
if _, ok := probe["dispatch"]; ok {
33+
return false
34+
}
35+
if _, ok := probe["repos"]; ok {
36+
return false
37+
}
38+
_, hasRoles := probe["roles"]
39+
return hasRoles
40+
}
41+
42+
// tryLoadInstallConfig attempts to load an org or per-repo config.yaml
43+
// from the given path. Returns nil without error when the file is absent
44+
// (best-effort). Per-repo config is adapted to OrgConfig via
45+
// OrgConfigFromPerRepo so callers see a unified type.
46+
func tryLoadInstallConfig(path string, printer *ui.Printer) *config.OrgConfig {
2447
data, err := os.ReadFile(path)
2548
if err != nil {
2649
if !os.IsNotExist(err) {
27-
printer.StepWarn("Org config unreadable (remote resource allowlist unavailable): " + err.Error())
50+
printer.StepWarn("Install config unreadable (remote resource allowlist unavailable): " + err.Error())
2851
}
2952
return nil
3053
}
54+
if isPerRepoYAML(data) {
55+
perRepo, perRepoErr := config.ParsePerRepoConfig(data)
56+
if perRepoErr != nil {
57+
printer.StepWarn("Per-repo config malformed (remote resource allowlist unavailable): " + perRepoErr.Error())
58+
return nil
59+
}
60+
return config.OrgConfigFromPerRepo(perRepo)
61+
}
3162
cfg, parseErr := config.ParseOrgConfig(data)
3263
if parseErr != nil {
3364
printer.StepWarn("Org config malformed (remote resource allowlist unavailable): " + parseErr.Error())
@@ -36,17 +67,28 @@ func tryLoadOrgConfig(path string, printer *ui.Printer) *config.OrgConfig {
3667
return cfg
3768
}
3869

39-
// requireOrgConfig loads org config from the given path with strict error
40-
// handling. Returns differentiated errors for missing files, unreadable
41-
// files, and parse failures.
42-
func requireOrgConfig(path string, printer *ui.Printer) (*config.OrgConfig, error) {
70+
// tryLoadOrgConfig loads an org or per-repo config.yaml (best-effort).
71+
var tryLoadOrgConfig = tryLoadInstallConfig
72+
73+
// requireInstallConfig loads an org or per-repo config.yaml from the
74+
// given path with strict error handling. Returns differentiated errors
75+
// for missing files, unreadable files, and parse failures.
76+
func requireInstallConfig(path string, printer *ui.Printer) (*config.OrgConfig, error) {
4377
data, err := os.ReadFile(path)
4478
if err != nil {
45-
printer.StepFail("Failed to load org config")
79+
printer.StepFail("Failed to load install config")
4680
if os.IsNotExist(err) {
47-
return nil, fmt.Errorf("URL-referenced resources require an org-level config.yaml with allowed_remote_resources (expected at %s)", path)
81+
return nil, fmt.Errorf("URL-referenced resources require a config.yaml with allowed_remote_resources (expected at %s)", path)
4882
}
49-
return nil, fmt.Errorf("reading org config for remote resource validation: %w", err)
83+
return nil, fmt.Errorf("reading install config for remote resource validation: %w", err)
84+
}
85+
if isPerRepoYAML(data) {
86+
perRepo, perRepoErr := config.ParsePerRepoConfig(data)
87+
if perRepoErr != nil {
88+
printer.StepFail("Failed to parse per-repo config")
89+
return nil, fmt.Errorf("parsing per-repo config: %w", perRepoErr)
90+
}
91+
return config.OrgConfigFromPerRepo(perRepo), nil
5092
}
5193
cfg, parseErr := config.ParseOrgConfig(data)
5294
if parseErr != nil {
@@ -55,3 +97,6 @@ func requireOrgConfig(path string, printer *ui.Printer) (*config.OrgConfig, erro
5597
}
5698
return cfg, nil
5799
}
100+
101+
// requireOrgConfig loads an org or per-repo config.yaml (strict).
102+
var requireOrgConfig = requireInstallConfig

0 commit comments

Comments
 (0)