Skip to content

feat(repo): projector + link registry for the PM stack - #859

Merged
dezren39 merged 2 commits into
mainfrom
pm/projector
Oct 25, 2026
Merged

dezren39 merged 2 commits into
mainfrom
pm/projector

Conversation

@dezren39

@dezren39 dezren39 commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Session J (PM-stack wave) — projector + link registry. Stacked on pm/connect-repos (Session H); this PR targets pm/connect-repos. Do not merge — Muse reviews/merges. Branch was merged forward to H's current tip (37265e02) with a merge commit, no conflicts.

What this is

The glue layer from the design record (doc/2026-10-24T11-05-38Z_design_project-management-stack/): a projector that turns GitHub App webhook events + reconciliation reads into a link registry and a joined read model in Turso (libSQL). It links and projects; it never writes back to GitHub/Kaneo/Pad/Grist and never becomes a fifth source of truth. Code: pm-stack/projector/ with its own README (runbook, env/secret NAMES, read schema for Session K).

  • Webhook intake — HMAC-SHA256-verified receiver (edge-gateway discipline: bounded body, Web Crypto, constant-time compare) for issues, issue_comment, pull_request, pull_request_review, pull_request_review_comment, push; delivery-GUID idempotency.
  • Reconciliation reads — GitHub App JWT → installation token, paged issues/PRs/comments/reviews, per-(repo, stream) cursors, idempotent monotonic upserts keyed by repo-qualified IDs.
  • Link registry (requirement test #1) — repo-qualified refs (owner/repo#N) attachable to kaneo:<projectSlug>/<taskId> and grist:<docId>/<tableId>/<rowId> subjects, across repos. H's project slugs (code, opencode, openchamber, nix in pm-stack/kaneo-projects.json) are the kaneo: slug namespace.
  • Joined read model + comment projection (requirement Bump checkmarx/ast-github-action from 2.0.11 to 2.0.18 #2) — SQL views pmp_v_links_items / pmp_v_subject_activity + HTTP API (/items, /comments, /links, /subjects/<subject>), including PR-interior reviews and review/line comments that Kaneo never syncs, as one unified stream per board item / roadmap row.

Proven

  • bun test: 14 tests, 0 fail — store upserts + monotonic guard, projector event mapping (incl. PR-interior fields, delete → tombstone, allow-list), and an end-to-end test that feeds a realistic fixture webhook sequence (push → issue → issue comment → PR → PR review → PR review comment → cross-repo issue) through the real handler with real HMAC signatures and asserts the resulting registry + read-model rows, plus duplicate-delivery idempotency.
  • tsc --noEmit clean.
  • Local HTTP smoke run: started the server, POSTed all 7 fixtures with Node-computed HMACs, wrote links, and read back the joined subject view (cross-repo: a Grist row linking a code issue + an openchamber issue) and the PR-interior comment stream for the PR.
  • Fixtures + tests committed under pm-stack/projector/{fixtures,test}.

Stubbed / not proven

  • Reconciliation is implemented but not exercised against live GitHub: no App credentials are exported in this environment, and the App is installed on developing-today/code only (dezren39/nix would need its own installation or the GITHUB_TOKEN fallback). Turso CLI is present but not logged in; the projector runs against file: libSQL locally and is env-switched to a remote Turso URL/token — no dedicated database was created (fields are pmp_-prefixed and can share the fleet's general DB; rationale in the README).
  • Board projection (Kaneo tasks) is out of scope — Session K reads Kaneo/Grist directly; the projector supplies only the cross-repo link/comment layers.
  • Cloudflare Worker packaging of the receiver is a follow-up; the receiver follows the same HMAC pattern as the fleet's edge webhook gateway.
  • bun install can't reach the npm registry from this laptop (hangs); scripts/setup-deps.sh provisions @libsql/client@0.18.0 from the existing local opencode-mem cache. node_modules/ is gitignored.

For Session K

Base URL PM_PROJECTOR_URL (default http://127.0.0.1:7801): GET /items?refs=…, GET /links?subject=…, GET /comments?refs=…|?subject=…, GET /subjects/<urlencoded subject> (joined links + live item state + unified activity), or the same via the SQL views above. Subjects: kaneo:<projectSlug>/<taskId>, grist:<docId>/<tableId>/<rowId> (full path — Grist row ids are unique only per table), gh:<ref>.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Build the glue layer between GitHub and the board/roadmap tools: a
projector that turns GitHub App webhook events and reconciliation reads
into a link registry and a joined read model in Turso (libSQL). Glue
only - it never writes back to GitHub/Kaneo/Pad/Grist and never becomes
a source of truth.

Components (per the design record's two aggregator requirements):
- Webhook intake: HMAC-SHA256-verified receiver for issues, issue_comment,
  pull_request, pull_request_review, pull_request_review_comment, push,
  with delivery-GUID idempotency (edge-gateway HMAC discipline).
- Reconciliation reads: GitHub App JWT -> installation token, paged
  issues/PRs/comments/reviews with per-(repo, stream) cursors, idempotent
  monotonic upserts keyed by repo-qualified IDs.
- Link registry: repo-qualified refs (owner/repo#N) attachable to
  kaneo:<project>/<taskId> and grist:<doc>/<table>/<rowId> subjects,
  across repos (requirement #1).
- Joined read model + comment projection: pmp_v_links_items and
  pmp_v_subject_activity views plus an HTTP API, incl. PR-interior
  reviews and review/line comments Kaneo never syncs (requirement #2).

Proof: bun test (14 tests) runs a realistic fixture webhook sequence
through the real handler with real HMAC verification and asserts the
resulting registry + read-model rows. Local HTTP smoke run verified.
Reconciliation is implemented but not exercised against live GitHub
(no App creds exported here; App installed on code only).
@dezren39
dezren39 requested a review from a team as a code owner October 25, 2026 08:26
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T08:31:56.876409Z f315ace PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 16 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0bc7328a-34c1-472a-8963-5804f6d60f0b
Commits

Reviewing files that changed from the base of the PR and between 9ecd06e and f315ace.

Files selected for processing: 26
  • pm-stack/projector/.env.example
  • pm-stack/projector/.gitignore
  • pm-stack/projector/README.md
  • pm-stack/projector/fixtures/01-push.json
  • pm-stack/projector/fixtures/02-issue-opened.json
  • pm-stack/projector/fixtures/03-issue-comment.json
  • pm-stack/projector/fixtures/04-pr-opened.json
  • pm-stack/projector/fixtures/05-pr-review.json
  • pm-stack/projector/fixtures/06-pr-review-comment.json
  • pm-stack/projector/fixtures/07-openchamber-issue.json
  • pm-stack/projector/package.json
  • pm-stack/projector/schema.sql
  • pm-stack/projector/scripts/setup-deps.sh
  • pm-stack/projector/src/bun.d.ts
  • pm-stack/projector/src/cli.ts
  • pm-stack/projector/src/github.ts
  • pm-stack/projector/src/index.ts
  • pm-stack/projector/src/projector.ts
  • pm-stack/projector/src/reconcile.ts
  • pm-stack/projector/src/server.ts
  • pm-stack/projector/src/store.ts
  • pm-stack/projector/src/webhook.ts
  • pm-stack/projector/test/e2e.test.ts
  • pm-stack/projector/test/projector.test.ts
  • pm-stack/projector/test/store.test.ts
  • pm-stack/projector/tsconfig.json
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f315ace9d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (stream === "issue_comments" || stream === "review_comments") {
const kind = stream === "issue_comments" ? "issue_comment" : "review_comment";
// These endpoints do not support `since`; pull the full list and upsert.
const rows = await listResource<Record<string, any>>(auth, `repos/${repo}/${kind}s?per_page=100`, fetchFn);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the repository comment API endpoints

Both comment reconciliation streams construct nonexistent endpoints: /repos/{repo}/issue_comments and /repos/{repo}/review_comments. GitHub documents the routes as /issues/comments and /pulls/comments. Consequently, both streams fail instead of rebuilding historical comments or recovering missed webhook deliveries.

Useful? React with 👍 / 👎.

const out: T[] = [];
let path: string | null = initialPath;
while (path) {
const res = await fetchFn(`https://api.github.com/${path}`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Follow absolute pagination URLs without adding another prefix

When a resource exceeds 100 entries, nextPageFromLink returns GitHub's absolute next-page URL, but this request prepends the API origin again. A mocked two-page response reproduces a request to https://api.github.com/https://api.github.com/repos/o/r/issues?page=2. Since listResource throws before returning any rows, affected reconciliation streams import nothing, including the first page. Resolve the initial relative path and subsequent absolute links appropriately, as shown in GitHub's pagination documentation.

Useful? React with 👍 / 👎.

Comment on lines +98 to +100
} catch (e) {
await store.recordDelivery(db, { deliveryId, event, outcome: "error", detail: String((e as Error).message) });
return jsonResponse(500, { error: "apply_failed" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow failed webhook deliveries to be retried

If projection fails transiently but recording the error succeeds, this catch persists the delivery ID even though application did not complete. deliveryApplied checks only whether that ID exists, so redelivering the same webhook returns 200 duplicate without retrying the failed writes. A mocked transient store failure reproduces 500 apply_failed followed by 200 duplicate. Distinguish successful deliveries from failed attempts and allow an error record to transition to success.

Useful? React with 👍 / 👎.

Comment on lines +52 to +53
const row = fromIssueOrPr(repo, obj, isPr ? "pr" : "issue");
await store.upsertItem(db, row);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve PR merge metadata when reconciling issue-shaped records

The PR stream reads /issues, whose response represents PRs as issue objects, rather than full pull-request objects with top-level merge metadata. Passing these directly to fromIssueOrPr defaults merged to 0 and merged_at to null. Because upsertItem accepts equal source timestamps, a reconciliation after a merge webhook can erase the correct merge status; a fresh rebuild also marks merged PRs as unmerged. Fetch or derive the PR-specific metadata before replacing these fields.

Useful? React with 👍 / 👎.

submitted_at: null,
commit_id: null,
deleted: 0,
source_updated_at: createdAt,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use comment modification time for the monotonic guard

For issue comments and review comments, every edit retains the same created_at, so assigning it to source_updated_at defeats the store's stale-write protection. If an older edit webhook arrives after a newer one, both guard values are equal and upsertComment overwrites the current body with stale content. Prefer updated_at with a creation-time fallback, and apply the same rule to comment rows constructed by reconciliation.

Useful? React with 👍 / 👎.

Comment on lines +192 to +194
const base = commentCommon(review, repo, ref, "review");
base.review_state = iso(review.state);
base.submitted_at = iso(review.submitted_at) ?? base.created_at;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize review timestamps from submitted_at

Reviews carrying submitted_at but no created_at or updated_at fall through commentCommon to the current processing time. Setting only base.submitted_at afterward leaves created_at and source_updated_at incorrect. Delayed or replayed reviews therefore appear as new activity, and reconciliation uses the original submission timestamp, which the monotonic guard rejects as older—even when reconciliation contains a corrected body or dismissed state. Initialize the review's creation and guard timestamps from its submission time.

Useful? React with 👍 / 👎.

Comment on lines +29 to +33
const b64 = decodePem(rawKey);
const der = Uint8Array.from(Buffer.from(b64, "base64"));
const key = await crypto.subtle.importKey(
"pkcs8",
der as unknown as BufferSource,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode base64-wrapped PEM before importing the key

When GITHUB_APP_PRIVATE_KEY contains base64 of a PEM file, as explicitly supported by the README and environment example, decodePem leaves the outer encoding intact. This single base64 decode then produces PEM text rather than DER, so importKey('pkcs8', ...) throws DataError and App-authenticated reconciliation cannot start. Decode the outer wrapper first, then strip the decoded PEM armor and decode its DER contents.

Useful? React with 👍 / 👎.

Comment on lines +278 to +280
sql: `SELECT c.* FROM pmp_comments c
JOIN pmp_links l ON l.ref = c.ref
WHERE l.subject = ? AND c.deleted = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate comments across multiple link relations

When a subject links to the same item under two relations, such as tracks and implements, both links are valid under the registry's (subject, ref, relation) primary key. This join returns each comment once per relation, duplicating activity in both /comments?subject=... and /subjects/.... The duplicates also consume the 500-row limit and can exclude otherwise eligible comments. Join against distinct linked refs or use an existence predicate so each comment appears only once per subject.

Useful? React with 👍 / 👎.

Comment on lines +58 to +60
const installId =
installationIds?.[repo] ?? (env.GITHUB_APP_INSTALL_ID ? Number(env.GITHUB_APP_INSTALL_ID) : undefined);
if (env.GITHUB_APP_ID && env.GITHUB_APP_PRIVATE_KEY && installId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select credentials according to repository installation access

When App credentials, a global installation ID, and GITHUB_TOKEN are configured together, this branch returns the same installation token for every repo without checking repo. In the documented deployment where the App covers developing-today/code but dezren39/nix requires the token fallback, reconciliation therefore uses the wrong credentials for nix and never reaches the fallback. GitHub's installation authentication documentation limits installation tokens to repositories granted to that installation. Resolve installation access per repository and use the configured fallback when the App cannot access it.

Useful? React with 👍 / 👎.

Comment on lines +19 to +20
if (url.startsWith("file:")) {
return createClient({ url });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create the local database directory before opening the file

On a fresh checkout with DATABASE_URL unset, the documented local startup uses file:data/projector.db, but data/ does not exist and this path opens the database without creating its parent directory. Searching the tracked tree and projector setup scripts found no directory creation for it; SQLite opening the same path reproduces unable to open database file. Both the server and reconciliation CLI therefore fail before migration under the default configuration. Create the local database's parent directory before calling createClient.

Useful? React with 👍 / 👎.

Base automatically changed from pm/connect-repos to main October 25, 2026 08:41
@dezren39
dezren39 merged commit 6647876 into main Oct 25, 2026
4 checks passed
@dezren39
dezren39 deleted the pm/projector branch October 25, 2026 08:53
dezren39 added a commit that referenced this pull request Oct 25, 2026
PR #859's wire schema differs from the sketch the aggregator was built against: snake_case fields, state open|closed with merged as a separate 0/1 flag, labels/assignees as JSON array strings, comment rows with created_at/review_state, and 0/1 deleted tombstones. Map rows to the read model in live.ts (mapProjectorItem/mapProjectorComment/parseProjectorLabels), percent-encode refs in query strings (# would otherwise start a fragment), enrich board cards and roadmap rows with GET /links?subject= registry links, use kaneo:<slug>/<taskId> subjects with the slug->repo map from kaneo-projects.json, and default PM_PROJECTOR_URL to 127.0.0.1:7801.

Verified end-to-end against Session J's real projector service (temp DB in /tmp, seeded item/comment/link): merged state, seeded title, parsed labels, and the seeded review all rendered; footer reported items/comments live (projector). Tests: 28 pass, tsc clean.
dezren39 added a commit that referenced this pull request Oct 25, 2026
…-align

feat(web): align aggregator projector reader with landed schema (#859)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant