Compare commits

...
15 changed files with 880 additions and 63 deletions

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
---
name: campaign-issues
description: Create GitHub issues for this LeetCode campaign — individual problem issues and the topic issues that own them — using the repo's exact title, label, milestone, body, and sub-issue conventions. Use when asked to add/create an issue for a LeetCode problem or a topic, attach a problem to its topic, or backfill the issue catalog.
---
# Campaign issues
The GitHub issue tracker **is the campaign catalog**: ~24 `topic` issues each own a
set of `problem` sub-issues. The Worker reconciles this catalog into D1 nightly
(`/admin/reconcile`), so a correctly-labelled issue is all that's needed — the SRS,
digest, charts, and Project mirror pick it up automatically. Get the labels/body shape
right; everything downstream is reconciliation.
All commands use `gh` against `prdlk/leetcode` and fall back to `gh auth token`.
## Problem issue
One issue per LeetCode problem, attached as a sub-issue of its topic.
- **Title:** `LC <num> · <Title>` — the LC number and the problem's name, nothing
else. Separator is a middle dot `·` (U+00B7) with single spaces. Difficulty and
set are **labels only**; never repeat them in the title (they used to be a `·
Hard · core` suffix — that is gone, and `catalog.ts` no longer parses it).
- **Labels** (four, all required — these are what the Worker reads):
- `problem` — marks the sub-issue as curriculum; without it the reconcile skips it.
- `diff:easy` | `diff:medium` | `diff:hard` — LeetCode's own rating, do not invent.
- `set:core` | `set:optional` | `set:deferred` — see set semantics below.
- `phase:N` — the **topic's** phase (same phase label the topic issue carries).
- **Milestone:**
- `core` / `optional` → the topic's phase milestone.
- `deferred` → `Review — Transfer & Mocks` (deferred Hards are parked until week 8).
- Pass the milestone **title**, not its number, to `--milestone`.
- **Body** (exact shape; `<slug>` is the LeetCode URL slug, `<topic-issue>` the owning topic's number):
```
https://leetcode.com/problems/<slug>/
- **Topic:** #<topic-issue>
- **Set:** <core|optional|deferred>
- **Language:** Python
### Close-out (paste before closing this issue)
- Time: O(?)
- Space: O(?)
- Edge case traced:
- First attempt passed: yes / no
- Miss note:
```
Create it:
```sh
gh issue create --repo prdlk/leetcode \
--title "LC 42 · Trapping Rain Water" \
--label problem --label set:core --label diff:hard --label phase:1 \
--milestone "Phase I — Linear Structures" \
--body "https://leetcode.com/problems/trapping-rain-water/
- **Topic:** #9
- **Set:** core
- **Language:** Python
### Close-out (paste before closing this issue)
- Time: O(?)
- Space: O(?)
- Edge case traced:
- First attempt passed: yes / no
- Miss note:"
```
Then **attach it as a sub-issue of the topic** (this is a separate API call — creating
the issue does not link it). The sub-issues API wants the child's internal **id**, not
its issue number, and the field is integer-typed, so use `-F` (not `-f`):
```sh
child_id=$(gh api repos/prdlk/leetcode/issues/211 --jq '.id')
gh api --method POST repos/prdlk/leetcode/issues/9/sub_issues -F sub_issue_id=$child_id
# verify
gh api repos/prdlk/leetcode/issues/9/sub_issues --jq '.[] | "\(.number) \(.title)"'
```
## Topic issue
One issue per curriculum topic; owns the problem sub-issues.
- **Title:** `Topic NN — <Name>` (zero-padded number, em dash, e.g. `Topic 07 — Monotonic Stack`).
- **Labels:** `topic`, `phase:N`.
- **Milestone:** that phase's milestone (title, not number).
- **Body:**
```
**Scheduled:** <Weekday Mon DD, YYYY>
**Phase:** <Roman> — <Phase name> (Week N)
**Builds on:** Topic <n>
### Trigger
TODO
### Invariant
TODO
### Trap
TODO
### Close-out ritual
Before every submit, out loud:
1. State time and space complexity.
2. Trace one edge case.
### Day rules
- 90-minute coding cap. Stop at the cap, even mid-problem.
- Core problems first. Optional only if the core is done.
- Finished early? Do this topic's optional set. Do not start tomorrow.
```
`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json`
(day → topic issue) — not guessed.
## Reference tables
Set semantics (from the label descriptions):
| set | meaning | milestone |
|---|---|---|
| `core` | must solve on the topic day | topic's phase milestone |
| `optional` | solve only if the clock allows | topic's phase milestone |
| `deferred` | a Hard, parked until week 8 | `Review — Transfer & Mocks` |
Milestones (`--milestone` takes the **title**):
| # | title |
|---|---|
| 1 | Phase I — Linear Structures |
| 2 | Phase II — Nodal & Grid |
| 3 | Phase III — Hierarchical |
| 4 | Phase IV — Relational |
| 5 | Phase V — Decision Space |
| 6 | Review — Transfer & Mocks |
Find a topic issue and confirm its phase before creating problems under it:
```sh
gh issue list --repo prdlk/leetcode --label topic --state all --limit 30 \
--json number,title,labels,milestone
```
## Gotchas
- **Check the LC number is not already in the curriculum BEFORE creating.** D1 keys
`problems` on `lc_number` (`ON CONFLICT(lc_number)`), so two issues for one LC number
silently fight over a single row — whichever the reconcile walks last wins, and the
loser's issue number ends up orphaned in D1. A problem can legitimately move topic or
set; that is a relabel/re-parent of the *existing* issue, never a second issue:
```sh
gh issue list --repo prdlk/leetcode --label problem --state all --limit 300 \
--search "LC 42" --json number,title,labels,state
```
If a row already exists, change the existing issue instead: swap its `set:*` label,
move the sub-issue to the new topic, and fix its milestone. Then reconcile the board
(`project-fields` skill). If a duplicate did get created and one is deleted, the
surviving issue number and set only reach D1 on the next catalog reconcile — SRS
columns (`stage`, `next_review`) are preserved, since they are the real attempt history.
- **`-F` vs `-f`:** `sub_issue_id` must be an integer; `-f` sends a string and the API
rejects it with `is not of type integer`.
- **id, not number:** the sub-issue payload wants the child's `.id` (a large opaque
integer), never its display `#number`.
- **`--milestone` wants the title string**, not the numeric id (`'1' not found` otherwise).
- **Reconcile, don't react:** don't edit D1 or the Project by hand — a correct issue is
reconciled into SRS state on the nightly cron. Solutions landing in `work/` are what
close problem issues (via `close-solved.yml`), which then roll up to close topics.
- **Difficulty is LeetCode's**, `set` is our scheduling choice — they're independent. A
Hard can be `core` if we deliberately schedule it (vs the usual `deferred`).
+108
View File
@@ -0,0 +1,108 @@
---
name: project-fields
description: Reconcile the "Interview Prep" GitHub Project (user project #2) fields — Set and Difficulty projected from issue labels — and understand which fields the Worker mirrors vs which live only in D1. Use when asked to sync/fix/backfill Project fields, when Project columns look empty or stale, or when adding a field to the project.
---
# Interview Prep Project fields
The user-level Project **"Interview Prep"** (`prdlk` project #2, id `PVT_kwHOADoNjs4BhV1a`)
is a *board over* the issue catalog, never a source of truth. Every field is a projection
of something owned elsewhere.
## Field ownership — the rule that matters
| Field | Owner | Written by |
|---|---|---|
| `Set` (Core/Optional/Deferred) | issue label `set:*` | `sync-project-fields`, last step of `close-solved.yml` |
| `Difficulty` (Easy/Medium/Hard) | issue label `diff:*` | `sync-project-fields`, last step of `close-solved.yml` |
| `Target Date` | D1 `next_review` | Worker mirror, per log (`apps/api/src/mirror.ts`) |
| `Status`, `Labels`, `Milestone`, `Parent issue`, … | GitHub built-ins | GitHub |
**SRS stage and first-attempt result are deliberately NOT Project fields.** They live only
in D1; the charts, digest and `/api/stats` read them from there. Duplicating them into
Project single-selects bought nothing but drift and two extra writes per log. Do not re-add
them to the project, and do not re-add `setStage`/`setFirstAttempt` to `mirror.ts`.
## Reconciling Set / Difficulty
`apps/cli/sync-project-fields.ts` recomputes every desired value from the issue labels and
writes only what differs — same reconcile-don't-react shape as `close-solved`.
**In CI it runs itself.** It is the final step of `.github/workflows/close-solved.yml`, so
any push under `work/` reconciles the board along with the issues and D1. The step is
`continue-on-error: true` and skips cleanly when `PROJECT_PAT` is unset — D1 and the issues
are the truth, so a Project failure must never fail a run that already closed issues and
logged the solve. It honours the workflow's `dry_run` input via `DRY_RUN=true`.
Run it by hand when issues changed without a `work/` push (e.g. you just filed a problem
issue, or relabelled one):
```sh
# needs a token with the `project` scope — neither `gh auth token` nor GITHUB_TOKEN has it.
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
bun run sync-project-fields -- --dry-run # or DRY_RUN=1|true; prints the table, writes nothing
bun run sync-project-fields # apply
bun run sync-project-fields # re-run: must report 0 writes
```
Invariants the script holds — preserve them in any edit:
- **Only `problem`-labelled items are touched.** Topic, cadence and review issues have no
set/difficulty; they are skipped, never written and never cleared.
- **Idempotent.** A second run reports `0 field value(s)`. That re-run *is* the verification.
- **Labels are truth.** Never "fix" a field by hand in the UI — fix the label, then reconcile.
A hand-edited field is silently reverted on the next run.
- **Fields and options resolve by NAME**, so re-creating a field in the UI doesn't strand a
dead id. A missing field or option is a hard error, not a silent skip.
- **A problem missing a `set:`/`diff:` label** warns and is left untouched (it means the
issue is mislabelled — fix it with the `campaign-issues` skill).
## Auth gotcha
Project v2 needs the `project` scope. The local `gh` login has only
`gist, read:org, repo, workflow`, so **any** `gh project`/GraphQL project call fails with
the default token. Use the Worker's `GH_PAT` from `apps/api/.dev.vars` via `GH_TOKEN`
(never echo it). Symptom of the wrong token: `Resource not accessible by personal access
token` or an empty `projectsV2.nodes`.
## Inspecting the project
```sh
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
# fields + single-select options
gh api graphql -f owner=prdlk -f title="Interview Prep" -f query='
query($owner: String!, $title: String!) {
user(login: $owner) { projectsV2(first: 10, query: $title) { nodes { id title number
items(first: 1) { totalCount }
fields(first: 30) { nodes {
... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField { options { id name } } } } } } }
}' --jq '.data.user.projectsV2.nodes[] | select(.title=="Interview Prep")
| .fields.nodes[] | "\(.name)\t\(.dataType)\t\((.options // []) | map(.name) | join(","))"'
```
Writes use `updateProjectV2ItemFieldValue` with
`value: { singleSelectOptionId: … }` (dates use `{ date: "YYYY-MM-DD" }`); clearing uses
`clearProjectV2ItemFieldValue`. An item id is *not* an issue number — resolve it from the
project's `items` connection (or the issue's `projectItems`), as `mirror.ts` does.
## Adding a new projected field
1. Create the field + options in the Project UI.
2. Add it to the `FIELDS` table in `apps/cli/sync-project-fields.ts` — one entry mapping
label → option name. Nothing else needs to change; resolution, diffing, dry-run and
reporting are generic over that table.
3. Dry-run, apply, re-run to prove idempotency.
If the new field's truth lives in **D1** rather than a label, it belongs in the Worker
mirror instead (`apps/api/src/mirror.ts`) — but read the ownership rule above first and
default to *not* mirroring.
## Known trap
`resolve()` in `mirror.ts` throws if a field it asks for is absent, *before* caching
`info` — so one missing field breaks **every** mirrored write, including `Target Date`,
and only as a `console.warn`. If Target Date silently stops updating, check that every
field `resolve()` looks up still exists in the project.
+22
View File
@@ -39,3 +39,25 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
# Project fields are a projection of the issue labels, so the same push
# that adds a solution (and any issue filed since the last run) can leave
# the board's Set/Difficulty empty. Reconciling here keeps the one
# "solution landed" entry point responsible for the whole fan-out; the
# script is idempotent, so a run with nothing to do writes nothing.
#
# Needs the `project` scope, which GITHUB_TOKEN does not have — hence
# PROJECT_PAT. Unset = skip, matching SRS_ADMIN_KEY above. Best-effort by
# design: D1 and the issues are the truth, so a Project failure must not
# fail a run that already closed issues and logged the solve.
- name: Reconcile Project Set/Difficulty
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PROJECT_PAT }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "PROJECT_PAT unset — skipping the Project field reconcile"
exit 0
fi
bun apps/cli/sync-project-fields.ts
+8 -4
View File
@@ -2,7 +2,7 @@
## Project Overview
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
## Architecture & Data Flow
@@ -18,7 +18,8 @@ flowchart LR
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
Mail -->|GET /log| D1
PI -->|/done webhook| D1
D1 -->|best-effort mirror| Proj[GitHub Project fields]
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
GH -->|close-solved.yml sync-project-fields| Proj
D1 -->|Sat midnight ET| Review[Review — Week N issue]
D1 --> Charts[SVG charts + /api/stats]
```
@@ -26,7 +27,7 @@ flowchart LR
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run.
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved` (`source='commit'`, first solve only, no index needed for idempotency: it refuses any problem past stage `new`). `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written.
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
@@ -39,7 +40,7 @@ flowchart LR
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` |
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved`, needs `SRS_ADMIN_KEY`), close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
## Development Commands
@@ -60,6 +61,9 @@ bun run close-topics -- --dry-run
# close-solved also pushes the solved set to the Worker; point it at a local
# one and nothing production is touched:
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
# runs as the last step of close-solved.yml in CI):
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
+7 -3
View File
@@ -16,9 +16,13 @@ Live at `https://srs-api.prdlk.workers.dev`.
never overwrites SRS-owned columns (`stage`, `next_review`).
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
bundled at deploy. Git history is its audit log.
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`)
is best-effort: failures log warnings, never block a D1 write. Topic rows
are never written.
- The GitHub Project mirror writes `Target Date` and nothing else: it is
best-effort, so failures log warnings and never block a D1 write, and topic
rows are never written. SRS stage and first-attempt result stay in D1 only —
the charts, digest and `/api/stats` read them from there, so mirroring them
into Project single-selects bought only drift. Do not re-add them.
`Set`/`Difficulty` are a projection of the issue labels, reconciled by
`bun run sync-project-fields` at the repo root, not by this Worker.
## The ladder
+34 -4
View File
@@ -4,11 +4,19 @@
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
* first sight of a deferred problem. The Worker never invents catalog rows.
*
* Difficulty and set come from the `diff:*` / `set:*` LABELS, never from the
* issue title — the title carries only the LC number and the problem's name,
* so relabelling a problem is enough to move it and titles stay readable.
* The `problem` label is what marks a sub-issue as curriculum.
*/
import { addDays } from "./srs.ts";
import type { GitHub } from "./github.ts";
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
const TITLE_RE = /^LC (\d+) · (.+)$/;
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
const SETS = ["core", "optional", "deferred"] as const;
/** Deferred Hards enter the queue from this date, two per day. */
const DEFER_FROM = "2026-09-28";
@@ -18,6 +26,18 @@ export interface ReconcileReport {
problems: number;
}
/** Label names on one issue payload, narrowed with guards rather than cast. */
function labelNames(value: object): string[] {
if (!("labels" in value) || !Array.isArray(value.labels)) return [];
const names: string[] = [];
for (const label of value.labels) {
if (label && typeof label === "object" && "name" in label && typeof label.name === "string") {
names.push(label.name);
}
}
return names;
}
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
interface TopicIssue {
number: number;
@@ -62,10 +82,20 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
const labels = labelNames(raw);
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
const m = raw.title.match(TITLE_RE);
if (!m) continue; // non-curriculum sub-issue
if (!m) {
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
continue;
}
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
const set = SETS.find((s) => labels.includes(`set:${s}`));
if (!difficulty || !set) {
console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`);
continue;
}
const lc = Number(m[1]);
const set = m[4]!;
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
// applied only when the row is first created (SRS owns it afterwards).
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
@@ -78,7 +108,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
ON CONFLICT(lc_number) DO UPDATE SET
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
)
.bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer),
.bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer),
);
problems++;
}
+4 -10
View File
@@ -30,22 +30,16 @@ import { buildStats } from "./stats.ts";
// ── shared side effects after a state write ──────────────────────
/** Project-field half of a mirror; shared by every write path. */
async function mirrorFields(mirror: Mirror, outcome: LogOutcome): Promise<void> {
await mirror.setStage(outcome.issue, outcome.stage);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
}
/**
* Mirror one logged attempt into GitHub: Project fields always; on a
* Mirror one logged attempt into GitHub: the Project's Target Date always; on a
* first-ever pass also close the problem's sub-issue (comment first — repo
* convention). Runs inside ctx.waitUntil; failures are warnings.
*/
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
if (outcome.error || outcome.duplicate) return;
const gh = github(env.GH_PAT, env.REPO);
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
const mirror = projectMirror(gh, env.REPO.split("/")[0]!);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first && outcome.result === "pass") {
try {
await gh.closeIssue(
@@ -248,7 +242,7 @@ async function handleSolved(request: Request, env: Env, date: string): Promise<R
logged.push(outcomeLine(outcome));
// One mirror for the batch: field IDs resolve once, not per problem.
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
await mirrorFields(mirror, outcome);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
}
}
return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] });
+10 -39
View File
@@ -4,30 +4,26 @@
* console warning and never blocks a state write. Only problem issues are
* ever passed in, so topic rows' Target Date is never written.
*
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
* short-lived; the two extra queries per mirror burst are irrelevant at this
* volume and survive field re-creation).
* Target Date is the ONLY mirrored column. SRS stage and first-attempt result
* live exclusively in D1 — they are read from there (charts, digest, /api/stats)
* and duplicating them into Project single-selects bought nothing but drift and
* two extra writes per log. Do not re-add them here.
*
* Field IDs are resolved by NAME per invocation (a Worker isolate is
* short-lived; the extra query per mirror burst is irrelevant at this
* volume and survives field re-creation).
*/
import type { GitHub } from "./github.ts";
const PROJECT_TITLE = "Interview Prep";
interface SelectField {
id: string;
options: Record<string, string>;
}
interface ProjectInfo {
id: string;
targetDate: string;
srsStage: SelectField;
firstAttempt: SelectField;
}
export interface Mirror {
setTargetDate(issue: number, date: string | null): Promise<void>;
setStage(issue: number, stage: string): Promise<void>;
setFirstAttempt(issue: number, result: string): Promise<void>;
warnings: string[];
}
@@ -47,7 +43,6 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
fields(first: 30) {
nodes {
... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
@@ -62,7 +57,7 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
id: string;
title: string;
fields: {
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
nodes: { id: string; name: string }[];
};
}[];
};
@@ -70,19 +65,9 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
};
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
const select = (name: string): SelectField => {
const f = node.fields.nodes.find((n) => n.name === name);
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
};
const date = node.fields.nodes.find((n) => n.name === "Target Date");
if (!date) throw new Error(`date field "Target Date" missing`);
info = {
id: node.id,
targetDate: date.id,
srsStage: select("SRS Stage"),
firstAttempt: select("First Attempt"),
};
info = { id: node.id, targetDate: date.id };
return info;
}
@@ -154,19 +139,5 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
await setField(issue, project.targetDate, { date });
}
}),
setStage: (issue, stage) =>
attempt(`SRS Stage #${issue}`, async () => {
const project = await resolve();
const option = project.srsStage.options[stage];
if (!option) throw new Error(`no option "${stage}"`);
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
}),
setFirstAttempt: (issue, result) =>
attempt(`First Attempt #${issue}`, async () => {
const project = await resolve();
const option = project.firstAttempt.options[result];
if (!option) throw new Error(`no option "${result}"`);
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
}),
};
}
+23 -3
View File
@@ -1,6 +1,6 @@
/**
* GitHub REST plumbing shared by the issue reconcilers (close-solved,
* close-topics).
* GitHub REST + GraphQL plumbing shared by the issue reconcilers (close-solved,
* close-topics) and the Project field reconciler (sync-project-fields).
*
* Importing this module is side-effect free — nothing resolves credentials or
* touches the network until github() is awaited — so a script can import it
@@ -22,6 +22,8 @@ export interface GitHub {
repo: string;
/** One authenticated request against api.github.com; throws on non-2xx. */
api(path: string, init?: RequestInit): Promise<unknown>;
/** One GraphQL call (Projects v2 lives here); throws on transport or query errors. */
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
/** Every page of a list endpoint, flattened into one stream of elements. */
list(path: string): AsyncGenerator<unknown, void, void>;
/** Comment on an issue, then close it as completed. */
@@ -74,6 +76,24 @@ export async function github(): Promise<GitHub> {
}
}
async function graphql(
query: string,
variables: Record<string, unknown> = {},
): Promise<unknown> {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
return payload.data;
}
/**
* Comment before closing: if the PATCH fails, the issue still carries a
* visible note of what the automation decided, instead of failing silently.
@@ -89,5 +109,5 @@ export async function github(): Promise<GitHub> {
});
}
return { repo, api, list, closeIssue };
return { repo, api, graphql, list, closeIssue };
}
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env bun
/**
* Reconcile the "Interview Prep" Project's Set and Difficulty fields from the
* labels on each problem issue.
*
* The issue labels are the truth: `set:core|optional|deferred` and
* `diff:easy|medium|hard` are fixed when a problem issue is filed, and the
* Project's single-selects are a projection of them so the board can group and
* filter. This recomputes every desired value from scratch and writes only the
* ones that differ, so re-runs are no-ops and a backfill needs no special
* casing — same reconcile-don't-react shape as close-solved / close-topics.
*
* Scope guard: only items whose issue carries the `problem` label are touched.
* Topic, cadence and review issues have no set or difficulty, so they are
* skipped outright — never written, never cleared.
*
* Deliberately NOT mirrored: SRS stage and first-attempt result. Those live
* only in D1 (apps/api owns them; the charts, digest and /api/stats read them
* from there). The Project mirror writes Target Date and nothing else.
*
* Auth: needs a token carrying the `project` scope — neither the default
* `gh auth token` nor Actions' GITHUB_TOKEN has it. Pass one in GH_TOKEN: the
* PROJECT_PAT secret in CI, or the Worker's GH_PAT locally:
* GH_TOKEN=$GH_PAT bun run sync-project-fields -- --dry-run
*/
import { github, type GitHub } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
const PROJECT_TITLE = "Interview Prep";
/** Label → single-select option name, per field. Static tables, so Record. */
const FIELDS: Record<string, Record<string, string>> = {
Set: { "set:core": "Core", "set:optional": "Optional", "set:deferred": "Deferred" },
Difficulty: { "diff:easy": "Easy", "diff:medium": "Medium", "diff:hard": "Hard" },
};
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
// ── project + field resolution ───────────────────────────────────
interface SelectField {
id: string;
/** option name → option id */
options: Record<string, string>;
}
interface Project {
id: string;
fields: Record<string, SelectField>;
}
/**
* Field and option IDs are resolved by NAME, so re-creating a field in the UI
* does not strand this script on a dead id.
*/
async function readProject(gh: GitHub, owner: string): Promise<Project> {
const data = (await gh.graphql(
`query($owner: String!, $title: String!) {
user(login: $owner) {
projectsV2(first: 10, query: $title) {
nodes {
id title
fields(first: 30) {
nodes {
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}
}`,
{ owner, title: PROJECT_TITLE },
)) as {
user: {
projectsV2: {
nodes: {
id: string;
title: string;
fields: { nodes: ({ id: string; name: string; options: { id: string; name: string }[] } | Record<string, never>)[] };
}[];
};
};
};
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
const fields: Record<string, SelectField> = {};
for (const name of Object.keys(FIELDS)) {
const field = node.fields.nodes.find((f) => "name" in f && f.name === name);
if (!field || !("options" in field)) {
throw new Error(`single-select field "${name}" missing from "${PROJECT_TITLE}"`);
}
fields[name] = {
id: field.id,
options: Object.fromEntries(field.options.map((o) => [o.name, o.id])),
};
}
return { id: node.id, fields };
}
// ── items ────────────────────────────────────────────────────────
interface Item {
id: string;
issue: number;
title: string;
labels: string[];
/** field name → current option name */
current: Record<string, string>;
}
async function* readItems(gh: GitHub, project: string): AsyncGenerator<Item, void, void> {
let cursor: string | null = null;
for (;;) {
const data = (await gh.graphql(
`query($id: ID!, $after: String) {
node(id: $id) {
... on ProjectV2 {
items(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
id
fieldValues(first: 25) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name field { ... on ProjectV2FieldCommon { name } }
}
}
}
content {
... on Issue { number title labels(first: 25) { nodes { name } } }
}
}
}
}
}
}`,
{ id: project, after: cursor },
)) as {
node: {
items: {
pageInfo: { hasNextPage: boolean; endCursor: string };
nodes: {
id: string;
fieldValues: { nodes: ({ name: string; field: { name: string } } | Record<string, never>)[] };
content: { number: number; title: string; labels: { nodes: { name: string }[] } } | Record<string, never>;
}[];
};
};
};
for (const node of data.node.items.nodes) {
const content = node.content;
// Draft items and PRs have no issue number; nothing to reconcile.
if (!("number" in content) || typeof content.number !== "number") continue;
const current: Record<string, string> = {};
for (const value of node.fieldValues.nodes) {
if ("name" in value && value.field?.name) current[value.field.name] = value.name;
}
yield {
id: node.id,
issue: content.number,
title: content.title,
labels: content.labels.nodes.map((l) => l.name),
current,
};
}
const page = data.node.items.pageInfo;
if (!page.hasNextPage) return;
cursor = page.endCursor;
}
}
// ── reconcile ────────────────────────────────────────────────────
interface Write {
item: string;
issue: number;
title: string;
field: string;
from: string;
to: string;
}
async function setField(gh: GitHub, project: string, w: Write, option: string): Promise<void> {
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $project, itemId: $item, fieldId: $field
value: { singleSelectOptionId: $option }
}
) { projectV2Item { id } }
}`,
{ project, item: w.item, field: w.field, option },
);
}
const gh = await github();
const owner = gh.repo.split("/")[0]!;
const project = await readProject(gh, owner);
const writes: Write[] = [];
let problems = 0;
let skipped = 0;
for await (const item of readItems(gh, project.id)) {
if (!item.labels.includes("problem")) {
skipped++;
continue;
}
problems++;
for (const [field, mapping] of Object.entries(FIELDS)) {
const want = item.labels.map((l) => mapping[l]).find(Boolean);
if (!want) {
console.warn(`#${item.issue} has no ${field.toLowerCase()} label — left untouched`);
continue;
}
const from = item.current[field];
if (from === want) continue;
if (!project.fields[field]!.options[want]) {
throw new Error(`field "${field}" has no option "${want}"`);
}
writes.push({
item: item.id,
issue: item.issue,
title: item.title,
field: project.fields[field]!.id,
from: from ?? "(empty)",
to: want,
});
}
}
const rows: string[][] = [["ISSUE", "PROBLEM", "FIELD", "FROM", "TO"]];
for (const w of writes) {
const name = Object.entries(project.fields).find(([, f]) => f.id === w.field)![0];
rows.push([`#${w.issue}`, w.title.slice(0, 46), name, w.from, w.to]);
if (DRY) continue;
await setField(gh, project.id, w, project.fields[name]!.options[w.to]!);
}
if (writes.length) printTable(rows);
console.log(
`\n${DRY ? "would update" : "updated"} ${writes.length} field value(s) across ${problems} problem item(s); ${skipped} non-problem item(s) skipped`,
);
if (writes.length) await writeStepSummary(markdownTable(rows));
@@ -0,0 +1,41 @@
---
title: '739. Daily Temperatures'
description: Given an array of integers temperatures represents the daily temperatures, return an array answer such that answer[i] is the number of days you have to wait after the i^th day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0 instead
sidebar:
label: 'Daily Temperatures'
badge: 'Medium'
---
<Badge variant="accent">Monotonic Stack</Badge>
### Example 1:
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
- Output: `[1,1,4,2,1,1,0,0]`
### Example 2:
- Input: `temperatures = [30,40,50,60]`
- Output: `[1,1,1,0]`
### Example 3:
- Input: `temperatures = [30,60,90]`
- Output: `[1,1,0]`
### Constraints:
- `1 <= temperatures.length <= 10^5`
- `30 <= temperatures[i] <= 100`
## Solution
```py
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
ans = [0] * len(temperatures)
stack = []
for i, temp in enumerate(temperatures):
while stack and temperatures[stack[-1]] < temp:
prev = stack.pop()
ans[prev] = i - prev
stack.append(i)
return ans
```
@@ -0,0 +1,47 @@
---
title: '853. Car Fleet'
description: There are n cars at given miles away from the starting mile 0, traveling to reach the mile target
sidebar:
label: 'Car Fleet'
badge: 'Medium'
---
<Badge variant="accent">Monotonic Stack</Badge>
### Example 1:
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
- Output: `3`
- Explanation: • The cars starting at `10 (speed 2`) and `8 (speed 4`) become a fleet, meeting each other at `12`. The fleet forms at `target`. • The car starting at `0 (speed 1`) does not catch up to any other car, so it is a fleet by itself. • The cars starting at `5 (speed 1`) and `3 (speed 3`) become a fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
### Example 2:
- Input: `target = 10, position = [3], speed = [3]`
- Output: `1`
- Explanation: There is only one car, hence there is only one fleet.
### Example 3:
- Input: `target = 100, position = [0,2,4], speed = [4,2,1]`
- Output: `1`
- Explanation: • The cars starting at `0 (speed 4`) and `2 (speed 2`) become a fleet, meeting each other at `4`. The car starting at `4 (speed 1`) travels to `5`. • Then, the fleet at `4 (speed 2`) and the car at `position 5 (speed 1`) become one fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
### Constraints:
- `n == position.length == speed.length`
- `1 <= n <= 10^5`
- `0 < target <= 10^6`
- `0 <= position[i] < target`
- All the values of `position` are unique.
- `0 < speed[i] <= 10^6`
## Solution
```py
class Solution:
def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
stack = []
for pos, spd in sorted(zip(position, speed), reverse=True):
time = (target - pos) / spd
if not stack or time > stack[-1]:
stack.append(time)
return len(stack)
```
+1
View File
@@ -9,6 +9,7 @@
"doctor": "cd apps/docs && bun run doctor",
"close-solved": "bun apps/cli/close-solved.ts",
"close-topics": "bun apps/cli/close-topics.ts",
"sync-project-fields": "bun apps/cli/sync-project-fields.ts",
"api:dev": "cd apps/api && bun run dev",
"api:deploy": "cd apps/api && bun run deploy",
"api:test": "cd apps/api && bun test src",
@@ -0,0 +1,50 @@
"""
739. Daily Temperatures
Difficulty: Medium
https://leetcode.com/problems/daily-temperatures/
──────────────────────────────────────────────────
Given an array of integers temperatures represents the daily
temperatures, return an array answer such that answer[i] is the number
of days you have to wait after the i^th day to get a warmer
temperature. If there is no future day for which this is possible,
keep answer[i] == 0 instead.
Example 1:
Input: temperatures = [73,74,75,71,69,72,76,73]
Output: [1,1,4,2,1,1,0,0]
Example 2:
Input: temperatures = [30,40,50,60]
Output: [1,1,1,0]
Example 3:
Input: temperatures = [30,60,90]
Output: [1,1,0]
Constraints:
• 1 <= temperatures.length <= 10^5
• 30 <= temperatures[i] <= 100
"""
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
ans = [0] * len(temperatures)
stack = []
for i, temp in enumerate(temperatures):
while stack and temperatures[stack[-1]] < temp:
prev = stack.pop()
ans[prev] = i - prev
stack.append(i)
return ans
+98
View File
@@ -0,0 +1,98 @@
"""
853. Car Fleet
Difficulty: Medium
https://leetcode.com/problems/car-fleet/
──────────────────────────────────────────────────
There are n cars at given miles away from the starting mile 0,
traveling to reach the mile target.
You are given two integer arrays position and speed, both of length
n, where position[i] is the starting mile of the i^th car and speed[i]
is the speed of the i^th car in miles per hour.
A car cannot pass another car, but it can catch up and then travel
next to it at the speed of the slower car.
A car fleet is a single car or a group of cars driving next to each
other. The speed of the car fleet is the minimum speed of any car in
the fleet.
If a car catches up to a car fleet at the mile target, it will still
be considered as part of the car fleet.
Return the number of car fleets that will arrive at the destination.
Example 1:
Input: target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]
Output: 3
Explanation:
• The cars starting at 10 (speed 2) and 8 (speed 4) become a fleet,
meeting each other at 12. The fleet forms at target.
• The car starting at 0 (speed 1) does not catch up to any other
car, so it is a fleet by itself.
• The cars starting at 5 (speed 1) and 3 (speed 3) become a fleet,
meeting each other at 6. The fleet moves at speed 1 until it reaches
target.
Example 2:
Input: target = 10, position = [3], speed = [3]
Output: 1
Explanation:
There is only one car, hence there is only one fleet.
Example 3:
Input: target = 100, position = [0,2,4], speed = [4,2,1]
Output: 1
Explanation:
• The cars starting at 0 (speed 4) and 2 (speed 2) become a fleet,
meeting each other at 4. The car starting at 4 (speed 1) travels to 5.
• Then, the fleet at 4 (speed 2) and the car at position 5 (speed 1)
become one fleet, meeting each other at 6. The fleet moves at speed 1
until it reaches target.
Constraints:
• n == position.length == speed.length
• 1 <= n <= 10^5
• 0 < target <= 10^6
• 0 <= position[i] < target
• All the values of position are unique.
• 0 < speed[i] <= 10^6
"""
class Solution:
def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
stack = []
for pos, spd in sorted(zip(position, speed), reverse=True):
time = (target - pos) / spd
if not stack or time > stack[-1]:
stack.append(time)
return len(stack)