Skip to content
Open
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
54 changes: 46 additions & 8 deletions internal/layers/enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ const (

// repoMaintenanceWorkflow is the workflow file that handles enrollment.
repoMaintenanceWorkflow = "repo-maintenance.yml"

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] comment-style

The new constants use multi-line comments while existing constants in the same block use single-line comments. The difference is driven by content length.

// enrollmentWaitTimeout is the maximum time to wait for the
// repo-maintenance workflow run to appear and complete.
enrollmentWaitTimeout = 3 * time.Minute

// enrollmentPollInitial is the initial polling interval for
// workflow run status checks.
enrollmentPollInitial = 2 * time.Second

// enrollmentPollMax is the maximum polling interval (backoff cap).
enrollmentPollMax = 15 * time.Second
)

// EnrollmentLayer monitors workflow-driven enrollment of target repos.
Expand Down Expand Up @@ -82,11 +93,11 @@ func (l *EnrollmentLayer) Install(ctx context.Context) error {
}
l.ui.StepDone("dispatched repo-maintenance workflow")

// Wait for the workflow run to complete.
// Wait for the workflow run to complete (bounded by enrollmentWaitTimeout).

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] error-handling-gap

The diff removes the StepInfo guidance from Install error handler. For context cancellation errors, the user no longer receives guidance about where to check results, unlike the Uninstall path which retains this message.

l.ui.StepStart("waiting for enrollment workflow to complete")
run, err := l.awaitWorkflowRun(ctx, dispatchTime)
if err != nil {
l.ui.StepWarn(fmt.Sprintf("could not confirm enrollment: %v", err))
l.ui.StepInfo("check the repo-maintenance workflow in .fullsend for results")
return nil // non-fatal — enrollment may still succeed
}

Expand All @@ -105,18 +116,35 @@ func (l *EnrollmentLayer) Install(ctx context.Context) error {
}

// awaitWorkflowRun polls for a repo-maintenance workflow run created after
// dispatchTime and waits for it to complete.
// dispatchTime and waits for it to complete. It uses exponential backoff
// and a bounded timeout to avoid long silent waits.
func (l *EnrollmentLayer) awaitWorkflowRun(ctx context.Context, dispatchTime time.Time) (*forge.WorkflowRun, error) {
for attempt := range 36 { // 3 minutes max
deadline := time.Now().Add(enrollmentWaitTimeout)
interval := enrollmentPollInitial
start := time.Now()

for {
if time.Now().After(deadline) {
elapsed := time.Since(start).Round(time.Second)
return nil, fmt.Errorf(
"timed out after %s waiting for repo-maintenance workflow; "+
"check the workflow in .fullsend and re-run install if needed",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] logic-error

The timeout error message from awaitWorkflowRun contains caller-specific guidance (re-run install if needed), but this method is also called by Uninstall (line 248). When the timeout fires during uninstall, the user sees misleading guidance to re-run install.

Suggested fix: Make the error message generic (remove and re-run install if needed) and let the caller append context-appropriate guidance, or pass an operation label into awaitWorkflowRun.

elapsed,
)
}

select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(5 * time.Second):
case <-time.After(interval):
}

elapsed := time.Since(start).Round(time.Second)

runs, err := l.client.ListWorkflowRuns(ctx, l.org, forge.ConfigRepoName, repoMaintenanceWorkflow)
if err != nil {
l.ui.StepInfo(fmt.Sprintf("waiting for workflow run (attempt %d)...", attempt+1))
l.ui.StepInfo(fmt.Sprintf("waiting for workflow registration (%s elapsed)...", elapsed))
interval = nextInterval(interval)
continue
}

Expand All @@ -133,11 +161,21 @@ func (l *EnrollmentLayer) awaitWorkflowRun(ctx context.Context, dispatchTime tim
if run.Status == "completed" {
return run, nil
}
l.ui.StepInfo(fmt.Sprintf("workflow run: %s (%s)", run.HTMLURL, run.Status))
l.ui.StepInfo(fmt.Sprintf("workflow run %s (%s, %s elapsed)", run.HTMLURL, run.Status, elapsed))
break // found our run, keep waiting
}

interval = nextInterval(interval)
}
}

// nextInterval doubles the polling interval up to enrollmentPollMax.
func nextInterval(current time.Duration) time.Duration {
next := current * 2
if next > enrollmentPollMax {
return enrollmentPollMax
}
return nil, fmt.Errorf("timed out waiting for repo-maintenance workflow")
return next
}

// showWorkflowLogs fetches and displays workflow run logs locally so the user
Expand Down
37 changes: 37 additions & 0 deletions internal/layers/enrollment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,40 @@ func TestEnrollmentLayer_Analyze_PerRepoGuardCheckError(t *testing.T) {
assert.Contains(t, report.Details[0], "all 1 repos failed guard check")
assert.Contains(t, report.Details[1], "guard check failed, skipped")
}

func TestEnrollmentLayer_Install_ContextCancelled(t *testing.T) {
// No workflow runs configured — awaitWorkflowRun will poll until
// context is cancelled.
client := &forge.FakeClient{}
repos := []string{"repo-a"}
layer, buf := newEnrollmentLayer(t, client, repos, nil)

ctx, cancel := context.WithCancel(context.Background())
// Cancel immediately so the first poll iteration exits.
cancel()

err := layer.Install(ctx)
require.NoError(t, err) // Install treats timeout/cancel as non-fatal

output := buf.String()
assert.Contains(t, output, "could not confirm enrollment")
}

func TestNextInterval(t *testing.T) {
tests := []struct {
name string
current time.Duration
expected time.Duration
}{
{"doubles small interval", 2 * time.Second, 4 * time.Second},
{"doubles again", 4 * time.Second, 8 * time.Second},
{"caps at max", 8 * time.Second, enrollmentPollMax},
{"stays at max", enrollmentPollMax, enrollmentPollMax},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := nextInterval(tt.current)
assert.Equal(t, tt.expected, got)
})
}
}
Loading