feat: populate CI invocation context on cloud events (#304)

* feat: add CloudSinkEnvResolver interface with default implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add GitHub Actions environment resolver for cloud sink

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: populate invocation context with CI environment on cloud events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address lint errors in cloud sink tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use getter-based CloudSinkCIResolver with nil-when-no-CI

Rename to CloudSinkCIResolver with focused CI concern. Factory returns
nil when no CI is detected, removing the need for IsCI() and a default
resolver. Leaves room for a separate agent resolver in the future.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add CI metadata support using updated API SDK

Update SDK to include SetMetadata on EndpointCIContext. Add Metadata()
to CloudSinkCIResolver interface and GitHub Actions implementation
(workflow, job, run_attempt, server_url). Wire metadata into
buildInvocationContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: address review comments on CI resolver

- Inject CloudSinkCIResolver as dependency into newCloudSink for testability
- Check both GITHUB_ACTIONS and GITHUB_RUN_ID for GHA environment detection
- Make factory and constructor package-private (newCloudSinkCIResolver,
  newGithubActionsCIResolver)
- Attach invocation context only to session complete events, not every event

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: fail fast on os.Getwd error instead of swallowing it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sahil Bansal
2026-05-28 19:12:12 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 4540dafccc
commit 6087bc922f
9 changed files with 365 additions and 20 deletions
+57
View File
@@ -40,6 +40,7 @@ func newTestCloudSink(t *testing.T, transport endpointsync.EventTransport) *clou
return &cloudSink{
SyncClientBundle: &SyncClientBundle{syncClient: syncClient},
invocationID: "test-invocation",
workingDir: t.TempDir(),
}
}
@@ -98,3 +99,59 @@ func TestCloudSinkEmitAndSync(t *testing.T) {
assert.Equal(t, 1, synced)
assert.Equal(t, 1, len(transport.requests))
}
func TestCloudSinkSetsInvocationContextOnSessionComplete(t *testing.T) {
transport := &mockTransport{}
sink := newTestCloudSink(t, transport)
defer func() {
require.NoError(t, sink.Close())
}()
ctx := context.Background()
err := sink.Handle(ctx, AuditEvent{
Type: EventTypeInstallStarted,
Timestamp: time.Now(),
PackageManager: "npm",
Args: []string{"install", "express"},
})
require.NoError(t, err)
err = sink.Handle(ctx, AuditEvent{
Type: EventTypeMalwareBlocked,
Timestamp: time.Now(),
Message: "blocked malware package",
})
require.NoError(t, err)
err = sink.Handle(ctx, AuditEvent{
Type: EventTypeSessionComplete,
Timestamp: time.Now(),
SessionData: &SessionData{
PackageManager: "npm",
FlowType: FlowTypeGuard,
Outcome: OutcomeSuccess,
TotalAnalyzed: 1,
AllowedCount: 1,
},
})
require.NoError(t, err)
synced, err := sink.syncClient.Sync(ctx)
require.NoError(t, err)
assert.Equal(t, 2, synced)
require.Equal(t, 1, len(transport.requests))
events := transport.requests[0].GetEvents()
require.Equal(t, 2, len(events))
malwareEvent := events[0]
assert.Nil(t, malwareEvent.GetInvocationContext(), "non-session events should not have invocation context")
sessionEvent := events[1]
invCtx := sessionEvent.GetInvocationContext()
require.NotNil(t, invCtx, "session complete event must have invocation context")
assert.Contains(t, invCtx.GetCommand(), "npm")
assert.NotEmpty(t, invCtx.GetWorkingDirectory())
}