Skip to content
Merged
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
19 changes: 19 additions & 0 deletions apps/api/migrations/20260818200000_github_adopted_links.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Ledger of link-adopted files (issue #709, follow-up to #701/#707). One row
-- per (repo, kind, num, source_key) — the resolved workspace key a pasted
-- uploads.sh link resolved to, scoped to the PR/issue it was adopted into.
-- detached_at NULL == currently referenced by `source` (the body or the
-- specific comment it was last seen in).
CREATE TABLE github_adopted_links (
repo TEXT NOT NULL,
kind TEXT NOT NULL,
num INTEGER NOT NULL,
source_key TEXT NOT NULL,
workspace TEXT NOT NULL,
object_key TEXT NOT NULL,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
detached_at TEXT,
PRIMARY KEY (repo, kind, num, source_key)
);
CREATE INDEX github_adopted_links_source_idx ON github_adopted_links (repo, kind, num, source);
CREATE INDEX github_adopted_links_target_idx ON github_adopted_links (repo, kind, num, detached_at);
6 changes: 4 additions & 2 deletions apps/api/src/github-comment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ describe("gatherCommentBody attachment metadata (issue #365)", () => {
expect(result.body).not.toContain("<code>/");
});

it("skips the D1 read entirely when githubCommentShowMetadata is false", async () => {
it("skips the path/state D1 read when githubCommentShowMetadata is false (issue #709: the gh.detached filter read still runs)", async () => {
const { env, ws, workspaceName, bucket } = makeTestEnv();
await seed(env, bucket, { path: "/settings", state: "before" });

Expand All @@ -293,7 +293,9 @@ describe("gatherCommentBody attachment metadata (issue #365)", () => {
{ repo: "acme/web", num: 12, kind: "pull" },
);

expect(metadataQueries).toBe(0);
// One query: the unconditional gh.detached filter (issue #709) — the
// path/state fetch itself is still skipped, since neither renders here.
expect(metadataQueries).toBe(1);
expect(result.body).not.toContain("<code>/settings");
});

Expand Down
30 changes: 27 additions & 3 deletions apps/api/src/github-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ const COMMENT_META_KEYS = [
"video.height",
];

/**
* Fetched unconditionally (unlike `COMMENT_META_KEYS`, which is skipped
* entirely when neither `metaPath` nor `metaState` is on): a link-adopted
* copy that's been detached (issue #709 — the source link was edited out of
* the PR/comment it was adopted from) must never render, regardless of the
* repo's meta display settings. The object is never deleted on detach, only
* hidden here.
*/
const DETACH_META_KEY = "gh.detached";

/** The workspace's own objects under the stable gh key prefix. */
async function gatherAttachments(
env: Env,
Expand Down Expand Up @@ -140,12 +150,26 @@ async function gatherAttachments(
return prefixItems;
}),
);
const items: AttachmentItem[] = perPrefixItems.flat();
let items: AttachmentItem[] = perPrefixItems.flat();

if (items.length === 0) return items;

// Detach filter (issue #709) runs unconditionally — D1 rows are
// tenant-scoped by `workspaceName` (the caller's own slug), not by
// anything derived from `ws`, same trust boundary as gatherGalleries. The
// path/state/video.* fetch below is skipped when the repo's meta display
// settings are both off, but this one always runs since a detached copy
// must never render regardless.
const detachByKey = await getMetadataForKeys(
env.DB,
workspaceName,
items.map((item) => item.key),
{ metaKeys: [DETACH_META_KEY] },
);
items = items.filter((item) => detachByKey.get(item.key)?.[DETACH_META_KEY] !== "true");

if (!showMetadata || items.length === 0) return items;

// D1 rows are tenant-scoped by `workspaceName` (the caller's own slug), not
// by anything derived from `ws` — same trust boundary as gatherGalleries.
const metaByKey = await getMetadataForKeys(
env.DB,
workspaceName,
Expand Down
192 changes: 192 additions & 0 deletions apps/api/src/github-link-adopt-ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* Ledger of link-adopted files (`github_adopted_links` D1 table), the
* idempotency backbone for `github-link-adopt.ts`'s noise guard — mirrors
* `github-ingest-ledger.ts`'s pattern exactly, scoped by
* (repo, kind, num, sourceKey) instead of (repo, assetId) since a link
* adoption is inherently per-PR/issue (the same source object can be
* independently adopted into more than one target). One row per resolved
* source key; `detachedAt` NULL means the copy is currently referenced by
* `source` (the body or the specific comment it was last seen pasted in).
* When a rescan no longer finds the source key referenced from that exact
* source, the caller sets `detachedAt`; if it later reappears, `detachedAt`
* is cleared back to null rather than inserting a duplicate row — the copy
* itself is never deleted (issue #709: "deleting the copied object is NOT
* required — detach means removed from the managed comment").
*
* Called from inside the webhook queue consumer, where a D1 failure must
* surface as a thrown error so the queue retries the delivery — nothing here
* swallows errors, same doctrine as the ingest ledger.
*/

export interface AdoptLedgerRow {
repo: string; // lowercase owner/name
kind: "pull" | "issues";
num: number;
sourceKey: string; // the resolved workspace key the pasted URL pointed to
workspace: string;
objectKey: string; // the adopted copy's key under the target's gh prefix
source: string; // "body" | "comment:<id>"
createdAt: string; // ISO
detachedAt: string | null;
}

interface AdoptLedgerDbRow {
repo: string;
kind: "pull" | "issues";
num: number;
source_key: string;
workspace: string;
object_key: string;
source: string;
created_at: string;
detached_at: string | null;
}

function normalizeRepo(repo: string): string {
return repo.toLowerCase();
}

function fromRow(row: AdoptLedgerDbRow): AdoptLedgerRow {
return {
repo: row.repo,
kind: row.kind,
num: row.num,
sourceKey: row.source_key,
workspace: row.workspace,
objectKey: row.object_key,
source: row.source,
createdAt: row.created_at,
detachedAt: row.detached_at,
};
}

const SELECT_COLUMNS =
"repo, kind, num, source_key, workspace, object_key, source, created_at, detached_at";

/**
* Records a newly-adopted link. `INSERT OR IGNORE` on the
* (repo, kind, num, source_key) primary key: a duplicate record (the same
* source key re-scanned before its row is read) is a silent no-op rather
* than an overwrite — `setLedgerDetached`/`setLedgerSource` are the explicit
* ways to update an existing row.
*/
export async function recordAdoptedLink(
db: D1Database,
row: Omit<AdoptLedgerRow, "detachedAt">,
): Promise<void> {
await db
.prepare(
`INSERT OR IGNORE INTO github_adopted_links
(repo, kind, num, source_key, workspace, object_key, source, created_at, detached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
)
.bind(
normalizeRepo(row.repo),
row.kind,
row.num,
row.sourceKey,
row.workspace,
row.objectKey,
row.source,
row.createdAt,
)
.run();
}

/** The ledger row for a single (repo, kind, num, source key), or null if never recorded. */
export async function adoptLedgerRow(
db: D1Database,
repo: string,
kind: "pull" | "issues",
num: number,
sourceKey: string,
): Promise<AdoptLedgerRow | null> {
const row = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM github_adopted_links
WHERE repo = ? AND kind = ? AND num = ? AND source_key = ?`,
)
.bind(normalizeRepo(repo), kind, num, sourceKey)
.first<AdoptLedgerDbRow>();
return row ? fromRow(row) : null;
}

/** All ledger rows for (repo, kind, num) currently attributed to `source` ("body" or "comment:<id>"). */
export async function adoptLedgerRowsForSource(
db: D1Database,
repo: string,
kind: "pull" | "issues",
num: number,
source: string,
): Promise<AdoptLedgerRow[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM github_adopted_links
WHERE repo = ? AND kind = ? AND num = ? AND source = ?`,
)
.bind(normalizeRepo(repo), kind, num, source)
.all<AdoptLedgerDbRow>();
return (results ?? []).map(fromRow);
}

/** Every ledger row for (repo, kind, num), across every source — the basis
* for the noise guard's total-adopted count (detached rows excluded by the
* caller, same as ingest's own target-scoped reads). */
export async function adoptLedgerRowsForTarget(
db: D1Database,
repo: string,
kind: "pull" | "issues",
num: number,
): Promise<AdoptLedgerRow[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM github_adopted_links WHERE repo = ? AND kind = ? AND num = ?`,
)
.bind(normalizeRepo(repo), kind, num)
.all<AdoptLedgerDbRow>();
return (results ?? []).map(fromRow);
}

/**
* Flips `detachedAt` for a ledger row — set to a timestamp when a rescan no
* longer finds the source key referenced, or back to null when it
* reappears.
*/
export async function setAdoptLedgerDetached(
db: D1Database,
repo: string,
kind: "pull" | "issues",
num: number,
sourceKey: string,
detachedAt: string | null,
): Promise<void> {
await db
.prepare(
`UPDATE github_adopted_links SET detached_at = ?
WHERE repo = ? AND kind = ? AND num = ? AND source_key = ?`,
)
.bind(detachedAt, normalizeRepo(repo), kind, num, sourceKey)
.run();
}

/**
* Moves an already-recorded link between sources (e.g. a comment gets
* edited and the link now lives under a different comment id, or moves from
* a comment into the body).
*/
export async function setAdoptLedgerSource(
db: D1Database,
repo: string,
kind: "pull" | "issues",
num: number,
sourceKey: string,
source: string,
): Promise<void> {
await db
.prepare(
`UPDATE github_adopted_links SET source = ?
WHERE repo = ? AND kind = ? AND num = ? AND source_key = ?`,
)
.bind(source, normalizeRepo(repo), kind, num, sourceKey)
.run();
}
Loading
Loading