From b0c0c0c9de9932fc0eb215d2a69d1763db7091b4 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:53:37 +0000 Subject: [PATCH] feat(#5187): provision pipeline labels during enrollment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each harness YAML now declares the labels its post-scripts require via a new `labels` field. During `fullsend admin install`, the install flow reads these declarations from the embedded scaffold and creates them in the target repo using `CreateLabel` (idempotent — existing labels are left unchanged). This ensures that newly enrolled repos have all pipeline-critical labels (ready-for-review, ready-for-merge, requires-manual-review, ready-to-code, triaged, rejected, needs-human, fullsend-fix, fullsend-no-fix) from the start, rather than relying on post-scripts to create them reactively. The approach is agent-driven — adding a new agent with new label requirements only requires updating that agent's harness YAML. Changes: - Add LabelDef struct and labels field to harness.Harness - Add CreateLabel to forge.Client interface (GitHub + GitLab impls) - Add scaffold.CollectHarnessLabels() to extract labels from embedded harness YAML files - Add label provisioning step to repos.Install() between scaffold commit and variable writes - Add label declarations to code, review, triage, and fix harnesses - Add tests for label collection and install-time provisioning Closes #5187 --- internal/forge/fake.go | 27 ++++++++ internal/forge/forge.go | 6 ++ internal/forge/github/github.go | 20 ++++++ internal/forge/gitlab/issue.go | 21 ++++++ internal/harness/harness.go | 10 +++ internal/repos/install.go | 17 ++++- internal/repos/install_test.go | 60 ++++++++++++++++- .../scaffold/fullsend-repo/harness/code.yaml | 5 ++ .../scaffold/fullsend-repo/harness/fix.yaml | 11 ++++ .../fullsend-repo/harness/review.yaml | 11 ++++ .../fullsend-repo/harness/triage.yaml | 8 +++ internal/scaffold/labels.go | 55 ++++++++++++++++ internal/scaffold/labels_test.go | 64 +++++++++++++++++++ 13 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 internal/scaffold/labels.go create mode 100644 internal/scaffold/labels_test.go diff --git a/internal/forge/fake.go b/internal/forge/fake.go index 4e81135c6a..af467fb0af 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -73,6 +73,11 @@ type CreatedIssueRecord struct { Number int } +// LabelRecord records a label creation call. +type LabelRecord struct { + Owner, Repo, Name, Color, Description string +} + // MinimizedCommentRecord records a comment minimize call. type MinimizedCommentRecord struct { NodeID string @@ -233,6 +238,10 @@ type FakeClient struct { // Annotations for GetWorkflowRunAnnotations. Annotations []Annotation + // LabelRecord records a label creation call. + // CreatedLabels tracks CreateLabel calls. + CreatedLabels []LabelRecord + // Call recorders CreatedRepos []Repository CreatedFiles []FileRecord @@ -1135,6 +1144,24 @@ func (f *FakeClient) DispatchWorkflow(_ context.Context, _, _, _, _ string, _ ma return nil } +func (f *FakeClient) CreateLabel(_ context.Context, owner, repo, name, color, description string) error { + f.mu.Lock() + defer f.mu.Unlock() + + if e := f.err("CreateLabel"); e != nil { + return e + } + + f.CreatedLabels = append(f.CreatedLabels, LabelRecord{ + Owner: owner, + Repo: repo, + Name: name, + Color: color, + Description: description, + }) + return nil +} + func (f *FakeClient) CreateIssue(_ context.Context, owner, repo, title, body string, labels ...string) (*Issue, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 1ebe3eafec..c3072c1437 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -487,6 +487,12 @@ type Client interface { GetWorkflowRun(ctx context.Context, owner, repo string, runID int) (*WorkflowRun, error) DispatchWorkflow(ctx context.Context, owner, repo, workflowFile, ref string, inputs map[string]string) error + // Label operations + // CreateLabel creates a repository label with the given name, color, and + // description. The call is idempotent: if a label with the same name + // already exists, it is not modified and no error is returned. + CreateLabel(ctx context.Context, owner, repo, name, color, description string) error + // Issue operations CreateIssue(ctx context.Context, owner, repo, title, body string, labels ...string) (*Issue, error) AddIssueLabels(ctx context.Context, owner, repo string, number int, labels ...string) error diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 7d671735a6..08e8b12d86 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -2382,6 +2382,26 @@ func (c *LiveClient) CreateIssue(ctx context.Context, owner, repo, title, body s }, nil } +// CreateLabel creates a repository label. If a label with the same name +// already exists the call succeeds without modification (idempotent). +func (c *LiveClient) CreateLabel(ctx context.Context, owner, repo, name, color, description string) error { + body := map[string]string{ + "name": name, + "color": color, + "description": description, + } + resp, err := c.post(ctx, fmt.Sprintf("/repos/%s/%s/labels", owner, repo), body) + if err != nil { + // 422 "already_exists" is the expected duplicate case. + if errors.Is(err, forge.ErrAlreadyExists) { + return nil + } + return fmt.Errorf("create label %q: %w", name, err) + } + resp.Body.Close() + return nil +} + // AddIssueLabels adds labels to an existing issue. func (c *LiveClient) AddIssueLabels(ctx context.Context, owner, repo string, number int, labels ...string) error { if len(labels) == 0 { diff --git a/internal/forge/gitlab/issue.go b/internal/forge/gitlab/issue.go index f67b87a23f..6476d3f9bc 100644 --- a/internal/forge/gitlab/issue.go +++ b/internal/forge/gitlab/issue.go @@ -157,6 +157,27 @@ func (c *LiveClient) AddIssueLabels(ctx context.Context, owner, repo string, num return nil } +// CreateLabel creates a project label. If a label with the same name already +// exists, the call succeeds without modification (idempotent). +func (c *LiveClient) CreateLabel(ctx context.Context, owner, repo, name, color, description string) error { + path := fmt.Sprintf("/projects/%s/labels", projectPath(owner, repo)) + body := map[string]string{ + "name": name, + "color": "#" + color, // GitLab requires a leading '#' on color hex codes + "description": description, + } + resp, err := c.post(ctx, path, body) + if err != nil { + // GitLab returns 409 Conflict when the label already exists. + if forge.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("create label %q: %w", name, err) + } + resp.Body.Close() + return nil +} + // ListIssueComments returns all notes on an issue, sorted ascending. // GitLab calls issue comments "notes". func (c *LiveClient) ListIssueComments(ctx context.Context, owner, repo string, number int) ([]forge.IssueComment, error) { diff --git a/internal/harness/harness.go b/internal/harness/harness.go index e0ca02e0ca..e789642ef8 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -263,6 +263,15 @@ func (dst *EnvConfig) mergeEnvFrom(src *EnvConfig, srcWins bool) { } } +// LabelDef declares a label that the agent pipeline requires in the target +// repository. Labels are provisioned during enrollment so that post-scripts +// can apply them without encountering "label not found" errors. +type LabelDef struct { + Name string `yaml:"name"` + Color string `yaml:"color"` + Description string `yaml:"description,omitempty"` +} + // Harness is the per-agent configuration that the runner reads to provision // a sandbox and launch one agent. It follows the ADR-0017 schema. type Harness struct { @@ -294,6 +303,7 @@ type Harness struct { AllowedRemoteResources []string `yaml:"allowed_remote_resources,omitempty"` AllowRuntimeFetch bool `yaml:"allow_runtime_fetch,omitempty"` // opt-in to runtime skill fetching (default: false) MaxRuntimeFetches *int `yaml:"max_runtime_fetches,omitempty"` // per-run fetch cap; nil = default (10), valid range 1-1000 + Labels []LabelDef `yaml:"labels,omitempty"` Forge map[string]*ForgeConfig `yaml:"forge,omitempty"` Trigger string `yaml:"trigger,omitempty"` // optional CEL boolean over normevent (ADR 0061) } diff --git a/internal/repos/install.go b/internal/repos/install.go index eedbfd57e6..4b3b13fc8b 100644 --- a/internal/repos/install.go +++ b/internal/repos/install.go @@ -258,7 +258,22 @@ func Install(ctx context.Context, cfg InstallConfig, } progress(repoFullName, "scaffold", "Scaffold files committed") - // Step 6: Write repository variables. + // Step 6: Provision labels declared by harness files. + progress(repoFullName, "labels", "Provisioning pipeline labels") + harnessLabels, labelCollectErr := scaffold.CollectHarnessLabels() + if labelCollectErr != nil { + return result, fmt.Errorf("collecting harness labels: %w", labelCollectErr) + } + for _, l := range harnessLabels { + if err := client.CreateLabel(ctx, cfg.Owner, cfg.Repo, l.Name, l.Color, l.Description); err != nil { + return result, fmt.Errorf("creating label %q: %w", l.Name, err) + } + } + if len(harnessLabels) > 0 { + progress(repoFullName, "labels", fmt.Sprintf("Provisioned %d pipeline labels", len(harnessLabels))) + } + + // Step 7: Write repository variables. progress(repoFullName, "vars", "Configuring repository variables") repoVars := map[string]string{ "FULLSEND_MINT_URL": mintURL, diff --git a/internal/repos/install_test.go b/internal/repos/install_test.go index 9e3b0deeba..9582c5c2f8 100644 --- a/internal/repos/install_test.go +++ b/internal/repos/install_test.go @@ -545,7 +545,7 @@ func TestInstall_ProgressCallbackPhases(t *testing.T) { } // Verify expected phases are reported in order. - wantPhases := []string{"scaffold", "scaffold", "scaffold", "vars", "vars", "secrets", "secrets", "done"} + wantPhases := []string{"scaffold", "scaffold", "scaffold", "labels", "labels", "vars", "vars", "secrets", "secrets", "done"} if len(phases) != len(wantPhases) { t.Fatalf("got %d phases %v, want %d phases %v", len(phases), phases, len(wantPhases), wantPhases) } @@ -778,3 +778,61 @@ func TestInstall_NilProvisioner_WIFRequired(t *testing.T) { t.Fatal("expected error when provisioner is nil and WIF provisioning required") } } + +func TestInstall_ProvisionLabels(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + if !result.Success { + t.Error("expected Success=true") + } + + // Verify that labels were created. + if len(fc.CreatedLabels) == 0 { + t.Fatal("expected labels to be provisioned during install") + } + + // Build a set of created label names. + created := make(map[string]struct{}, len(fc.CreatedLabels)) + for _, l := range fc.CreatedLabels { + created[l.Name] = struct{}{} + if l.Owner != "acme" || l.Repo != "widgets" { + t.Errorf("label %q created on %s/%s, want acme/widgets", + l.Name, l.Owner, l.Repo) + } + } + + // Verify key pipeline labels. + for _, want := range []string{ + "ready-for-review", + "ready-for-merge", + "requires-manual-review", + "ready-to-code", + } { + if _, ok := created[want]; !ok { + t.Errorf("expected label %q to be provisioned", want) + } + } +} + +func TestInstall_LabelCreateError(t *testing.T) { + fc := newFakeClientWithRepo() + fc.Errors["CreateLabel"] = fmt.Errorf("permission denied") + cfg := baseCfg() + sc := &fakeScaffoldCommit{} + + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error when label creation fails") + } + + // Scaffold should have been committed before the label step. + if !sc.called { + t.Error("expected scaffold commit to be called before label creation") + } +} diff --git a/internal/scaffold/fullsend-repo/harness/code.yaml b/internal/scaffold/fullsend-repo/harness/code.yaml index 728cf919f5..7c16dadd50 100644 --- a/internal/scaffold/fullsend-repo/harness/code.yaml +++ b/internal/scaffold/fullsend-repo/harness/code.yaml @@ -35,6 +35,11 @@ host_files: dest: /sandbox/workspace/.gcp-oidc-token optional: true +labels: + - name: ready-for-review + color: "0E8A16" + description: "Code agent PR ready for automated review" + pre_script: scripts/pre-code.sh post_script: scripts/post-code.sh diff --git a/internal/scaffold/fullsend-repo/harness/fix.yaml b/internal/scaffold/fullsend-repo/harness/fix.yaml index 16b1c88b61..9d3fa3f438 100644 --- a/internal/scaffold/fullsend-repo/harness/fix.yaml +++ b/internal/scaffold/fullsend-repo/harness/fix.yaml @@ -22,6 +22,17 @@ providers: role: coder slug: fullsend-ai-coder +labels: + - name: needs-human + color: D93F0B + description: "Fix iterations approaching cap — human attention needed" + - name: fullsend-fix + color: 1D76DB + description: "Fix agent iteration in progress" + - name: fullsend-no-fix + color: FBCA04 + description: "Disable fix agent for this PR" + pre_script: scripts/pre-fix.sh post_script: scripts/post-fix.sh diff --git a/internal/scaffold/fullsend-repo/harness/review.yaml b/internal/scaffold/fullsend-repo/harness/review.yaml index bba2db3e9b..0a340629a4 100644 --- a/internal/scaffold/fullsend-repo/harness/review.yaml +++ b/internal/scaffold/fullsend-repo/harness/review.yaml @@ -31,6 +31,17 @@ host_files: dest: /sandbox/workspace/prior-review.txt optional: true +labels: + - name: ready-for-merge + color: "0E8A16" + description: "All reviewers approved — ready to merge" + - name: requires-manual-review + color: FBCA04 + description: "Review requires human judgment" + - name: rejected + color: B60205 + description: "Approach rejected by review agent" + pre_script: scripts/pre-review.sh post_script: scripts/post-review.sh diff --git a/internal/scaffold/fullsend-repo/harness/triage.yaml b/internal/scaffold/fullsend-repo/harness/triage.yaml index 7a59b2cc73..ff47dbfb6c 100644 --- a/internal/scaffold/fullsend-repo/harness/triage.yaml +++ b/internal/scaffold/fullsend-repo/harness/triage.yaml @@ -24,6 +24,14 @@ host_files: skills: - skills/issue-labels +labels: + - name: ready-to-code + color: "0E8A16" + description: "Triaged and ready for code agent" + - name: triaged + color: ededed + description: "Triaged but awaiting human prioritization" + pre_script: scripts/pre-triage.sh post_script: scripts/post-triage.sh diff --git a/internal/scaffold/labels.go b/internal/scaffold/labels.go new file mode 100644 index 0000000000..d225b805c6 --- /dev/null +++ b/internal/scaffold/labels.go @@ -0,0 +1,55 @@ +package scaffold + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// LabelDef describes a label that must exist in the target repo for +// agent post-scripts to function correctly. It mirrors harness.LabelDef +// but lives here to avoid an import cycle (harness test files import +// scaffold). +type LabelDef struct { + Name string `yaml:"name"` + Color string `yaml:"color"` + Description string `yaml:"description,omitempty"` +} + +// CollectHarnessLabels reads embedded harness YAML files and returns +// the deduplicated set of labels declared across all harnesses. +// When the same label name appears in multiple harnesses, the first +// definition wins (stable because embed.FS walks alphabetically). +func CollectHarnessLabels() ([]LabelDef, error) { + seen := make(map[string]struct{}) + var labels []LabelDef + + err := WalkFullsendRepoAll(func(path string, data []byte) error { + if !strings.HasPrefix(path, "harness/") || !isYAML(path) { + return nil + } + var h struct { + Labels []LabelDef `yaml:"labels"` + } + if err := yaml.Unmarshal(data, &h); err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + for _, l := range h.Labels { + if _, ok := seen[l.Name]; ok { + continue + } + seen[l.Name] = struct{}{} + labels = append(labels, l) + } + return nil + }) + if err != nil { + return nil, err + } + return labels, nil +} + +func isYAML(path string) bool { + return strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") +} diff --git a/internal/scaffold/labels_test.go b/internal/scaffold/labels_test.go new file mode 100644 index 0000000000..164ac0e0e4 --- /dev/null +++ b/internal/scaffold/labels_test.go @@ -0,0 +1,64 @@ +package scaffold + +import ( + "testing" +) + +func TestCollectHarnessLabels(t *testing.T) { + labels, err := CollectHarnessLabels() + if err != nil { + t.Fatalf("CollectHarnessLabels() returned error: %v", err) + } + + if len(labels) == 0 { + t.Fatal("expected at least one label from harness files") + } + + // Build a set of collected label names. + found := make(map[string]LabelDef, len(labels)) + for _, l := range labels { + if l.Name == "" { + t.Error("label with empty name found") + } + if l.Color == "" { + t.Errorf("label %q has empty color", l.Name) + } + found[l.Name] = l + } + + // Verify key pipeline labels are present. + required := []string{ + "ready-for-review", + "ready-for-merge", + "requires-manual-review", + "rejected", + "ready-to-code", + "triaged", + "needs-human", + "fullsend-fix", + "fullsend-no-fix", + } + for _, name := range required { + if _, ok := found[name]; !ok { + t.Errorf("expected label %q in collected labels; got labels: %v", + name, labelNames(labels)) + } + } + + // Verify deduplication: no duplicate names. + seen := make(map[string]struct{}, len(labels)) + for _, l := range labels { + if _, dup := seen[l.Name]; dup { + t.Errorf("duplicate label %q in collected labels", l.Name) + } + seen[l.Name] = struct{}{} + } +} + +func labelNames(labels []LabelDef) []string { + names := make([]string, len(labels)) + for i, l := range labels { + names[i] = l.Name + } + return names +}