Skip to content

Commit 334067e

Browse files
Merge pull request #254 from CodeForPhilly/develop
Release: v0.10.2
2 parents 8758190 + d174d7e commit 334067e

28 files changed

Lines changed: 1332 additions & 731 deletions

‎apps/api/scripts/import-laddr/importer.ts‎

Lines changed: 33 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
* Branch model:
99
* - On first run, `legacy-import` is created from the `empty` branch (which
1010
* carries only `.gitsheets/` configs, no records).
11-
* - On subsequent runs, the importer opens the gitsheets store, reads the
12-
* previous snapshot's record UUIDs at the branch tip
13-
* (to keep them stable across re-runs), then opens a single transaction
11+
* - On subsequent runs, the importer reads the previous snapshot's record
12+
* UUIDs through a store pinned at the branch tip (to keep them stable
13+
* across re-runs), then opens a single transaction
1414
* that clears each importer-owned sheet and re-upserts the fresh data.
1515
* - Files are written by gitsheets per each sheet's path template
1616
* (e.g. `people/${{ slug }}.toml`) — the runtime API reads the same paths.
@@ -22,21 +22,17 @@
2222
* directly on `transact`).
2323
*
2424
* Side effects:
25-
* - Creates the local target branch on first run (from origin/<branch> or
26-
* the initial parent)
2725
* - Creates one commit on the local `legacy-import` branch via gitsheets
28-
* `transact({ parent, branch })`; the data repo's HEAD is not moved
26+
* `transact({ parent, branch })`. On first run the commit is parented on
27+
* origin/<branch> (or the initial parent) and gitsheets creates the local
28+
* branch. The data repo's HEAD is not moved
2929
* - Does NOT push to origin (operator's call)
3030
*
3131
* Private-store side: out of scope for this importer. The JSON endpoints
3232
* expose only public fields; private data (emails, password hashes,
3333
* newsletter prefs) will be imported separately on a future plan.
3434
*/
35-
import { execFile } from 'node:child_process';
3635
import { resolve } from 'node:path';
37-
import { promisify } from 'node:util';
38-
39-
const exec = promisify(execFile);
4036

4137
import {
4238
BlogPostSchema,
@@ -59,9 +55,14 @@ import type {
5955
TagAssignment,
6056
} from '@cfp/shared/schemas';
6157

62-
import type { Repository } from 'gitsheets';
58+
import { openStore, type Repository } from 'gitsheets';
6359

64-
import { openPublicStore, type PublicStore, type PublicStoreTx } from '../../src/store/public.js';
60+
import {
61+
openPublicStore,
62+
PUBLIC_SHEET_SCHEMAS,
63+
type PublicStore,
64+
type PublicStoreTx,
65+
} from '../../src/store/public.js';
6566
import { processAvatar } from '../../src/lib/avatar.js';
6667
import {
6768
fetchAllPages,
@@ -189,16 +190,16 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
189190
// against the last even when nothing changed upstream.
190191
// -------------------------------------------------------------------------
191192
let store: PublicStore | null = null;
192-
let branchRef: string | null = null;
193+
let target: { branchRef: string; parent: string } | null = null;
193194
let existingIds: ExistingIds;
194195

195196
if (opts.dryRun) {
196197
existingIds = newExistingIds();
197198
} else {
198199
const opened = await openPublicStore(repo);
199200
store = opened.store;
200-
branchRef = await resolveTargetBranch(opened.repo, repo, branch, initialParent);
201-
existingIds = await collectExistingIdsAt(store, opened.repo, branchRef, log);
201+
target = await resolveTargetBranch(opened.repo, repo, branch, initialParent);
202+
existingIds = await collectExistingIdsAt(opened.repo, target.parent, log);
202203
}
203204

204205
const ctx: TranslateCtx = { idMaps, warnings, now: runAt, existingIds };
@@ -481,14 +482,14 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
481482
// - clear() each importer-owned sheet (deletes capture for free)
482483
// - upsert() every translated record (path-template + schema validation)
483484
// -------------------------------------------------------------------------
484-
if (store === null || branchRef === null) throw new Error('[import-laddr] internal: store not opened');
485+
if (store === null || target === null) throw new Error('[import-laddr] internal: store not opened');
485486

486487
const message = buildCommitMessage({ runAt, sourceHost: opts.sourceHost, counts });
487488

488489
const result = await store.transact(
489490
{
490-
parent: branchRef,
491-
branch: branchRef,
491+
parent: target.parent,
492+
branch: target.branchRef,
492493
message,
493494
author: { name: AUTHOR_NAME, email: AUTHOR_EMAIL },
494495
trailers: {
@@ -626,61 +627,42 @@ function describe(err: unknown): string {
626627
// ---------------------------------------------------------------------------
627628

628629
/**
629-
* Resolve `refs/heads/<branch>` in the data repo, creating it on first run
630-
* from `origin/<branch>` if available, falling back to `initialParent`
631-
* (typically `origin/empty`). Returns the full ref name; the snapshot
632-
* transaction then commits onto it with `transact({ parent, branch })`.
633-
*
634-
* Creating the ref stays on raw git: gitsheets 2.7's `transact({ branch })`
635-
* only advances an existing branch (compare-and-swap against `parent`) and
636-
* exposes no public "create branch" call.
630+
* Resolve where the snapshot commits: the local `refs/heads/<branch>` and the
631+
* parent to build on — the branch itself, or on first run `origin/<branch>`
632+
* (falling back to `initialParent`, typically `origin/empty`), resolved to a
633+
* commit. `transact({ parent, branch })` creates the branch when the snapshot
634+
* commits.
637635
*/
638636
async function resolveTargetBranch(
639637
repo: Repository,
640638
repoPath: string,
641639
branch: string,
642640
initialParent: string,
643-
): Promise<string> {
644-
const ref = `refs/heads/${branch}`;
645-
if ((await repo.resolveRef(ref)) !== null) return ref;
641+
): Promise<{ branchRef: string; parent: string }> {
642+
const branchRef = `refs/heads/${branch}`;
643+
if ((await repo.resolveRef(branchRef)) !== null) return { branchRef, parent: branchRef };
646644

647645
const start =
648646
(await repo.resolveRef(`refs/remotes/origin/${branch}`)) ?? (await repo.resolveRef(initialParent));
649647
if (start === null) {
650648
throw new Error(
651-
`[import-laddr] cannot create ${ref}: neither origin/${branch} nor ${initialParent} resolves in ${repoPath}`,
649+
`[import-laddr] cannot create ${branchRef}: neither origin/${branch} nor ${initialParent} resolves in ${repoPath}`,
652650
);
653651
}
654-
// Create-only (empty old value): fails rather than clobbering a racing writer.
655-
await exec('git', ['update-ref', ref, start, ''], { cwd: repoPath });
656-
return ref;
652+
return { branchRef, parent: start };
657653
}
658654

659655
/**
660-
* Read the previous snapshot's ids at the branch tip. gitsheets 2.7 has no
661-
* public "sheet at a ref" handle, so this reads inside a transaction parented
662-
* on the tip's commit hash and mutates nothing — it finalizes without a
663-
* commit and moves no ref. Swap for ref-scoped read-only sheets once
664-
* gitsheets ships them.
656+
* Read the previous snapshot's ids at `ref` (the commit the snapshot builds
657+
* on) through a read-only store pinned there.
665658
*/
666659
async function collectExistingIdsAt(
667-
store: PublicStore,
668660
repo: Repository,
669661
ref: string,
670662
log: (msg: string) => void,
671663
): Promise<ExistingIds> {
672-
const tip = await repo.resolveRef(ref);
673-
if (tip === null) throw new Error(`[import-laddr] internal: ${ref} vanished`);
674-
const { value, commitHash } = await store.transact(
675-
{
676-
parent: tip,
677-
message: 'import-laddr: read existing ids (never committed)',
678-
author: { name: AUTHOR_NAME, email: AUTHOR_EMAIL },
679-
},
680-
(tx) => collectExistingIds(tx, log),
681-
);
682-
if (commitHash !== null) throw new Error('[import-laddr] internal: read-only transaction committed');
683-
return value;
664+
const pinned = await openStore(repo, { validators: PUBLIC_SHEET_SCHEMAS, ref });
665+
return collectExistingIds(pinned, log);
684666
}
685667

686668
// ---------------------------------------------------------------------------

‎apps/api/scripts/prune-spam.ts‎

Lines changed: 31 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,15 @@
3131
* --verbose Increase logging verbosity.
3232
*
3333
* All reads and the prune commit go through gitsheets: evaluations are read
34-
* at their refs through read-only transactions, and the prune commits onto
35-
* `refs/heads/<branch>` via `transact({ parent, branch })`. The data repo's
36-
* HEAD is never moved.
34+
* through sheets pinned at their refs, and the prune commits onto
35+
* `refs/heads/<branch>` via `transact({ parent, branch })`, which creates the
36+
* local branch from `origin/<branch>` on a fresh clone. The data repo's HEAD
37+
* is never moved.
3738
*/
38-
import { execFile } from 'node:child_process';
3939
import { existsSync } from 'node:fs';
4040
import { join, resolve } from 'node:path';
41-
import { promisify } from 'node:util';
4241

43-
import { ConfigError, openRepo, type Repository } from 'gitsheets';
42+
import { ConfigError, openRepo, RefError, type Repository } from 'gitsheets';
4443

4544
import { openPublicStore } from '../src/store/public.js';
4645
import { SHEET_REFERENCES } from '@cfp/shared';
@@ -56,8 +55,6 @@ const PRUNE_CASCADE = SHEET_REFERENCES.filter(
5655
(r) => r.target.sheet === 'people' && r.target.field === 'slug' && r.sheet !== 'person-evaluations',
5756
);
5857

59-
const exec = promisify(execFile);
60-
6158
// ---------------------------------------------------------------------------
6259
// CLI argument parsing
6360
// ---------------------------------------------------------------------------
@@ -139,9 +136,6 @@ export interface PersonVerdict {
139136
humanEvaluatedAt: string | null;
140137
}
141138

142-
/** Author stamped on the read-only transactions; they never commit. */
143-
const READ_AUTHOR = { name: 'prune-spam (read-only)', email: 'api@users.noreply.codeforphilly.org' };
144-
145139
/**
146140
* Open a gitsheets Repository on a bare or non-bare clone. The evaluations
147141
* repo (the private spam-detection clone) is usually a working-tree clone.
@@ -195,14 +189,8 @@ export function foldEvaluation(
195189

196190
/**
197191
* Read every person-evaluation at `ref` in `repo` and fold it into
198-
* `verdictMap`.
199-
*
200-
* gitsheets 2.7 has no public "sheet at a ref" handle, so this opens a
201-
* transaction parented on `ref` and only reads from it: a transaction with
202-
* no mutations finalizes to `commitHash: null` and moves no ref. Swap for
203-
* ref-scoped read-only sheets once gitsheets ships them (see the plan's
204-
* `awaits`). A ref without a person-evaluations sheet config contributes
205-
* nothing.
192+
* `verdictMap`, through a read-only sheet pinned at `ref`. A ref without a
193+
* person-evaluations sheet config contributes nothing.
206194
*/
207195
export async function aggregateVerdicts(
208196
repoPath: string,
@@ -213,21 +201,16 @@ export async function aggregateVerdicts(
213201
): Promise<Map<string, PersonVerdict>> {
214202
log(`[prune-spam] reading person-evaluations in ${repoPath} at ref=${ref}`);
215203
const repo = await openAnyRepo(repoPath);
216-
// Parent on the resolved commit hash: the core takes branch names or
217-
// hashes (not `HEAD`), and a hash parent never updates any ref.
218-
const commit = await repo.resolveRef(ref);
219-
if (commit === null) throw new Error(`[prune-spam] ref '${ref}' not found in ${repoPath}`);
220-
const { value: records } = await repo.transact(
221-
{ parent: commit, message: 'prune-spam: read person-evaluations (never committed)', author: READ_AUTHOR },
222-
async (tx) => {
223-
try {
224-
return await tx.sheet('person-evaluations').queryAll();
225-
} catch (err) {
226-
if (err instanceof ConfigError && err.code === 'config_missing') return [];
227-
throw err;
228-
}
229-
},
230-
);
204+
let records: readonly Record<string, unknown>[];
205+
try {
206+
records = await (await repo.openSheet('person-evaluations', { ref })).queryAll();
207+
} catch (err) {
208+
if (err instanceof RefError && err.code === 'ref_not_found') {
209+
throw new Error(`[prune-spam] ref '${ref}' not found in ${repoPath}`, { cause: err });
210+
}
211+
if (err instanceof ConfigError && err.code === 'config_missing') records = [];
212+
else throw err;
213+
}
231214
log(`[prune-spam] found ${records.length} evaluation records`);
232215
for (const record of records) foldEvaluation(verdictMap, record, threshold);
233216
return verdictMap;
@@ -328,22 +311,21 @@ export interface PruneSummary {
328311
// ---------------------------------------------------------------------------
329312

330313
/**
331-
* Resolve the local branch the prune commits onto, creating it from
332-
* `origin/<branch>` when a fresh clone lacks it. HEAD is not touched — the
333-
* commit is aimed at the branch through `transact({ parent, branch })`.
334-
*
335-
* Creating the ref stays on raw git: gitsheets 2.7's `transact({ branch })`
336-
* only advances an existing branch (compare-and-swap against `parent`), and
337-
* exposes no public "create branch" call.
314+
* Resolve where the prune commits: the local `refs/heads/<branch>` and the
315+
* parent to build on — the branch itself, or `origin/<branch>`'s commit when a
316+
* fresh clone lacks the local branch. `transact({ parent, branch })` then
317+
* creates the branch if the prune commits. HEAD is not touched.
338318
*/
339-
async function resolveTargetBranch(repo: Repository, repoPath: string, branch: string): Promise<string> {
340-
const ref = `refs/heads/${branch}`;
341-
if ((await repo.resolveRef(ref)) !== null) return ref;
319+
async function resolveTargetBranch(
320+
repo: Repository,
321+
repoPath: string,
322+
branch: string,
323+
): Promise<{ branchRef: string; parent: string }> {
324+
const branchRef = `refs/heads/${branch}`;
325+
if ((await repo.resolveRef(branchRef)) !== null) return { branchRef, parent: branchRef };
342326
const origin = await repo.resolveRef(`refs/remotes/origin/${branch}`);
343327
if (origin === null) throw new Error(`[prune-spam] branch '${branch}' not found in ${repoPath}`);
344-
// Create-only (empty old value): fails rather than clobbering a racing writer.
345-
await exec('git', ['update-ref', ref, origin, ''], { cwd: repoPath });
346-
return ref;
328+
return { branchRef, parent: origin };
347329
}
348330

349331
// ---------------------------------------------------------------------------
@@ -383,7 +365,7 @@ export async function pruneSpam(args: CliArgs): Promise<PruneSummary> {
383365
// 2. Open the data store and resolve the target branch
384366
// -------------------------------------------------------------------------
385367
const { store, repo } = await openPublicStore(args.dataRepo);
386-
const branchRef = await resolveTargetBranch(repo, args.dataRepo, args.branch);
368+
const { branchRef, parent } = await resolveTargetBranch(repo, args.dataRepo, args.branch);
387369
log(`[prune-spam] pruning ${args.dataRepo} ${branchRef}`);
388370

389371
// -------------------------------------------------------------------------
@@ -402,7 +384,7 @@ export async function pruneSpam(args: CliArgs): Promise<PruneSummary> {
402384

403385
const result = await store.transact(
404386
{
405-
parent: branchRef,
387+
parent,
406388
branch: branchRef,
407389
message: `prune: remove confident-spam people from published (${runAt})\n\nThreshold: ${args.threshold}, pruneSet: ${pruneSet.size} slugs evaluated.\n`,
408390
author: { name: AUTHOR_NAME, email: AUTHOR_EMAIL },

0 commit comments

Comments
 (0)