Skip to content

Commit 0e550ad

Browse files
authored
Name the workflow files that caused an escalation (#120)
The message said only that the change touches .github/workflows/. That leaves a reader with nothing but the agent's own account of what it edited, and a model's prose summary is not evidence. Working out whether a real escalation was correct cost two probe runs and a wrong hypothesis on #119. TouchesWorkflow becomes WorkflowChanges and returns the paths. The rename forces every call site to be updated deliberately rather than silently keeping a bool. Two details the change surfaced: git status --porcelain collapses an untracked directory to a single '.github/workflows/' entry, so a brand new workflow directory produced a path that named no file. -uall lists them individually, and that is exactly the case where naming matters most. A staged rename renders as 'R <old> -> <new>'. The destination is the file that would be pushed, so that is what gets named. The push-refusal path passes no paths: git has already refused, and the remote's message carries its own detail.
1 parent 1f6ac69 commit 0e550ad

4 files changed

Lines changed: 154 additions & 40 deletions

File tree

‎internal/loop/loop.go‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ type VCS interface {
7575
}
7676

7777
type workflowToucher interface {
78-
// TouchesWorkflow reports whether the working tree has changes under
78+
// WorkflowChanges lists the changed paths under
7979
// `.github/workflows/`.
80-
TouchesWorkflow(ctx context.Context, dir string) (bool, error)
80+
WorkflowChanges(ctx context.Context, dir string) ([]string, error)
8181
}
8282

8383
const workflowPushRefusal = "refusing to allow a GitHub App to create or update workflow"
@@ -146,20 +146,35 @@ func (e *Engine) promptFor(role domain.Role, iss domain.Issue) string {
146146
return iss.Body
147147
}
148148

149-
func workflowPermissionReason(login string) string {
149+
func workflowPermissionReason(login string, paths []string) string {
150150
who := "a GitHub App"
151151
if login != "" {
152152
who = "the `" + login + "` GitHub App"
153153
}
154-
return "the change touches `.github/workflows/`, but this run is authenticated as " + who + ", which deliberately lacks `workflows` permission. GitHub will refuse that push. Commit the workflow change as a human, or rerun the CLI under your own GitHub auth."
154+
what := "the change touches `.github/workflows/`"
155+
if len(paths) > 0 {
156+
what = "the change touches " + quoteList(paths)
157+
}
158+
return what + ", but this run is authenticated as " + who + ", which deliberately lacks `workflows` permission. GitHub will refuse that push. Commit the workflow change as a human, or rerun the CLI under your own GitHub auth."
159+
}
160+
161+
// quoteList renders paths for an escalation message. Naming them is the point:
162+
// without it a reader has only the agent's own description of what it changed,
163+
// which is not evidence.
164+
func quoteList(paths []string) string {
165+
quoted := make([]string, 0, len(paths))
166+
for _, p := range paths {
167+
quoted = append(quoted, "`"+p+"`")
168+
}
169+
return strings.Join(quoted, ", ")
155170
}
156171

157172
func (e *Engine) workflowPushReason(err error) string {
158173
if err == nil {
159174
return ""
160175
}
161176
if strings.Contains(err.Error(), workflowPushRefusal) {
162-
return workflowPermissionReason(e.Cfg.SelfLogin)
177+
return workflowPermissionReason(e.Cfg.SelfLogin, nil)
163178
}
164179
return ""
165180
}
@@ -172,14 +187,14 @@ func (e *Engine) workflowPreflight(ctx context.Context) (string, error) {
172187
if !ok {
173188
return "", nil
174189
}
175-
touched, err := vcs.TouchesWorkflow(ctx, e.Cfg.WorkDir)
190+
paths, err := vcs.WorkflowChanges(ctx, e.Cfg.WorkDir)
176191
if err != nil {
177192
return "", err
178193
}
179-
if !touched {
194+
if len(paths) == 0 {
180195
return "", nil
181196
}
182-
return workflowPermissionReason(e.Cfg.SelfLogin), nil
197+
return workflowPermissionReason(e.Cfg.SelfLogin, paths), nil
183198
}
184199

185200
// log records an event if a ledger is configured; otherwise it is a no-op.

‎internal/loop/loop_vcs_test.go‎

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@ import (
1212
)
1313

1414
type fakeVCS struct {
15-
committed bool
16-
commitDone bool
17-
commitErr error
18-
pushDone bool
19-
pushErr error
20-
pushedBranch string
21-
syncedBranch string
22-
workflows bool
23-
workflowErr error
24-
commitMsg string
15+
committed bool
16+
commitDone bool
17+
commitErr error
18+
pushDone bool
19+
pushErr error
20+
pushedBranch string
21+
syncedBranch string
22+
workflows bool
23+
workflowPaths []string
24+
workflowErr error
25+
commitMsg string
2526
}
2627

2728
func (f *fakeVCS) Commit(_ context.Context, _, msg string) (bool, error) {
@@ -41,8 +42,17 @@ func (f *fakeVCS) Sync(_ context.Context, _, branch string) error {
4142
return nil
4243
}
4344

44-
func (f *fakeVCS) TouchesWorkflow(_ context.Context, _ string) (bool, error) {
45-
return f.workflows, f.workflowErr
45+
func (f *fakeVCS) WorkflowChanges(_ context.Context, _ string) ([]string, error) {
46+
if f.workflowErr != nil {
47+
return nil, f.workflowErr
48+
}
49+
if len(f.workflowPaths) > 0 {
50+
return f.workflowPaths, nil
51+
}
52+
if f.workflows {
53+
return []string{".github/workflows/check.yml"}, nil
54+
}
55+
return nil, nil
4656
}
4757

4858
func TestOpenPRCommitsAndPushesFirst(t *testing.T) {
@@ -221,7 +231,7 @@ func TestWorkflowEscalationNamesTheAuthenticatedIdentity(t *testing.T) {
221231
})
222232

223233
t.Run("says a GitHub App when the identity is unknown", func(t *testing.T) {
224-
if got := workflowPermissionReason(""); !strings.Contains(got, "authenticated as a GitHub App") {
234+
if got := workflowPermissionReason("", nil); !strings.Contains(got, "authenticated as a GitHub App") {
225235
t.Fatalf("reason = %q, expected the generic form when no login is known", got)
226236
}
227237
})
@@ -289,3 +299,38 @@ func TestWorkflowPreflightOnlyBlocksWhenAWorkflowActuallyChanged(t *testing.T) {
289299
}
290300
})
291301
}
302+
303+
// An escalation that names no file asks the reader to trust the agent's own
304+
// account of what it changed. That is not evidence, and getting it wrong costs
305+
// a real investigation, so the message must name the paths git actually saw.
306+
func TestWorkflowEscalationNamesTheFilesThatCausedIt(t *testing.T) {
307+
f := &forgefake.Forge{}
308+
eng := &Engine{
309+
Runner: enginefake.New(enginefake.Step{Summary: "edited a workflow", Apply: writeFixed}),
310+
Gate: gateChecksFile(),
311+
Forge: f,
312+
VCS: &fakeVCS{workflowPaths: []string{
313+
".github/workflows/check.yml",
314+
".github/workflows/release.yml",
315+
}},
316+
Cfg: Config{
317+
WorkDir: t.TempDir(),
318+
Branch: "loop/1",
319+
WorkflowRestrictedPush: true,
320+
SelfLogin: "acme-code[bot]",
321+
},
322+
}
323+
324+
res, err := eng.Run(context.Background(), domain.Issue{Repo: "o/r", Number: 1})
325+
if err != nil {
326+
t.Fatalf("unexpected error: %v", err)
327+
}
328+
if res.Outcome != OutcomeBlocked {
329+
t.Fatalf("outcome = %s want blocked", res.Outcome)
330+
}
331+
for _, want := range []string{"`.github/workflows/check.yml`", "`.github/workflows/release.yml`"} {
332+
if !strings.Contains(res.Reason, want) {
333+
t.Fatalf("reason = %q, expected it to name %s", res.Reason, want)
334+
}
335+
}
336+
}

‎internal/vcs/git/git.go‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,35 @@ func (g *Git) Push(ctx context.Context, dir, branch string) error {
115115
return err
116116
}
117117

118-
// TouchesWorkflow reports whether the working tree has tracked or untracked
119-
// changes under `.github/workflows/`.
120-
func (g *Git) TouchesWorkflow(ctx context.Context, dir string) (bool, error) {
121-
out, err := g.run(ctx, dir, "status", "--porcelain", "--", ".github/workflows")
118+
// WorkflowChanges lists the tracked or untracked paths under
119+
// `.github/workflows/` that the working tree has changed. An empty result means
120+
// nothing there changed. The paths are returned rather than a bool so an
121+
// escalation can name the files that caused it, instead of asking the reader to
122+
// take the agent's word for what it edited.
123+
func (g *Git) WorkflowChanges(ctx context.Context, dir string) ([]string, error) {
124+
// -uall lists untracked files individually. Without it git collapses a new
125+
// directory to a single "?? .github/workflows/" entry, and an escalation
126+
// that names a directory is barely better than one that names nothing.
127+
out, err := g.run(ctx, dir, "status", "--porcelain", "-uall", "--", ".github/workflows")
122128
if err != nil {
123-
return false, err
129+
return nil, err
130+
}
131+
var paths []string
132+
for _, line := range strings.Split(out, "\n") {
133+
// Porcelain v1 is "XY <path>", and a rename is "R <old> -> <new>".
134+
// The destination is the file that would be pushed, so report that.
135+
if len(line) < 4 {
136+
continue
137+
}
138+
path := strings.TrimSpace(line[3:])
139+
if _, dst, ok := strings.Cut(path, " -> "); ok {
140+
path = dst
141+
}
142+
if path = strings.Trim(path, `"`); path != "" {
143+
paths = append(paths, path)
144+
}
124145
}
125-
return strings.TrimSpace(out) != "", nil
146+
return paths, nil
126147
}
127148

128149
// Sync hard-resets dir to the remote tip of branch. It fetches the branch and

‎internal/vcs/git/git_test.go‎

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,14 @@ func TestCommitWithoutAnIdentityStillWorksWhenGitHasOne(t *testing.T) {
152152
}
153153
}
154154

155-
func TestTouchesWorkflowReportsOnlyWorkflowChanges(t *testing.T) {
155+
func TestWorkflowChangesReportsOnlyWorkflowChanges(t *testing.T) {
156156
work := repoWithBareRemote(t)
157157
g := &Git{}
158158
ctx := context.Background()
159159

160-
touched, err := g.TouchesWorkflow(ctx, work)
161-
if err != nil || touched {
162-
t.Fatalf("clean repo should report no workflow changes: touched=%v err=%v", touched, err)
160+
paths, err := g.WorkflowChanges(ctx, work)
161+
if err != nil || len(paths) != 0 {
162+
t.Fatalf("clean repo should report no workflow changes: paths=%v err=%v", paths, err)
163163
}
164164

165165
if err := os.MkdirAll(filepath.Join(work, ".github", "workflows"), 0o755); err != nil {
@@ -168,27 +168,27 @@ func TestTouchesWorkflowReportsOnlyWorkflowChanges(t *testing.T) {
168168
if err := os.WriteFile(filepath.Join(work, ".github", "workflows", "check.yml"), []byte("name: check\n"), 0o644); err != nil {
169169
t.Fatal(err)
170170
}
171-
touched, err = g.TouchesWorkflow(ctx, work)
172-
if err != nil || !touched {
173-
t.Fatalf("workflow edit should be reported: touched=%v err=%v", touched, err)
171+
paths, err = g.WorkflowChanges(ctx, work)
172+
if err != nil || len(paths) != 1 || paths[0] != ".github/workflows/check.yml" {
173+
t.Fatalf("an edit must be reported by path so an escalation can name it: paths=%v err=%v", paths, err)
174174
}
175175

176176
if _, err := g.Commit(ctx, work, "add workflow"); err != nil {
177177
t.Fatalf("Commit: %v", err)
178178
}
179-
touched, err = g.TouchesWorkflow(ctx, work)
180-
if err != nil || touched {
181-
t.Fatalf("committed workflow should leave a clean tree: touched=%v err=%v", touched, err)
179+
paths, err = g.WorkflowChanges(ctx, work)
180+
if err != nil || len(paths) != 0 {
181+
t.Fatalf("committed workflow should leave a clean tree: paths=%v err=%v", paths, err)
182182
}
183183

184184
if err := os.WriteFile(filepath.Join(work, "plain.txt"), []byte("x"), 0o644); err != nil {
185185
t.Fatal(err)
186186
}
187-
touched, err = g.TouchesWorkflow(ctx, work)
187+
paths, err = g.WorkflowChanges(ctx, work)
188188
if err != nil {
189-
t.Fatalf("TouchesWorkflow: %v", err)
189+
t.Fatalf("WorkflowChanges: %v", err)
190190
}
191-
if touched {
191+
if len(paths) > 0 {
192192
t.Fatal("non-workflow edits must not trip workflow detection")
193193
}
194194
}
@@ -217,3 +217,36 @@ func TestDryRunCommitsButDoesNotPush(t *testing.T) {
217217
t.Fatalf("a dry run must not push, but the remote has the branch: %s", out)
218218
}
219219
}
220+
221+
// Porcelain renders a staged rename as "R <old> -> <new>". The destination is
222+
// the file that would be pushed, so that is the one an escalation should name.
223+
func TestWorkflowChangesReportsTheDestinationOfARename(t *testing.T) {
224+
work := repoWithBareRemote(t)
225+
g := &Git{}
226+
ctx := context.Background()
227+
228+
dir := filepath.Join(work, ".github", "workflows")
229+
if err := os.MkdirAll(dir, 0o755); err != nil {
230+
t.Fatal(err)
231+
}
232+
if err := os.WriteFile(filepath.Join(dir, "old.yml"), []byte("name: old\n"), 0o644); err != nil {
233+
t.Fatal(err)
234+
}
235+
if _, err := g.Commit(ctx, work, "add workflow"); err != nil {
236+
t.Fatalf("Commit: %v", err)
237+
}
238+
239+
mv := exec.Command("git", "mv", ".github/workflows/old.yml", ".github/workflows/new.yml")
240+
mv.Dir = work
241+
if out, err := mv.CombinedOutput(); err != nil {
242+
t.Fatalf("git mv: %v: %s", err, out)
243+
}
244+
245+
paths, err := g.WorkflowChanges(ctx, work)
246+
if err != nil {
247+
t.Fatalf("WorkflowChanges: %v", err)
248+
}
249+
if len(paths) != 1 || paths[0] != ".github/workflows/new.yml" {
250+
t.Fatalf("paths = %v, want the rename destination", paths)
251+
}
252+
}

0 commit comments

Comments
 (0)