Skip to content

Commit 75a8189

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@407268a57ca56f64b87013573c29bc72058fff28
1 parent c864bab commit 75a8189

5 files changed

Lines changed: 258 additions & 0 deletions

File tree

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/src/types/contracts/bigquery.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export type ChatCompletionTraceRow = {
4646
tool_count: number
4747
tools?: unknown[] | null
4848
tools_omitted: boolean
49+
repo_snapshot?: unknown | null
50+
surface?: string | null
4951
}
5052

5153
export type InsertChatCompletionTraceBigqueryFn = (params: {
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import {
4+
REPO_SNAPSHOT_FIELDS,
5+
toRepoSnapshot,
6+
type RepoSnapshot,
7+
} from '../file'
8+
9+
import type { ProjectFileContext } from '../file'
10+
11+
type GitChanges = ProjectFileContext['gitChanges']
12+
13+
/** A snapshot with every aggregate field set, plus the legacy fields an older
14+
* client still populates. The legacy ones are the hazard this suite exists
15+
* for: they carry raw patch content. */
16+
const fullGitChanges = (): GitChanges => ({
17+
gitAvailable: true,
18+
branch: 'feature/secret-project-name',
19+
changedFiles: ['src/internal/pricing.ts', 'docs/acquisition-memo.md'],
20+
changedFileCount: 2,
21+
changedFileScanTruncated: false,
22+
repositoryVisibility: 'private',
23+
commitCount: 4471,
24+
historyIsShallow: false,
25+
historyScanTruncated: false,
26+
commitDatePercentiles: {
27+
p0: '2019-03-02',
28+
p25: '2021-07-14',
29+
p50: '2023-01-09',
30+
p75: '2024-11-30',
31+
p100: '2026-08-24',
32+
},
33+
mergedPullRequestCount: 812,
34+
humanContributorCount: 34,
35+
botContributorCount: 2,
36+
contributorCount: 36,
37+
fileCount: 8123,
38+
fileCountIsLowerBound: false,
39+
testFileCount: 611,
40+
status: 'On branch feature/secret-project-name\nChanges not staged',
41+
diff: 'diff --git a/src/internal/pricing.ts b/src/internal/pricing.ts\n+const MARGIN = 0.42',
42+
diffCached: 'diff --git a/docs/acquisition-memo.md b/docs/acquisition-memo.md\n+Target: $4M',
43+
lastCommitMessages: 'Raise the enterprise floor before the Q3 renewal',
44+
})
45+
46+
describe('toRepoSnapshot', () => {
47+
it('never emits patch content, paths, or branch names', () => {
48+
const snapshot = toRepoSnapshot(fullGitChanges())
49+
const serialized = JSON.stringify(snapshot)
50+
51+
// The four legacy fields hold raw repository source. A spread instead of
52+
// the allowlist would put all of them in the warehouse.
53+
for (const forbidden of [
54+
'status',
55+
'diff',
56+
'diffCached',
57+
'lastCommitMessages',
58+
// Names and paths are out of contract for an aggregate snapshot.
59+
'branch',
60+
'changedFiles',
61+
]) {
62+
expect(snapshot).not.toHaveProperty(forbidden)
63+
}
64+
65+
// Belt and braces: assert on the serialized bytes too, so a future field
66+
// that merely *embeds* one of these values also trips the test.
67+
expect(serialized).not.toContain('diff --git')
68+
expect(serialized).not.toContain('MARGIN')
69+
expect(serialized).not.toContain('acquisition-memo')
70+
expect(serialized).not.toContain('secret-project-name')
71+
expect(serialized).not.toContain('Raise the enterprise floor')
72+
})
73+
74+
it('keeps every aggregate the scoring job reads', () => {
75+
const snapshot = toRepoSnapshot(fullGitChanges())
76+
77+
expect(snapshot).toMatchObject({
78+
gitAvailable: true,
79+
repositoryVisibility: 'private',
80+
commitCount: 4471,
81+
mergedPullRequestCount: 812,
82+
humanContributorCount: 34,
83+
botContributorCount: 2,
84+
fileCount: 8123,
85+
testFileCount: 611,
86+
})
87+
expect(snapshot?.commitDatePercentiles?.p50).toBe('2023-01-09')
88+
})
89+
90+
it('carries the lower-bound flags, which are unrecoverable if dropped', () => {
91+
const snapshot = toRepoSnapshot({
92+
...fullGitChanges(),
93+
historyIsShallow: true,
94+
historyScanTruncated: true,
95+
fileCountIsLowerBound: true,
96+
})
97+
98+
expect(snapshot?.historyIsShallow).toBe(true)
99+
expect(snapshot?.historyScanTruncated).toBe(true)
100+
expect(snapshot?.fileCountIsLowerBound).toBe(true)
101+
})
102+
103+
it('omits absent fields rather than emitting undefined', () => {
104+
const snapshot = toRepoSnapshot({ gitAvailable: true, fileCount: 12 })
105+
106+
expect(Object.keys(snapshot ?? {}).sort()).toEqual([
107+
'fileCount',
108+
'gitAvailable',
109+
])
110+
})
111+
112+
it('returns undefined when there is nothing to record', () => {
113+
expect(toRepoSnapshot(undefined)).toBeUndefined()
114+
// A legacy-only snapshot carries no allowlisted field, so it must produce
115+
// an absent column rather than a row of nulls that reads as measured zero.
116+
expect(
117+
toRepoSnapshot({ status: 'On branch main', diff: 'diff --git a/a b/a' }),
118+
).toBeUndefined()
119+
})
120+
121+
it('emits false and zero, which are values rather than absences', () => {
122+
const snapshot = toRepoSnapshot({
123+
gitAvailable: false,
124+
commitCount: 0,
125+
testFileCount: 0,
126+
})
127+
128+
expect(snapshot?.gitAvailable).toBe(false)
129+
expect(snapshot?.commitCount).toBe(0)
130+
expect(snapshot?.testFileCount).toBe(0)
131+
})
132+
133+
it('has no allowlisted field that can hold free text', () => {
134+
// Every field in the contract must be a count, a boolean, a bounded enum,
135+
// or the date-percentile object. A future string field is how a path or a
136+
// repo name gets back in, so the shape is asserted rather than reviewed.
137+
const snapshot = toRepoSnapshot(fullGitChanges()) as Record<string, unknown>
138+
139+
for (const key of REPO_SNAPSHOT_FIELDS) {
140+
const value = snapshot[key]
141+
if (value === undefined) continue
142+
if (key === 'repositoryVisibility') {
143+
expect(['public', 'private', 'internal', 'unknown']).toContain(value)
144+
continue
145+
}
146+
if (key === 'commitDatePercentiles') {
147+
expect(typeof value).toBe('object')
148+
continue
149+
}
150+
expect(['number', 'boolean']).toContain(typeof value)
151+
}
152+
})
153+
154+
it('is the single source of truth for the field list', () => {
155+
// The trace writer re-applies this allowlist server-side, because the
156+
// client-side projection runs in code we do not control at runtime. If the
157+
// two lists drift, a field added here is silently stripped on arrival —
158+
// which looks like a producer bug and is not one.
159+
//
160+
// Asserted as a literal so adding a field is a visible line in a diff, the
161+
// same way REPO_SNAPSHOT_FIELDS itself is.
162+
expect([...REPO_SNAPSHOT_FIELDS].sort()).toEqual([
163+
'botContributorCount',
164+
'changedFileCount',
165+
'changedFileScanTruncated',
166+
'commitCount',
167+
'commitDatePercentiles',
168+
'contributorCount',
169+
'fileCount',
170+
'fileCountIsLowerBound',
171+
'gitAvailable',
172+
'historyIsShallow',
173+
'historyScanTruncated',
174+
'humanContributorCount',
175+
'mergedPullRequestCount',
176+
'repositoryVisibility',
177+
'testFileCount',
178+
])
179+
})
180+
181+
it('exposes a type that matches the runtime allowlist', () => {
182+
// Compile-time guard: RepoSnapshot must stay derived from the field list,
183+
// so adding a key to one without the other fails to typecheck.
184+
const snapshot: RepoSnapshot | undefined = toRepoSnapshot(fullGitChanges())
185+
expect(snapshot).toBeDefined()
186+
})
187+
})

common/src/util/file.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,66 @@ export type ProjectFileContext = {
182182
}
183183
}
184184

185+
/**
186+
* The ONLY fields of `gitChanges` that may leave the client as telemetry.
187+
*
188+
* An explicit allowlist, never a spread, and the exclusions are the point:
189+
*
190+
* - `status` / `diff` / `diffCached` / `lastCommitMessages` are the legacy
191+
* fields still populated by older clients, and they hold **raw patch
192+
* content**. Spreading `gitChanges` would put repository source into the
193+
* warehouse.
194+
* - `branch` and `changedFiles` are names and paths. A repository snapshot is
195+
* aggregate scalars, not a manifest; paths and branches are out of contract
196+
* (see infobuff docs/specs/2026-08-22-repository-feature-extractor-v2.md).
197+
*
198+
* Everything kept is a count, a bounded enum, or a date percentile. Adding a
199+
* field here is a deliberate act; widening it by spread is not available.
200+
*/
201+
export const REPO_SNAPSHOT_FIELDS = [
202+
'gitAvailable',
203+
'repositoryVisibility',
204+
'fileCount',
205+
'fileCountIsLowerBound',
206+
'testFileCount',
207+
'commitCount',
208+
'historyIsShallow',
209+
'historyScanTruncated',
210+
'commitDatePercentiles',
211+
'mergedPullRequestCount',
212+
'humanContributorCount',
213+
'botContributorCount',
214+
'contributorCount',
215+
'changedFileCount',
216+
'changedFileScanTruncated',
217+
] as const
218+
219+
export type RepoSnapshot = Pick<
220+
ProjectFileContext['gitChanges'],
221+
(typeof REPO_SNAPSHOT_FIELDS)[number]
222+
>
223+
224+
/**
225+
* Project `gitChanges` down to the exportable aggregate contract.
226+
*
227+
* Returns undefined when the snapshot carries nothing worth recording, so a
228+
* client with no Git and a client that never reported are the same absent
229+
* column rather than a row of nulls that reads as measured zeroes.
230+
*/
231+
export const toRepoSnapshot = (
232+
gitChanges: ProjectFileContext['gitChanges'] | undefined,
233+
): RepoSnapshot | undefined => {
234+
if (!gitChanges) return undefined
235+
const snapshot: Record<string, unknown> = {}
236+
for (const field of REPO_SNAPSHOT_FIELDS) {
237+
const value = gitChanges[field]
238+
if (value !== undefined) snapshot[field] = value
239+
}
240+
return Object.keys(snapshot).length > 0
241+
? (snapshot as RepoSnapshot)
242+
: undefined
243+
}
244+
185245
export const fileRegex =
186246
/<write_file>\s*<path>([^<]+)<\/path>\s*<content>([\s\S]*?)<\/content>\s*<\/write_file>/g
187247
export const fileWithNoPathRegex = /<write_file>([\s\S]*?)<\/write_file>/g

sdk/src/run.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
withSystemTags,
88
} from '@codebuff/agent-runtime/util/messages'
99
import { MAX_AGENT_STEPS_DEFAULT } from '@codebuff/common/constants/agents'
10+
import { toRepoSnapshot } from '@codebuff/common/util/file'
1011
import { dropUnansweredToolCalls } from '@codebuff/common/util/messages'
1112
import {
1213
FILE_READ_STATUS,
@@ -801,6 +802,8 @@ async function runOnce({
801802
}
802803
: undefined
803804

805+
const repoSnapshot = toRepoSnapshot(sessionState.fileContext?.gitChanges)
806+
804807
callMainPrompt({
805808
...agentRuntimeImpl,
806809
promptId,
@@ -824,6 +827,10 @@ async function runOnce({
824827
extraCodebuffMetadata: {
825828
...(extraCodebuffMetadata ?? {}),
826829
trace_session_id: traceSessionId,
830+
// Aggregate repository scale, collected once per thread by getGitChanges
831+
// (#2138) and reused on later turns. Sent as the allowlisted projection
832+
// so the legacy patch-content fields can never ride along.
833+
...(repoSnapshot && { repo_snapshot: JSON.stringify(repoSnapshot) }),
827834
},
828835
signal: signal ?? new AbortController().signal,
829836
onAgentUsageReceived: report(onUsage),

0 commit comments

Comments
 (0)