Skip to content

Commit 0b8117f

Browse files
authored
Merge pull request #353 from mean-weasel/codex/per-install-feedback-count
feat: count feedback by GitHub installation
2 parents a25ff61 + ada6920 commit 0b8117f

22 files changed

Lines changed: 1355 additions & 85 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Per-installation successful feedback counts
2+
3+
BugDrop can privately count successful GitHub Issues per GitHub App installation without adding a
4+
database. The feature reuses the existing `FEEDBACK_COUNTER` Durable Object namespace for atomic
5+
counts and asynchronously mirrors the latest durable integer into the existing
6+
`INSTALLATION_ANALYTICS` KV namespace for the operator-only consent review.
7+
8+
## Data boundary
9+
10+
Each usage record contains exactly:
11+
12+
```json
13+
{
14+
"schemaVersion": 1,
15+
"installationId": 123,
16+
"successfulFeedbackCount": 7
17+
}
18+
```
19+
20+
The count covers successful feedback Issues created in every repository available through that
21+
installation. It is prospective and does not backfill older Issues. BugDrop does not store the
22+
repository, Issue contents, reporter, submission timestamp, or last-active date in this record.
23+
The public aggregate feedback counter remains separate and rounded.
24+
25+
## Activation and rollback
26+
27+
Collection is off unless `INSTALLATION_USAGE_ENABLED` is exactly `true`. Do not enable it until the
28+
published privacy policy accurately discloses the purpose, fields, retention, and deletion behavior.
29+
Turning the setting off immediately stops accepting new per-installation events without affecting
30+
the public anonymous total. A count already accepted by the Durable Object may finish mirroring to
31+
KV. Keep the existing `FEEDBACK_COUNTER` binding available after activation so uninstall cleanup
32+
can remove previously stored durable counts.
33+
34+
After enabling it, dogfood one controlled installation and verify that its private record has only
35+
the three allowed fields above. Uninstall it and verify that the identity, usage mirror, and atomic
36+
counter are removed.
37+
38+
## Deletion behavior
39+
40+
While collection is enabled, an uninstall webhook or retention sweep first writes a seven-day
41+
opaque KV deletion guard, then sets a strongly consistent seven-day deletion marker in the
42+
installation's Durable Object. It then deletes the atomic count and KV usage mirror, and removes the
43+
installation identity last. This order makes a partial cleanup retryable and prevents a delayed
44+
in-flight submission from recreating usage after uninstall. While collection is disabled, cleanup
45+
hard-purges any old counter and mirror without creating those guards. No installation ID or account
46+
identity is exposed through a public endpoint.
47+
48+
The Durable Object coalesces bursts into a single delayed KV write, avoiding Workers KV's
49+
same-key write-rate limit. Its alarm retries a failed mirror write; the operator view can lag a
50+
successful submission briefly while the durable count remains authoritative.
51+
52+
The mirror is not created until the installation identity record is visible. If that record is
53+
still propagating, the Durable Object makes at most 1,440 one-minute retry checks without resetting
54+
the original budget. The budget is a non-temporal integer; no submission timestamp is stored. If no
55+
identity arrives, it purges the unanchored counter instead of retaining usage that the scheduled
56+
cleanup cannot discover. This intentionally favors deletion safety over completeness for a
57+
pre-tracking installation or a permanently missed installation-created webhook.
58+
59+
Delivery uses one opaque event ID across retries, so an ambiguous retry does not increment twice.
60+
It has the same best-effort delivery boundary as the existing anonymous public counter; it is not a
61+
billing ledger.

docs/social-proof-consent.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ authorizes public display from an installation alone.
1212
inside the repository, including paths reached through a symlinked parent directory, and refuses
1313
input files readable or writable by other users.
1414
- Never commit the registry, fingerprint key, permission evidence, or installation identities.
15-
- Do not add repository information, usage frequency, issue counts, email addresses, or other
16-
enrichment to this workflow.
15+
- Do not add repository information, issue contents, reporter details, email addresses, last-active
16+
dates, or other enrichment to this workflow. The only allowed usage signal is the exact count of
17+
successful feedback Issues associated with an installation.
1718
- Contact an app owner at most once unless they reply. Record `contacted`, `declined`, `approved`,
1819
or `withdrawn` before reviewing candidates again.
1920
- Publishing requires affirmative permission from an authorized representative. Copy only the
@@ -45,11 +46,13 @@ npm run social-proof:consent -- review \
4546
--exclude mean-weasel,neonwatty
4647
```
4748

48-
The private terminal displays the currently eligible account, profile, installation date, and its
49-
keyed fingerprint. The command does not save those installation identities to another file. Close
50-
the terminal session after finishing the review. The command rejects installation records with any
51-
fields beyond the approved minimal schema. The exclusion list is required so owned and controlled
52-
test accounts cannot accidentally enter the outreach queue.
49+
The private terminal displays the currently eligible account, profile, installation date, keyed
50+
fingerprint, and—when collection was enabled for that installation—the successful-feedback count.
51+
Known counts are ranked highest first; a missing count is not presented as zero. The command does
52+
not save installation identities or usage counts to another file. Close the terminal session after
53+
finishing the review. The command rejects identity and usage records with any fields beyond their
54+
approved minimal schemas. The exclusion list is required so owned and controlled test accounts
55+
cannot accidentally enter the outreach queue.
5356

5457
## Record decisions
5558

@@ -80,6 +83,6 @@ npm run social-proof:consent -- export-approved \
8083
--output /private/path/bugdrop-approved-social-proof.json
8184
```
8285

83-
This export contains only approved public profile fields. It omits account fingerprints, private
84-
evidence references, contact status, and all unapproved apps. Review the output against the
85-
permission evidence before copying it into the website repository.
86+
This export contains only approved public profile fields. It omits account fingerprints, usage
87+
counts, private evidence references, contact status, and all unapproved apps. Review the output
88+
against the permission evidence before copying it into the website repository.

scripts/social-proof-consent-lib.mjs

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,33 @@ export function buildCandidateReview(
2727
registry,
2828
excludedLogins,
2929
fingerprintKey,
30-
generatedAt = new Date().toISOString()
30+
generatedAt = new Date().toISOString(),
31+
usageRecords = []
3132
) {
3233
validateRegistry(registry);
3334
validateRegistryKey(registry, fingerprintKey);
3435
assertIsoDate(generatedAt);
3536
const excluded = new Set(validateExcludedLogins(excludedLogins));
37+
const usageByInstallation = new Map();
38+
for (const record of usageRecords) {
39+
const valid = validateInstallationUsageRecord(record);
40+
if (usageByInstallation.has(valid.installationId)) {
41+
throw new Error('Duplicate installation usage record');
42+
}
43+
usageByInstallation.set(valid.installationId, valid.successfulFeedbackCount);
44+
}
3645
const decided = registry.entries.map(entry => Buffer.from(entry.accountFingerprint, 'hex'));
3746
const eligible = records
3847
.map(validateInstallationRecord)
39-
.map(record => ({
40-
account: record.account,
41-
installedAt: record.installedAt,
42-
accountFingerprint: fingerprintAccount(record.account.login, fingerprintKey),
43-
}))
48+
.map(record => {
49+
const successfulFeedbackCount = usageByInstallation.get(record.installationId);
50+
return {
51+
account: record.account,
52+
installedAt: record.installedAt,
53+
accountFingerprint: fingerprintAccount(record.account.login, fingerprintKey),
54+
...(successfulFeedbackCount === undefined ? {} : { successfulFeedbackCount }),
55+
};
56+
})
4457
.filter(
4558
record =>
4659
!excluded.has(record.account.login.toLocaleLowerCase('en-US')) &&
@@ -56,13 +69,26 @@ export function buildCandidateReview(
5669
candidatesByAccount.set(candidate.accountFingerprint, candidate);
5770
}
5871
}
59-
const candidates = [...candidatesByAccount.values()].sort((a, b) =>
60-
a.installedAt.localeCompare(b.installedAt)
61-
);
72+
const candidates = [...candidatesByAccount.values()].sort(compareCandidatePriority);
6273

6374
return { schemaVersion: 1, generatedAt, candidates };
6475
}
6576

77+
function validateInstallationUsageRecord(record) {
78+
if (
79+
!isObject(record) ||
80+
!hasExactKeys(record, ['schemaVersion', 'installationId', 'successfulFeedbackCount']) ||
81+
record.schemaVersion !== 1
82+
) {
83+
throw new Error('Invalid installation usage record');
84+
}
85+
assertPositiveInteger(record.installationId);
86+
if (!Number.isSafeInteger(record.successfulFeedbackCount) || record.successfulFeedbackCount < 0) {
87+
throw new Error('Invalid installation usage record');
88+
}
89+
return record;
90+
}
91+
6692
export function buildApprovedExport(registry, generatedAt = new Date().toISOString()) {
6793
validateRegistry(registry);
6894
assertIsoDate(generatedAt);
@@ -193,6 +219,16 @@ function validateInstallationRecord(record) {
193219
return record;
194220
}
195221

222+
function compareCandidatePriority(a, b) {
223+
const aKnown = Number.isSafeInteger(a.successfulFeedbackCount);
224+
const bKnown = Number.isSafeInteger(b.successfulFeedbackCount);
225+
if (aKnown !== bKnown) return aKnown ? -1 : 1;
226+
if (aKnown && a.successfulFeedbackCount !== b.successfulFeedbackCount) {
227+
return b.successfulFeedbackCount - a.successfulFeedbackCount;
228+
}
229+
return b.installedAt.localeCompare(a.installedAt);
230+
}
231+
196232
function normalizeGitHubLogin(value) {
197233
if (typeof value !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?$/.test(value)) {
198234
throw new Error('Invalid GitHub account');

scripts/social-proof-consent.mjs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616

1717
const execFileAsync = promisify(execFile);
1818
const INSTALLATION_PREFIX = 'installation:';
19+
const INSTALLATION_USAGE_PREFIX = 'installation-usage:';
1920
const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
2021

2122
async function readInstallationRecords() {
@@ -38,10 +39,13 @@ async function readInstallationRecords() {
3839
const keys = JSON.parse(listed.stdout);
3940
if (!Array.isArray(keys)) throw new Error('invalid list');
4041

41-
const records = [];
42+
const installations = [];
43+
const usageRecords = [];
4244
for (const item of keys) {
4345
if (!isObject(item) || typeof item.name !== 'string') throw new Error('invalid key');
44-
if (!item.name.startsWith(INSTALLATION_PREFIX)) continue;
46+
const isInstallation = item.name.startsWith(INSTALLATION_PREFIX);
47+
const isUsage = item.name.startsWith(INSTALLATION_USAGE_PREFIX);
48+
if (!isInstallation && !isUsage) continue;
4549
const result = await execFileAsync(
4650
wrangler,
4751
[
@@ -58,9 +62,11 @@ async function readInstallationRecords() {
5862
],
5963
{ cwd: REPOSITORY_ROOT, maxBuffer: 1024 * 1024 }
6064
);
61-
records.push(JSON.parse(result.stdout));
65+
const record = JSON.parse(result.stdout);
66+
if (isUsage) usageRecords.push(record);
67+
else installations.push(record);
6268
}
63-
return records;
69+
return { installations, usageRecords };
6470
} catch {
6571
throw new Error('Unable to read installation records from Cloudflare KV');
6672
}
@@ -178,8 +184,17 @@ export async function runCli(args, dependencies = {}) {
178184
const registry = await readRegistry(options.registry);
179185
const fingerprintKey = await readFingerprintKey(options.key);
180186
validateRegistryKey(registry, fingerprintKey);
181-
const records = await readRecords();
182-
const review = buildCandidateReview(records, registry, excludedLogins, fingerprintKey);
187+
const result = await readRecords();
188+
const installations = Array.isArray(result) ? result : result.installations;
189+
const usageRecords = Array.isArray(result) ? [] : result.usageRecords;
190+
const review = buildCandidateReview(
191+
installations,
192+
registry,
193+
excludedLogins,
194+
fingerprintKey,
195+
new Date().toISOString(),
196+
usageRecords
197+
);
183198
showReview(review);
184199
return `Reviewed ${review.candidates.length} outreach candidate(s) without saving identities.`;
185200
}

0 commit comments

Comments
 (0)