Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions internal/forge/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -233,6 +238,10 @@ type FakeClient struct {
// Annotations for GetWorkflowRunAnnotations.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] field-ordering-and-comments

CreatedLabels field is positioned before the '// Call recorders' section but logically belongs within it (all other Created*/Deleted*/Updated* fields appear after that comment). Additionally, the field has a misleading two-line comment: the first line describes the type rather than the field, duplicating the type's own godoc.

Suggested fix: Move CreatedLabels into the '// Call recorders' block alongside other Created* fields. Remove the duplicate type-description comment.

Annotations []Annotation

// LabelRecord records a label creation call.
// CreatedLabels tracks CreateLabel calls.
CreatedLabels []LabelRecord

// Call recorders
CreatedRepos []Repository
CreatedFiles []FileRecord
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions internal/forge/gitlab/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions internal/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
17 changes: 16 additions & 1 deletion internal/repos/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] stale-reference

Duplicate step number after insertion. The PR inserts a new Step 6 (labels) and renumbers the old Step 6 (vars) to Step 7, but does not renumber the existing Step 7 (secrets) to Step 8. After merge there will be two comments reading '// Step 7'.

Suggested fix: Renumber '// Step 7: Write repository secrets.' to '// Step 8: Write repository secrets.'

// Step 7: Write repository variables.
progress(repoFullName, "vars", "Configuring repository variables")
repoVars := map[string]string{
"FULLSEND_MINT_URL": mintURL,
Expand Down
60 changes: 59 additions & 1 deletion internal/repos/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
}
}
5 changes: 5 additions & 0 deletions internal/scaffold/fullsend-repo/harness/code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions internal/scaffold/fullsend-repo/harness/fix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions internal/scaffold/fullsend-repo/harness/review.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions internal/scaffold/fullsend-repo/harness/triage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions internal/scaffold/labels.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading